mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d54addd61a
commit
8528c3b4fe
54 files changed
+2343
-324
No files matched your search
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.6] — 2026-05-29 — Pure-history detail page, legacy step consolidation, egress finish
|
||||
|
||||
v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns `/medications/[id]` into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the `safeFetch` wrapper started.
|
||||
|
||||
### Added
|
||||
|
||||
- **Legacy step consolidation** — a pg-boss job (`STEP_CONSOLIDATION_QUEUE`) folds pre-v1.5.0 granular `ACTIVITY_STEPS` rows into one canonical daily-total row per user per day, anchored to the user's timezone, and soft-deletes the originals. A day that already carries a post-v1.5.0 `stats:` total is left untouched (no double-count) while its legacy rows are still tombstoned. Boot-time discovery enqueues only users still holding live legacy rows, so the pass converges to zero work and is idempotent across reboots. Migration `0087` adds a partial index scoped to live step rows to keep the discovery scan cheap.
|
||||
- **`healthlog/safe-fetch-required` ESLint rule** — bans raw `fetch(` outside the wrapper, exempting only genuine same-origin relative paths. Positioned as the author-time egress floor.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/medications/[id]` is now a pure history view** — the Today's-dose card stays on the list page; the detail page reads the medication's past intake, cadence summary, and trends only. Every setting (notifications, phase config, grace window, pause/resume, end, purge) moves into a new `<AdvancedSettingsSheet>` reached from the header. The edit trigger becomes a two-option picker (plan vs. advanced). The `/medications` list page is unchanged apart from matching the detail-nav card glyph to its destination.
|
||||
- **Outbound-fetch migration finished** — every remaining server-side fetch routes through `safeFetch`: Withings (token exchange, refresh, measurements, webhook subscribe/unsubscribe, activity + sleep sync), Codex OAuth + SSE client, the bug-report GitHub comment, and the operator-host calls (Umami script/send/test, GlitchTip envelope + store, Loki). Credential-carrying constant hosts inherit the `redirect: "manual"` default so secrets cannot replay onto a redirect hop; operator-supplied hosts additionally pin `requirePublicHost` for the connect-time IP guard.
|
||||
- **`/api/insights/layout`** is registered in the OpenAPI spec so the contract matches the shipped route.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Intake-edit dialog opened empty** — editing a logged intake row now seeds the dialog from the row's real `takenAt` / `skipped` instead of an empty stub.
|
||||
- **Step consolidation could strand a day** — a minted daily total can collide on the second unique index when a manual step row already sits at the day's canonical-noon instant; the collision rolled back the in-transaction soft-delete and the day re-appeared on every boot. The pass now catches the constraint violation, logs and counts the skip, and steps to the next day.
|
||||
- **Avatar upload over-buffered on the abort path** — the upload body is now read once through a bounded reader that drops retained bytes past the 2 MiB cap, replacing the clone + parallel parse that left the native parse buffering an oversized body after the cap tripped.
|
||||
- **Egress lint exemption let absolute URLs through** — the same-origin exemption now rejects protocol-relative (`//host`) and backslash (`/\host`) forms, which both resolve off-origin.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead wizard landing-intent path** — the pure-history rewrite removed the last caller that passed a landing intent into the medication wizard, so the prop, its state-key segment, and the intent branch in `landingStepForEdit` are gone; the helper reduces to the schedule-count heuristic.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
|
||||
|
||||
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
|
||||
|
||||
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
|
||||
|
||||
@@ -133,6 +133,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe
|
||||
|
||||
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
|
||||
- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
|
||||
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
|
||||
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
|
||||
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
|
||||
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
|
||||
|
||||
+198
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.5.4
|
||||
version: 1.5.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1525,6 +1525,60 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/layout:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Read the calling user's Insights tile layout
|
||||
description: Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the
|
||||
user has not customised it. Mirrors the dashboard-widgets contract.
|
||||
responses:
|
||||
"200":
|
||||
description: The resolved layout (custom or default).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayout"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
put:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Replace the calling user's Insights tile layout
|
||||
description: Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422
|
||||
envelope, matching the dashboard-widgets route.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Layout saved; the normalised layout is echoed back.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutSaved"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Reset the calling user's Insights tile layout
|
||||
description: Clears the persisted layout and returns the default layout. Idempotent.
|
||||
responses:
|
||||
"200":
|
||||
description: Layout reset; the default layout is returned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutReset"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/chat/messages/{id}/feedback:
|
||||
post:
|
||||
tags:
|
||||
@@ -2081,6 +2135,51 @@ components:
|
||||
required:
|
||||
- disableCoach
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
InsightsLayoutBody:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: number
|
||||
const: 1
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- blutdruck
|
||||
- puls
|
||||
- sauerstoff
|
||||
- koerpertemperatur
|
||||
- gewicht
|
||||
- bmi
|
||||
- aktive-energie
|
||||
- workouts
|
||||
- schlaf
|
||||
- ruhepuls
|
||||
- hrv
|
||||
- stimmung
|
||||
- medikamente
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 99
|
||||
required:
|
||||
- id
|
||||
- visible
|
||||
- order
|
||||
required:
|
||||
- version
|
||||
- tiles
|
||||
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
|
||||
schema version; tile ids are a closed enum derived from the server's tile registry."
|
||||
CoachMessageFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4312,6 +4411,57 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayout:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayoutSaved:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayoutReset:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachMessageFeedbackResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4620,6 +4770,53 @@ components:
|
||||
- disableCoach
|
||||
additionalProperties: false
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
InsightsLayoutBodyOutput:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: number
|
||||
const: 1
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- blutdruck
|
||||
- puls
|
||||
- sauerstoff
|
||||
- koerpertemperatur
|
||||
- gewicht
|
||||
- bmi
|
||||
- aktive-energie
|
||||
- workouts
|
||||
- schlaf
|
||||
- ruhepuls
|
||||
- hrv
|
||||
- stimmung
|
||||
- medikamente
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 99
|
||||
required:
|
||||
- id
|
||||
- visible
|
||||
- order
|
||||
additionalProperties: false
|
||||
required:
|
||||
- version
|
||||
- tiles
|
||||
additionalProperties: false
|
||||
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
|
||||
schema version; tile ids are a closed enum derived from the server's tile registry."
|
||||
Medication:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @fileoverview Aggregates the project-local `healthlog/*` ESLint rules
|
||||
* into a single plugin object for the flat config.
|
||||
*
|
||||
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
|
||||
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const queryKeyFactory = require("./queryKey-factory.js");
|
||||
const safeFetchRequired = require("./safe-fetch-required.js");
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
|
||||
"safe-fetch-required": safeFetchRequired,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — outbound `fetch` must route through the
|
||||
* `safeFetch` wrapper.
|
||||
*
|
||||
* v1.5.6 — `src/lib/safe-fetch.ts` is the documented egress entry point:
|
||||
* it pins `redirect: "manual"` + an `AbortSignal.timeout` by default and
|
||||
* gates the connect-time IP pin behind `requirePublicHost`. A raw
|
||||
* `fetch(...)` call under `src/lib/` or `src/app/` bypasses every one of
|
||||
* those defences, so this rule flags it at authoring time and in CI.
|
||||
*
|
||||
* Allowed:
|
||||
* - `safeFetch(...)` — the wrapper itself.
|
||||
* - calls inside `src/lib/safe-fetch.ts` — the wrapper's own `fetch`.
|
||||
* - calls inside `src/lib/safe-fetch-dispatcher.ts` — the pinned dispatcher.
|
||||
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
||||
* mock or assert against `fetch` directly.
|
||||
*
|
||||
* The check is a syntactic `CallExpression` match against a bare
|
||||
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
||||
* `window.fetch(` member forms. `safeFetch` is a distinct identifier and
|
||||
* never matches.
|
||||
*
|
||||
* Same-origin client fetches are exempt: a first argument that is a
|
||||
* string literal (or template head) starting with `/` is a relative,
|
||||
* same-origin request (`fetch("/api/…")`) that never leaves the origin
|
||||
* and has no SSRF / redirect-leak / DNS-rebinding surface. The wrapper
|
||||
* is for OUTBOUND egress to external hosts — absolute URLs or
|
||||
* variable-sourced targets — which is exactly what stays flagged.
|
||||
*
|
||||
* @see src/lib/safe-fetch.ts
|
||||
* @see src/lib/safe-fetch-dispatcher.ts
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Files exempt from the rule — the wrapper internals and the test
|
||||
// surface. Posix-style suffix matches against the absolute filename via
|
||||
// `String#includes`, mirroring the queryKey-factory rule's convention.
|
||||
const EXEMPT_FILES = [
|
||||
"src/lib/safe-fetch.ts",
|
||||
"src/lib/safe-fetch-dispatcher.ts",
|
||||
];
|
||||
|
||||
// Only enforce inside the application source roots. Scripts, config, and
|
||||
// generated code are out of scope.
|
||||
const ENFORCED_ROOTS = ["src/lib/", "src/app/"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isEnforced(filename) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const safeFetchRequiredRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Outbound fetch must route through the safeFetch wrapper (src/lib/safe-fetch.ts).",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
rawFetch:
|
||||
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`.
|
||||
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
||||
if (
|
||||
callee.type === "MemberExpression" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
callee.property.name === "fetch" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
(callee.object.name === "globalThis" ||
|
||||
callee.object.name === "window" ||
|
||||
callee.object.name === "self")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSameOriginRelative(arg) {
|
||||
if (!arg) return false;
|
||||
// A single leading `/` not followed by `/` or `\`. `//evil.com` is a
|
||||
// protocol-relative absolute URL and `/\evil.com` normalises to the
|
||||
// same under WHATWG parsing — both resolve off-origin, so neither is
|
||||
// a same-origin path and the wrapper must still cover them.
|
||||
const isRelativeHead = (head) => /^\/(?![/\\])/.test(head);
|
||||
// `fetch("/api/…")`
|
||||
if (arg.type === "Literal" && typeof arg.value === "string") {
|
||||
return isRelativeHead(arg.value);
|
||||
}
|
||||
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
||||
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
||||
return isRelativeHead(arg.quasis[0].value.cooked ?? "");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isFetchCallee(node.callee)) return;
|
||||
// Exempt same-origin relative-path requests — they never leave
|
||||
// the origin, so the wrapper's outbound defences do not apply.
|
||||
if (isSameOriginRelative(node.arguments[0])) return;
|
||||
context.report({ node, messageId: "rawFetch" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = safeFetchRequiredRule;
|
||||
+13
-2
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js";
|
||||
import healthlogPlugin from "./eslint-plugins/healthlog/index.js";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
@@ -17,6 +17,10 @@ const eslintConfig = defineConfig([
|
||||
// worktree on an older commit can't poison `pnpm lint` locally.
|
||||
// CI never sees this path; the rule is for the dev environment.
|
||||
".claude/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
// .exports) by ESLint convention; it is not application source and
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
"eslint-plugins/**",
|
||||
]),
|
||||
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
|
||||
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
|
||||
@@ -27,7 +31,14 @@ const eslintConfig = defineConfig([
|
||||
// whitelist + rationale.
|
||||
{
|
||||
plugins: { healthlog: healthlogPlugin },
|
||||
rules: { "healthlog/queryKey-factory": "error" },
|
||||
rules: {
|
||||
"healthlog/queryKey-factory": "error",
|
||||
// v1.5.6 — every outbound fetch under src/lib + src/app must route
|
||||
// through the safeFetch wrapper (manual-redirect + timeout, and the
|
||||
// requirePublicHost connect-time pin). The wrapper internals and
|
||||
// test files are exempt; see the rule file for the allowlist.
|
||||
"healthlog/safe-fetch-required": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token kopiert",
|
||||
"copyToken": "Token kopieren",
|
||||
"mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Plan bearbeiten",
|
||||
"advancedOption": "Erweiterte Einstellungen"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweiterte Einstellungen"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Edit plan",
|
||||
"advancedOption": "Advanced settings"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+8
-1
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Editar plan",
|
||||
"advancedOption": "Ajustes avanzados"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Ajustes avanzados"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3537,4 +3544,4 @@
|
||||
"dailyTitle": "Registrar el ánimo",
|
||||
"dailyBody": "¿Cómo te sientes hoy?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Modifier le plan",
|
||||
"advancedOption": "Paramètres avancés"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Paramètres avancés"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3537,4 +3544,4 @@
|
||||
"dailyTitle": "Enregistrer votre humeur",
|
||||
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Modifica piano",
|
||||
"advancedOption": "Impostazioni avanzate"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Impostazioni avanzate"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3537,4 +3544,4 @@
|
||||
"dailyTitle": "Registra il tuo umore",
|
||||
"dailyBody": "Come ti senti oggi?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1256,6 +1256,13 @@
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Edytuj plan",
|
||||
"advancedOption": "Ustawienia zaawansowane"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Ustawienia zaawansowane"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3537,4 +3544,4 @@
|
||||
"dailyTitle": "Zapisz nastrój",
|
||||
"dailyBody": "Jak się dzisiaj czujesz?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- v1.5.6 — accelerate the legacy step-consolidation discovery query.
|
||||
--
|
||||
-- The boot-time consolidation pass (`enqueueBootTimeStepConsolidation`
|
||||
-- in src/lib/jobs/step-consolidation.ts) repeatedly scans for users
|
||||
-- still holding live pre-v1.5.0 granular step rows: `ACTIVITY_STEPS`
|
||||
-- rows that are not tombstoned and whose `external_id` is NULL or does
|
||||
-- not carry the `stats:HKQuantityTypeIdentifierStepCount:` daily-total
|
||||
-- prefix. On an account with a multi-year HealthKit history that
|
||||
-- predicate matches hundreds-of-thousands of rows before the
|
||||
-- consolidation tombstones them.
|
||||
--
|
||||
-- A partial index keyed on the legacy-candidate predicate keeps both
|
||||
-- the per-boot discovery scan and the per-user `findMany` cheap, and —
|
||||
-- because the index only covers `deleted_at IS NULL` rows — it shrinks
|
||||
-- to (eventually) empty as the pass converges, so it carries no
|
||||
-- steady-state write cost once every legacy row is consolidated.
|
||||
--
|
||||
-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column
|
||||
-- add, no backfill, no rewrite of `measurements`.
|
||||
--
|
||||
-- Lock note (conscious trade-off): this is a plain, non-`CONCURRENTLY`
|
||||
-- index build, so it runs inside Prisma's migration transaction and
|
||||
-- holds an ACCESS EXCLUSIVE lock on `measurements` for the duration of
|
||||
-- the build — reads and writes to the table block until it completes.
|
||||
-- The `deleted_at IS NULL` + `type = 'ACTIVITY_STEPS'` partial predicate
|
||||
-- keeps the index tiny (most tenants carry no legacy step rows at all,
|
||||
-- and the covered set shrinks to empty as the pass converges), so on a
|
||||
-- typical self-host the build is sub-second and the lock is not felt.
|
||||
-- The largest multi-year HealthKit tenants are the ones who both need
|
||||
-- this index and would feel a longer lock; for those, run the deploy at
|
||||
-- a low-traffic window. A `CONCURRENTLY` build cannot run inside the
|
||||
-- migration transaction and is not warranted given the partial-index
|
||||
-- size on the overwhelming majority of deployments.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_legacy_step_consolidation_idx"
|
||||
ON "measurements" ("user_id", "measured_at")
|
||||
WHERE "type" = 'ACTIVITY_STEPS'
|
||||
AND "deleted_at" IS NULL;
|
||||
@@ -610,6 +610,11 @@ model Measurement {
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
@@index([externalId])
|
||||
// `measurements_legacy_step_consolidation_idx` is a partial index
|
||||
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
|
||||
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
|
||||
// discovery scan. Prisma can't express a partial-predicate index, so
|
||||
// it lives in raw migration SQL and is intentionally omitted here.
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
|
||||
@@ -89,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastDetails = "";
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
|
||||
@@ -159,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await fetch(
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -26,9 +26,16 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const response = await safeFetch(settings.umamiScriptUrl, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const response = await safeFetch(
|
||||
settings.umamiScriptUrl,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
// Operator-configured host — pin the connect-time IP so a low-TTL
|
||||
// DNS record cannot rebind the fetch at an internal range between
|
||||
// the input-time `isPublicUrl` accept and the socket connect.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return new NextResponse(NOOP_SCRIPT, {
|
||||
status: 200,
|
||||
|
||||
@@ -74,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastResponseStatus = 404;
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
lastResponseStatus = upstream.status;
|
||||
|
||||
|
||||
@@ -146,6 +146,41 @@ describe("POST /api/user/avatar", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
// Build a multipart body as a streamed ReadableStream with NO
|
||||
// Content-Length header — the shape a `Transfer-Encoding: chunked`
|
||||
// upload takes. The bounded stream reader must trip before the body
|
||||
// is fully buffered.
|
||||
const boundary = "----healthlogtest";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41);
|
||||
const parts = [head, oversize, tail];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const p of parts) {
|
||||
controller.enqueue(new Uint8Array(p));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const req = new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: stream,
|
||||
// Node's fetch requires `duplex: "half"` for a streamed body.
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Plain text content — no JPEG/PNG/WebP magic.
|
||||
|
||||
@@ -62,6 +62,67 @@ interface UploadResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */
|
||||
class BodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body exceeds the configured byte cap");
|
||||
this.name = "BodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a request body stream into a single buffer while counting bytes,
|
||||
* throwing `BodyTooLargeError` the moment the running total passes
|
||||
* `maxBytes`. Bounds the work at the stream level so a chunked /
|
||||
* unbounded upload cannot park past the cap even when no `Content-Length`
|
||||
* header is present. The captured bytes are retained (capped at
|
||||
* `maxBytes`) so the caller can reconstruct `FormData` from them — this
|
||||
* reads the body exactly once, with no clone and no second parser racing
|
||||
* the same body, so a cap trip frees the allocation immediately instead
|
||||
* of leaving a native parse buffering the rest of an oversized upload.
|
||||
*/
|
||||
async function readBoundedBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (!body) return new Uint8Array(0);
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let retained = 0;
|
||||
let overflow = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (overflow) continue;
|
||||
if (retained + value.byteLength > maxBytes) {
|
||||
// Past the cap. Drop everything retained so far and stop
|
||||
// retaining further chunks — memory stays bounded at the cap.
|
||||
// Keep draining the stream to its natural close rather than
|
||||
// cancelling it: an abrupt cancel mid-transfer races the
|
||||
// underlying (undici) producer, which then enqueues into a
|
||||
// closed controller and surfaces as an unhandled rejection.
|
||||
chunks.length = 0;
|
||||
retained = 0;
|
||||
overflow = true;
|
||||
continue;
|
||||
}
|
||||
chunks.push(value);
|
||||
retained += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (overflow) throw new BodyTooLargeError();
|
||||
const out = new Uint8Array(retained);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -94,10 +155,31 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Stream-level cap. The Content-Length pre-flight above only fires when
|
||||
// the header is present; a `Transfer-Encoding: chunked` upload (or any
|
||||
// client that omits the header) skips it, and `request.formData()`
|
||||
// would then buffer the whole stream before the post-parse `file.size`
|
||||
// check can run. Read the body ONCE through a bounded reader that
|
||||
// aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the
|
||||
// FormData from the captured bytes. A single read means the cap trip
|
||||
// frees the allocation immediately — no clone, no tee, and no native
|
||||
// parse left buffering the rest of an oversized body. The post-parse
|
||||
// `file.size` check stays in place as defence-in-depth.
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
// Surface the most useful failure: a cap overflow always wins.
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "stream_size_exceeded" },
|
||||
});
|
||||
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
|
||||
}
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §6 + §9 — medication detail page.
|
||||
* v1.5.6 G-1 — medication detail page as a pure history surface.
|
||||
*
|
||||
* Replaces the v1.5.4 "form retirement" gap that left every action
|
||||
* (Pausieren / Beenden / Notifications / API tokens / Phasen / CSV
|
||||
* import / Verlauf löschen / Medikament löschen) reachable only
|
||||
* through the list-page kebab — which the kebab didn't actually wire
|
||||
* up. The detail page surfaces every restored action in section
|
||||
* order; one phone-scroll reaches every one of them.
|
||||
* Supersedes the v1.5.5 composition that stacked every setting inline.
|
||||
* The page now reads as a calm "Vergangenheit" view: back link → slim
|
||||
* header (name / dose / status + a two-option Bearbeiten dropdown) →
|
||||
* STATIC cadence line → GLP-1 dose-ladder read-only visualisation →
|
||||
* intake-history table. Every setting — Notifications, API tokens,
|
||||
* Phasen, CSV import, grace and the destructive zone — moves into the
|
||||
* `<AdvancedSettingsSheet>`, reached from the dropdown's "Erweiterte
|
||||
* Einstellungen" item. "Plan bearbeiten" opens the wizard at Step 1.
|
||||
*
|
||||
* Recurring variant: 8 sections (header → today's dose → cadence →
|
||||
* dose ladder / phases → intake history preview → notifications →
|
||||
* settings → destructive zone).
|
||||
*
|
||||
* One-shot variant: 5 sections (header → today's-dose-or-logged →
|
||||
* cadence static line → intake history single row → destructive
|
||||
* zone). Per D-3 §6 the wizard handles cadence edit via the header
|
||||
* pencil so the cadence row hides its own edit affordance.
|
||||
*
|
||||
* Paused variant: same as the recurring/one-shot layout; the header
|
||||
* status pill flips and the today's-dose card disables both buttons
|
||||
* with a muted helper. Settings and destructive zone keep rendering.
|
||||
* One-shot variant drops the dose-ladder section and renders the
|
||||
* static `Einmalig am …` line in place of the cadence summary.
|
||||
*
|
||||
* Reads:
|
||||
*
|
||||
* - `medicationDetail(id)` for the medication snapshot.
|
||||
* - `medicationIntakeList(id, …)` for the last-14-grouped preview
|
||||
* (the v2 list owns its own paging).
|
||||
* - `notificationsStatus()` + `authMe()` on the notifications
|
||||
* section.
|
||||
* - `medicationTitration(id)` on the GLP-1 dose ladder.
|
||||
* - `medicationIntakeList(id, …)` for `intakeCount`, which the
|
||||
* sheet's destructive zone needs.
|
||||
*
|
||||
* Mutations all cascade through `medicationDependentKeys` (which now
|
||||
* Mutations all cascade through `medicationDependentKeys` (which
|
||||
* includes the `compliance-chart-inline` prefix per D-3 §10).
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -49,16 +38,13 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
import { TodaysDoseCard } from "@/components/medications/todays-dose-card";
|
||||
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
|
||||
@@ -135,16 +121,6 @@ function snapshotToWizardPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function isToday(iso: string): boolean {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
return (
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -157,9 +133,8 @@ export default function MedicationDetailPage({
|
||||
const formatters = useFormatters();
|
||||
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardIntent, setWizardIntent] = useState<
|
||||
"cadence" | "name" | undefined
|
||||
>(undefined);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,6 +176,14 @@ export default function MedicationDetailPage({
|
||||
enabled: isAuthenticated && !!medication,
|
||||
});
|
||||
|
||||
// v1.5.6 F-1 M-2 — memoise the wizard payload so the header dropdown
|
||||
// + wizard mount share one object identity per medication snapshot
|
||||
// instead of recomputing it on every render.
|
||||
const wizardPayload = useMemo<MedicationPayload | null>(
|
||||
() => (medication ? snapshotToWizardPayload(medication) : null),
|
||||
[medication],
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -246,23 +229,18 @@ export default function MedicationDetailPage({
|
||||
}
|
||||
|
||||
const oneShot = medication.oneShot === true;
|
||||
const todayEvent = intakeList?.events.find(
|
||||
(e) =>
|
||||
(e.takenAt && isToday(e.takenAt)) ||
|
||||
(e.skipped && isToday(e.scheduledFor)),
|
||||
);
|
||||
const intakeCount = intakeList?.meta.total ?? 0;
|
||||
const primaryGrace =
|
||||
medication.schedules[0]?.reminderGraceMinutes ?? null;
|
||||
// `wizardPayload` is non-null here — the early returns above bail
|
||||
// before this point whenever `medication` is undefined.
|
||||
const payload = wizardPayload as MedicationPayload;
|
||||
|
||||
function openWizardWithIntent(intent: "cadence" | "name") {
|
||||
setWizardIntent(intent);
|
||||
setWizardOpen(true);
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setWizardOpen(false);
|
||||
setWizardIntent(undefined);
|
||||
// v1.5.6 G-1 §5 — sibling-swap: close the advanced sheet first, then
|
||||
// open the phase sheet so the two never stack (modal depth ≤ 2).
|
||||
function openPhaseSheet() {
|
||||
setAdvancedOpen(false);
|
||||
setPhaseSheetOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -279,30 +257,21 @@ export default function MedicationDetailPage({
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* §9.1 — Header band */}
|
||||
{/* G-1 §3.2 — Header band. The Bearbeiten dropdown owns the
|
||||
wizard ("Plan bearbeiten") + the advanced sheet ("Erweiterte
|
||||
Einstellungen"); the page owns both open-states. */}
|
||||
<MedicationDetailHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
onEdit={() => openWizardWithIntent("name")}
|
||||
onEditPlan={() => setWizardOpen(true)}
|
||||
onOpenAdvanced={() => setAdvancedOpen(true)}
|
||||
/>
|
||||
|
||||
{/* §9.2 — Today's dose card */}
|
||||
<TodaysDoseCard
|
||||
medicationId={id}
|
||||
active={medication.active}
|
||||
oneShot={oneShot}
|
||||
scheduledForToday={!oneShot || !todayEvent}
|
||||
alreadyTakenAt={
|
||||
todayEvent && !todayEvent.skipped ? todayEvent.takenAt : null
|
||||
}
|
||||
alreadySkipped={Boolean(todayEvent?.skipped)}
|
||||
/>
|
||||
|
||||
{/* §9.3 — Cadence summary. One-shot: static line; the
|
||||
`hideEdit` flag suppresses the row's own edit affordance
|
||||
because the header pencil routes the wizard. */}
|
||||
{/* G-1 §3.3 — Static cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row with
|
||||
`hideEdit` (cadence editing lives in the wizard). */}
|
||||
{oneShot ? (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
@@ -318,13 +287,14 @@ export default function MedicationDetailPage({
|
||||
</Card>
|
||||
) : (
|
||||
<CadenceSummaryRow
|
||||
medication={snapshotToWizardPayload(medication)}
|
||||
onEdit={() => openWizardWithIntent("cadence")}
|
||||
medication={payload}
|
||||
hideEdit
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* §9.4 — Dose ladder / Phasen (GLP-1 only, recurring path
|
||||
only — one-shot variant drops this) */}
|
||||
{/* G-1 §3.4 — Dose ladder / Phasen visualisation (read-only,
|
||||
GLP-1 recurring only). The editor lives in the sheet. */}
|
||||
{!oneShot && medication.treatmentClass === "GLP1" && (
|
||||
<>
|
||||
<DrugLevelChart
|
||||
@@ -338,54 +308,52 @@ export default function MedicationDetailPage({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* §9.5 — Intake history preview */}
|
||||
{/* G-1 §3.5 — Intake history table (primary surface). */}
|
||||
<IntakeHistoryPreview
|
||||
medicationId={id}
|
||||
importOpen={importOpen}
|
||||
onImportOpenChange={setImportOpen}
|
||||
/>
|
||||
|
||||
{/* §9.6 + §9.7 — Notifications + Settings: recurring path only. */}
|
||||
{!oneShot && (
|
||||
<>
|
||||
<NotificationsSection
|
||||
medicationId={id}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
<Separator className="opacity-0" />
|
||||
<SettingsSection
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
reminderGraceMinutes={primaryGrace}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* §9.8 — Verwaltung & Gefahrenzone */}
|
||||
<DestructiveZoneSection
|
||||
{/* G-1 §5 — Advanced settings sheet. Hosts Notifications →
|
||||
Settings → destructive zone. The Phasen button sibling-swaps
|
||||
to the phase sheet below. */}
|
||||
<AdvancedSettingsSheet
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
active={medication.active}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
reminderGraceMinutes={primaryGrace}
|
||||
intakeCount={intakeCount}
|
||||
onRequestPhaseSheet={openPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* Wizard mount — edit-only on the detail page. Intent flips
|
||||
between "name" (header pencil → Step 1) and "cadence"
|
||||
(cadence row pencil → Step 5). Cancel returns to the detail
|
||||
page (the success branch refreshes the medication detail
|
||||
via the `medicationDependentKeys` cascade). */}
|
||||
{/* G-1 §5 — phase sheet mounted as a sibling of the advanced
|
||||
sheet so they never stack (GLP-1 + course window only). */}
|
||||
{!oneShot &&
|
||||
medication.treatmentClass === "GLP1" &&
|
||||
Boolean(medication.startsOn) &&
|
||||
Boolean(medication.endsOn) && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={id}
|
||||
open={phaseSheetOpen}
|
||||
onOpenChange={setPhaseSheetOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard mount — edit-only, lands on Step 1 (no landing
|
||||
intent). Cancel + success both close via the cascade. */}
|
||||
<MedicationWizardDialog
|
||||
open={wizardOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeWizard();
|
||||
}}
|
||||
onOpenChange={setWizardOpen}
|
||||
mode="edit"
|
||||
initial={snapshotToWizardPayload(medication)}
|
||||
landingIntent={wizardIntent}
|
||||
onSuccess={() => closeWizard()}
|
||||
initial={payload}
|
||||
onSuccess={() => setWizardOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §3 — the `/medications/[id]` detail page is a pure
|
||||
* history surface.
|
||||
*
|
||||
* Source-level guards (the page is a client component with `use()`
|
||||
* + hooks, so a full render is heavier than the contract warrants):
|
||||
* - no `TodaysDoseCard` import or render (removed from the detail
|
||||
* page; it lives only on the list page);
|
||||
* - no `isToday` / `todayEvent` derivations (dead once the card is
|
||||
* gone);
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing (the header dropdown is the only wizard entry, landing
|
||||
* on Step 1);
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders the
|
||||
* cadence row with `hideEdit`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/medications/[id]/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("medication detail page is a pure history surface (G-1 §3)", () => {
|
||||
it("does not import or render TodaysDoseCard", () => {
|
||||
expect(source).not.toContain("TodaysDoseCard");
|
||||
expect(source).not.toContain("todays-dose-card");
|
||||
});
|
||||
|
||||
it("drops the dead isToday / todayEvent derivations", () => {
|
||||
expect(source).not.toContain("isToday");
|
||||
expect(source).not.toContain("todayEvent");
|
||||
});
|
||||
|
||||
it("drops the landingIntent plumbing", () => {
|
||||
expect(source).not.toContain("landingIntent");
|
||||
expect(source).not.toContain("wizardIntent");
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("hosts the AdvancedSettingsSheet and the static cadence row", () => {
|
||||
expect(source).toContain("AdvancedSettingsSheet");
|
||||
expect(source).toContain("hideEdit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts the three moved sections in routine → rare →
|
||||
* destructive order: Notifications → Settings → destructive zone.
|
||||
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
|
||||
* does not materialise, so we mock it to a passthrough that renders
|
||||
* children only when `open`, and mock the three sections to ordered
|
||||
* markers. The test pins:
|
||||
* - the three sections render in the documented order;
|
||||
* - `onRequestPhaseSheet` threads through to `<SettingsSection>`;
|
||||
* - the destructive zone's `onAfterAction` closes the sheet;
|
||||
* - the sheet renders nothing when `open` is false.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/notifications-section",
|
||||
() => ({
|
||||
NotificationsSection: () => <div data-slot="mock-notifications" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
SettingsSection: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-settings"
|
||||
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/destructive-zone-section",
|
||||
() => ({
|
||||
DestructiveZoneSection: ({
|
||||
onAfterAction,
|
||||
}: {
|
||||
onAfterAction?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
data-slot="mock-destructive"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
onClick={onAfterAction}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
|
||||
function baseProps(open: boolean, onRequestPhaseSheet?: () => void) {
|
||||
return {
|
||||
open,
|
||||
onOpenChange: () => {},
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
active: true,
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
notificationsEnabled: true,
|
||||
reminderGraceMinutes: 30,
|
||||
intakeCount: 5,
|
||||
onRequestPhaseSheet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (G-1 §5)", () => {
|
||||
it("renders the three sections in routine → rare → destructive order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
const notif = html.indexOf("mock-notifications");
|
||||
const settings = html.indexOf("mock-settings");
|
||||
const destructive = html.indexOf("mock-destructive");
|
||||
expect(notif).toBeGreaterThan(-1);
|
||||
expect(settings).toBeGreaterThan(notif);
|
||||
expect(destructive).toBeGreaterThan(settings);
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the settings section", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
expect(html).toContain('data-has-phase-swap="yes"');
|
||||
});
|
||||
|
||||
it("passes an onAfterAction to the destructive zone so it can close the sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-has-after-action="yes"');
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(false)} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("titles the sheet with the advanced-settings key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("medications.detail.advanced.title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §9 Q1 — IntakeEditDialog seeds from the row's real
|
||||
* values.
|
||||
*
|
||||
* Before this pass the preview opened the dialog with a stub
|
||||
* `{ id, takenAt: null, skipped: false }`, so "Bearbeiten" always
|
||||
* showed an empty form. The fix threads the whole `IntakeEvent` up
|
||||
* through `onEditIntake(event)`; this test pins that the dialog's
|
||||
* datetime-local input + skipped switch seed from the event passed in.
|
||||
*
|
||||
* `<Dialog>` wraps a Radix portal that `renderToStaticMarkup` does not
|
||||
* materialise, so we mock the dialog primitives to passthroughs.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
|
||||
describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
|
||||
it("seeds the datetime-local input from the event's real takenAt", () => {
|
||||
// 2026-03-04T09:30 in local time — the dialog formats to
|
||||
// `YYYY-MM-DDTHH:mm` in the user's timezone.
|
||||
const taken = new Date(2026, 2, 4, 9, 30).toISOString();
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-1", takenAt: taken, skipped: false }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('value="2026-03-04T09:30"');
|
||||
});
|
||||
|
||||
it("seeds the skipped switch from a skipped event (no stub default)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-2", takenAt: null, skipped: true }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Radix Switch reflects checked state via data-state.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("renders nothing when no event is selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog medicationId="med-1" event={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const mounjaro: Glp1Medication = {
|
||||
};
|
||||
|
||||
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
it("both cards render History + Pencil header buttons", () => {
|
||||
it("both cards render the detail-nav + Pencil header buttons", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, ramipril.id);
|
||||
seedCompliance(client, mounjaro.id);
|
||||
@@ -140,7 +140,10 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
expect(html).toContain("lucide-history");
|
||||
// v1.5.6 F-1 M-1 — the detail-nav icon swapped from the
|
||||
// history glyph to a neutral chevron now that it routes to the
|
||||
// detail page rather than the history sub-route.
|
||||
expect(html).toContain("lucide-chevron-right");
|
||||
expect(html).toContain("lucide-pencil");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,35 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
|
||||
// does not materialise while closed; mock the primitives to
|
||||
// passthroughs so the trigger + menu items are reachable in static
|
||||
// markup and the `onSelect` wiring is assertable (G-1 §4).
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown">{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-trigger">{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button onClick={onSelect} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
|
||||
function render(props: {
|
||||
@@ -40,7 +69,8 @@ function render(props: {
|
||||
dose={props.dose ?? "5 mg"}
|
||||
active={props.active ?? true}
|
||||
endsOn={props.endsOn}
|
||||
onEdit={() => {}}
|
||||
onEditPlan={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -79,6 +109,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => {
|
||||
expect(headingIndex).toBeLessThan(buttonIndex);
|
||||
});
|
||||
|
||||
it("renders the two-option Bearbeiten picker (G-1 §4)", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("medications.detail.edit.planOption");
|
||||
expect(html).toContain("medications.detail.edit.advancedOption");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-plan"');
|
||||
expect(html).toContain('data-slot="medication-detail-edit-advanced"');
|
||||
});
|
||||
|
||||
it("orders Plan bearbeiten above Erweiterte Einstellungen", () => {
|
||||
const html = render({ active: true });
|
||||
const plan = html.indexOf("medications.detail.edit.planOption");
|
||||
const advanced = html.indexOf("medications.detail.edit.advancedOption");
|
||||
expect(plan).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(plan);
|
||||
});
|
||||
|
||||
it("status pill text always renders (no colour-only state)", () => {
|
||||
// Regardless of theme support the label MUST be in the DOM so a
|
||||
// screen reader announces "Aktiv" even when the dot's hue is
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — Advanced settings sheet.
|
||||
*
|
||||
* Hosts the three settings sections the detail page no longer renders
|
||||
* inline: Notifications, Settings (API tokens / CSV stub / Phasen /
|
||||
* grace) and the destructive zone, stacked top → bottom in
|
||||
* routine → rare → destructive order inside a `<ResponsiveSheet>`.
|
||||
*
|
||||
* The Phasen button is sibling-swapped, not nested: when the user
|
||||
* opens it, `onRequestPhaseSheet` closes this sheet first and lets the
|
||||
* parent open the phase sheet so the modal stack never exceeds two
|
||||
* (G-1 §5 / D-3 §10 invariant 7). Each moved section saves itself, so
|
||||
* the footer slot stays empty. On a non-navigating destructive success
|
||||
* the sheet closes; the Tier 3b delete already navigates to
|
||||
* `/medications`.
|
||||
*/
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdvancedSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
active: boolean;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
active,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
onRequestPhaseSheet,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
className="sm:max-w-lg"
|
||||
bodyClassName="gap-6"
|
||||
>
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="advanced-settings-sheet-body"
|
||||
>
|
||||
{/* routine */}
|
||||
<NotificationsSection
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
|
||||
{/* rare */}
|
||||
<SettingsSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* destructive */}
|
||||
<DestructiveZoneSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* Renders the medication's cadence as a plain-language line (e.g.
|
||||
* "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs",
|
||||
* "Monatlich am 15.") with an optional course-window sub-line, and an
|
||||
* edit pencil that opens the wizard with `landingIntent: "cadence"`.
|
||||
* optional edit pencil whose `onEdit` the caller wires (suppressed via
|
||||
* `hideEdit` on the read-only detail surface).
|
||||
* For one-shot medications the line collapses to
|
||||
* `Einmalig am DD.MM.` and the edit affordance is suppressed (the
|
||||
* header pencil owns the wizard route per D-3 §6 one-shot variant).
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
@@ -337,7 +337,7 @@ export function Glp1MedicationCard({
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
export interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
@@ -108,9 +108,12 @@ interface IntakeHistoryListV2Props {
|
||||
pageSize?: number;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action.
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action. v1.5.6
|
||||
* G-1 §9 Q1: threads the whole `IntakeEvent` (not just the id) so
|
||||
* the edit dialog seeds from the row's real `takenAt` / `skipped`
|
||||
* instead of an empty stub.
|
||||
*/
|
||||
onEditIntake?: (eventId: string) => void;
|
||||
onEditIntake?: (event: IntakeEvent) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
|
||||
* renders a kebab `<DropdownMenu>` with a Delete action.
|
||||
@@ -380,7 +383,7 @@ export function IntakeHistoryListV2({
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onEditIntake(event.id)}
|
||||
onSelect={() => onEditIntake(event)}
|
||||
data-slot="intake-history-row-edit"
|
||||
>
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Flame,
|
||||
Pencil,
|
||||
Loader2,
|
||||
History,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -275,7 +275,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -18,10 +18,16 @@
|
||||
* destructive zone, not on the header (E-2 M-1).
|
||||
*/
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { ChevronDown, Pencil } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MedicationDetailHeaderProps {
|
||||
@@ -30,11 +36,15 @@ export interface MedicationDetailHeaderProps {
|
||||
active: boolean;
|
||||
endsOn?: string | null;
|
||||
/**
|
||||
* Fires when the user taps the Bearbeiten pencil. The detail page
|
||||
* routes this to the wizard with `landingIntent: "name"` so the
|
||||
* user lands on Step 1.
|
||||
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
|
||||
* Step 1). Fired from the Bearbeiten dropdown's first item.
|
||||
*/
|
||||
onEdit: () => void;
|
||||
onEditPlan: () => void;
|
||||
/**
|
||||
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
|
||||
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
|
||||
*/
|
||||
onOpenAdvanced: () => void;
|
||||
}
|
||||
|
||||
type Status = "active" | "paused" | "ended";
|
||||
@@ -58,7 +68,8 @@ export function MedicationDetailHeader({
|
||||
dose,
|
||||
active,
|
||||
endsOn,
|
||||
onEdit,
|
||||
onEditPlan,
|
||||
onOpenAdvanced,
|
||||
}: MedicationDetailHeaderProps) {
|
||||
const { t } = useTranslations();
|
||||
const status = resolveStatus(active, endsOn);
|
||||
@@ -98,16 +109,34 @@ export function MedicationDetailHeader({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={onEditPlan}
|
||||
data-slot="medication-detail-edit-plan"
|
||||
>
|
||||
{t("medications.detail.edit.planOption")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={onOpenAdvanced}
|
||||
data-slot="medication-detail-edit-advanced"
|
||||
>
|
||||
{t("medications.detail.edit.advancedOption")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
|
||||
*
|
||||
* When the hosting `<AdvancedSettingsSheet>` passes
|
||||
* `onRequestPhaseSheet`, the section must NOT mount its own
|
||||
* `<PhaseConfigSheet>` — the parent orchestrates the swap (close the
|
||||
* advanced sheet, then open the phase sheet) so the two never stack.
|
||||
* Without the callback the section keeps owning its own phase sheet,
|
||||
* preserving the standalone-surface fallback.
|
||||
*
|
||||
* `<PhaseConfigSheet>` is mocked to a marker so its presence /
|
||||
* absence is observable in static markup regardless of open-state.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
|
||||
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
|
||||
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
|
||||
}));
|
||||
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={makeClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// GLP-1 + a set course window so the Phasen row + button mount.
|
||||
const glp1Props = {
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
reminderGraceMinutes: 30,
|
||||
};
|
||||
|
||||
describe("SettingsSection phase sibling-swap (G-1 §5)", () => {
|
||||
it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => {
|
||||
const html = render(
|
||||
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).not.toContain("mock-phase-config-sheet");
|
||||
});
|
||||
|
||||
it("self-mounts the phase sheet on the standalone surface (no callback)", () => {
|
||||
const html = render(<SettingsSection {...glp1Props} />);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).toContain("mock-phase-config-sheet");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,13 @@ export interface DestructiveZoneSectionProps {
|
||||
medicationName: string;
|
||||
active: boolean;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — fires after a non-navigating destructive success
|
||||
* (Tier 1 pause/resume, Tier 2 end, Tier 3a purge) so a hosting
|
||||
* sheet can close itself. Tier 3b delete navigates instead and does
|
||||
* not call this. Absent on the standalone surface.
|
||||
*/
|
||||
onAfterAction?: () => void;
|
||||
}
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
@@ -66,6 +73,7 @@ export function DestructiveZoneSection({
|
||||
medicationName,
|
||||
active,
|
||||
intakeCount,
|
||||
onAfterAction,
|
||||
}: DestructiveZoneSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -107,6 +115,7 @@ export function DestructiveZoneSection({
|
||||
? t("medications.detail.zone.pause.pausedToast")
|
||||
: t("medications.detail.zone.pause.resumedToast"),
|
||||
);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
setPaused(previous);
|
||||
toast.error(t("medications.detail.zone.pause.failed"));
|
||||
@@ -132,6 +141,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.end.failed"));
|
||||
} finally {
|
||||
@@ -154,6 +164,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.purge.failed"));
|
||||
} finally {
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import {
|
||||
IntakeHistoryListV2,
|
||||
type IntakeEvent,
|
||||
} from "@/components/medications/intake-history-list-v2";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -61,7 +64,7 @@ export function IntakeHistoryPreview({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
@@ -206,7 +209,7 @@ export function IntakeHistoryPreview({
|
||||
<IntakeHistoryListV2
|
||||
medicationId={medicationId}
|
||||
pageSize={14}
|
||||
onEditIntake={setEditingId}
|
||||
onEditIntake={setEditingEvent}
|
||||
onDeleteIntake={setPendingDeleteId}
|
||||
selection={{
|
||||
mode: "multi",
|
||||
@@ -226,19 +229,25 @@ export function IntakeHistoryPreview({
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
|
||||
<IntakeImportDialog
|
||||
medicationId={importOpen ? medicationId : null}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={medicationId}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IntakeEditDialog
|
||||
medicationId={medicationId}
|
||||
event={
|
||||
editingId
|
||||
? { id: editingId, takenAt: null, skipped: false }
|
||||
editingEvent
|
||||
? {
|
||||
id: editingEvent.id,
|
||||
takenAt: editingEvent.takenAt,
|
||||
skipped: editingEvent.skipped,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setEditingId(null)}
|
||||
onClose={() => setEditingEvent(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
|
||||
@@ -40,6 +40,15 @@ export interface SettingsSectionProps {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
|
||||
* Phasen button hands control to the parent (which closes the
|
||||
* hosting sheet before opening `<PhaseConfigSheet>`) instead of the
|
||||
* self-managed `phaseSheetOpen` state, so the two sheets never
|
||||
* stack. Absent on the standalone surface, where the section owns
|
||||
* its own phase sheet.
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -49,6 +58,7 @@ export function SettingsSection({
|
||||
startsOn,
|
||||
endsOn,
|
||||
reminderGraceMinutes,
|
||||
onRequestPhaseSheet,
|
||||
}: SettingsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -136,7 +146,11 @@ export function SettingsSection({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPhaseSheetOpen(true)}
|
||||
onClick={() =>
|
||||
onRequestPhaseSheet
|
||||
? onRequestPhaseSheet()
|
||||
: setPhaseSheetOpen(true)
|
||||
}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-phase-management-button"
|
||||
>
|
||||
@@ -196,7 +210,7 @@ export function SettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPhases && (
|
||||
{showPhases && !onRequestPhaseSheet && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={medicationId}
|
||||
open={phaseSheetOpen}
|
||||
|
||||
@@ -87,14 +87,6 @@ export interface MedicationWizardDialogProps {
|
||||
mode: "create" | "edit";
|
||||
/** Required when `mode === "edit"`. */
|
||||
initial?: MedicationPayload;
|
||||
/**
|
||||
* v1.5.5 C-E2-2 — call-site intent that drives the landing step on
|
||||
* an edit-open. The detail page's cadence-summary row passes
|
||||
* `"cadence"` so the user lands on Step 5; the header pencil passes
|
||||
* `"name"` so the user lands on Step 1. Defaults to `undefined`
|
||||
* which keeps the legacy 1-or-8 heuristic.
|
||||
*/
|
||||
landingIntent?: "cadence" | "summary" | "name";
|
||||
/** Fires with the medication id on a successful create / save. */
|
||||
onSuccess?: (id: string) => void;
|
||||
}
|
||||
@@ -113,14 +105,13 @@ const STEP_ICONS = {
|
||||
type StepNumber = keyof typeof STEP_ICONS;
|
||||
|
||||
export function MedicationWizardDialog(props: MedicationWizardDialogProps) {
|
||||
// Key the inner state container on (open + mode + medication id +
|
||||
// landingIntent) so every open gets a fresh state tree AND every
|
||||
// change of intent re-runs the initial-step decision. React unmounts
|
||||
// the inner component when the key changes, which sidesteps the
|
||||
// "setState in useEffect" anti-pattern that resetting on open via an
|
||||
// effect would otherwise trigger. The outer shell stays mounted so
|
||||
// the dialog's close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}:${props.landingIntent ?? "default"}`;
|
||||
// Key the inner state container on (open + mode + medication id) so
|
||||
// every open gets a fresh state tree. React unmounts the inner
|
||||
// component when the key changes, which sidesteps the "setState in
|
||||
// useEffect" anti-pattern that resetting on open via an effect would
|
||||
// otherwise trigger. The outer shell stays mounted so the dialog's
|
||||
// close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}`;
|
||||
return <WizardDialogShell key={stateKey} {...props} />;
|
||||
}
|
||||
|
||||
@@ -129,7 +120,6 @@ function WizardDialogShell({
|
||||
onOpenChange,
|
||||
mode,
|
||||
initial,
|
||||
landingIntent,
|
||||
onSuccess,
|
||||
}: MedicationWizardDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -144,7 +134,7 @@ function WizardDialogShell({
|
||||
const [step, setStep] = useState<StepNumber>(() => {
|
||||
if (mode === "edit" && initial) {
|
||||
const hydrated = hydrateWizardPayload(initial);
|
||||
return landingStepForEdit(hydrated, landingIntent) as StepNumber;
|
||||
return landingStepForEdit(hydrated) as StepNumber;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
@@ -722,63 +722,6 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
|
||||
expect(landingStepForEdit(single)).toBe(1);
|
||||
});
|
||||
|
||||
// v1.5.5 C-E2-2 — `landingStepForEdit` gains an `intent` arg so the
|
||||
// detail-page cadence-summary row can drop the user directly on
|
||||
// Step 5 instead of bouncing them through Step 1 first. One-shot
|
||||
// payloads fall back to Step 8 because the cadence picker is not on
|
||||
// the one-shot path.
|
||||
it("lands on Step 5 for the cadence intent", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_cadence",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(5);
|
||||
expect(landingStepForEdit(payload, "name")).toBe(1);
|
||||
expect(landingStepForEdit(payload, "summary")).toBe(8);
|
||||
});
|
||||
|
||||
it("falls back to Step 8 on cadence intent for a one-shot medication", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_oneshot",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: true,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(8);
|
||||
});
|
||||
|
||||
it("removeSchedule refuses when schedules.length === 1", () => {
|
||||
const payload = emptyWizardPayload();
|
||||
expect(payload.schedules).toHaveLength(1);
|
||||
|
||||
@@ -879,36 +879,12 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate.
|
||||
*
|
||||
* Three call-site intents:
|
||||
*
|
||||
* - `"name"` (default header pencil): land on Step 1 so the user
|
||||
* edits the medication's name, dose, treatment-class as a single
|
||||
* top-down walk. Multi-schedule medications still jump to the
|
||||
* summary list because the muscle memory there is "see every
|
||||
* parallel schedule first".
|
||||
* - `"cadence"`: land directly on Step 5 (the cadence picker). The
|
||||
* v1.5.5 detail page's cadence-summary row uses this so a tap on
|
||||
* the row's edit pencil drops the user one screen away from the
|
||||
* cadence they wanted to change, not two screens away after the
|
||||
* intro + summary.
|
||||
* - undefined: legacy behaviour — Step 1 for single-schedule, Step 8
|
||||
* for multi-schedule.
|
||||
*
|
||||
* One-shot medications collapse Step 5 out of the path, so a
|
||||
* `"cadence"` intent on a one-shot payload falls back to Step 8
|
||||
* (the summary, where the user can flip the one-shot toggle).
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate:
|
||||
* Step 1 for a single-schedule medication (a straight top-down walk),
|
||||
* Step 8 (the summary list) for a multi-schedule one, where the muscle
|
||||
* memory is "see every parallel schedule first".
|
||||
*/
|
||||
export function landingStepForEdit(
|
||||
payload: WizardPayload,
|
||||
intent?: "cadence" | "summary" | "name",
|
||||
): number {
|
||||
if (intent === "cadence") {
|
||||
return payload.mode === "oneShot" ? 8 : 5;
|
||||
}
|
||||
if (intent === "summary") return 8;
|
||||
if (intent === "name") return 1;
|
||||
export function landingStepForEdit(payload: WizardPayload): number {
|
||||
return payload.schedules.length > 1 ? 8 : 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import {
|
||||
getCachedCodexSlug,
|
||||
@@ -283,7 +284,7 @@ export class CodexClient implements AIProvider {
|
||||
): Promise<Response> {
|
||||
const sessionId = randomUUID();
|
||||
const threadId = randomUUID();
|
||||
return fetch(CODEX_ENDPOINT, {
|
||||
return safeFetch(CODEX_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -320,7 +321,10 @@ export class CodexClient implements AIProvider {
|
||||
// Required field; empty array when not asking for reasoning.
|
||||
include: [],
|
||||
}),
|
||||
});
|
||||
// SSE streaming completion — match the 60 s budget the other AI
|
||||
// clients use so a long generation is not clipped by the 15 s
|
||||
// default while still bounding a tar-pit upstream.
|
||||
}, { timeoutMs: 60_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT-OAuth flow — implemented against `docs/codex-protocol-spec.md`,
|
||||
@@ -173,7 +174,7 @@ export interface DeviceCodeStart {
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(): Promise<DeviceCodeStart> {
|
||||
const res = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
||||
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: getCodexClientId() }),
|
||||
@@ -214,7 +215,7 @@ export async function pollDeviceCode(params: {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
}): Promise<DevicePollResult> {
|
||||
const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
||||
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -240,7 +241,7 @@ export async function pollDeviceCode(params: {
|
||||
// Standard PKCE exchange against the OAuth token endpoint, with the
|
||||
// device-auth callback URI Hydra associates with this authorization
|
||||
// code internally.
|
||||
const tokenRes = await fetch(`${ISSUER}/oauth/token`, {
|
||||
const tokenRes = await safeFetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
@@ -276,7 +277,7 @@ export async function pollDeviceCode(params: {
|
||||
export async function refreshDeviceTokens(
|
||||
refreshToken: string,
|
||||
): Promise<CodexCreds> {
|
||||
const res = await fetch(`${ISSUER}/oauth/token`, {
|
||||
const res = await safeFetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -100,6 +100,13 @@ import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
STEP_CONSOLIDATION_CONCURRENCY,
|
||||
runStepConsolidationForUser,
|
||||
enqueueBootTimeStepConsolidation,
|
||||
type StepConsolidationPayload,
|
||||
} from "@/lib/jobs/step-consolidation";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
|
||||
@@ -1868,6 +1875,14 @@ export async function startReminderWorker() {
|
||||
// rollup tier. Discovery enqueues one job per user with intake
|
||||
// events but no rollup coverage; idempotent across reboots.
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
// v1.5.6 — boot-time legacy step consolidation. Discovery enqueues
|
||||
// one job per user still holding live pre-v1.5.0 granular step
|
||||
// rows; the per-user pass collapses them into one daily total and
|
||||
// soft-deletes the originals. Idempotent across reboots — a
|
||||
// consolidated user drops off the discovery list. The queue MUST be
|
||||
// registered here or pg-boss never provisions it and the boot
|
||||
// enqueue silently never drains.
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
// v1.4.37 W7c — explicit createQueue is required before the
|
||||
// nightly schedule below registers (pg-boss v12 contract). Without
|
||||
// this entry the drain schedule silently no-ops and the
|
||||
@@ -2173,6 +2188,27 @@ export async function startReminderWorker() {
|
||||
},
|
||||
);
|
||||
|
||||
// v1.5.6 — legacy step consolidation worker. The boot enqueue helper
|
||||
// below sends one job per user still holding live pre-v1.5.0 granular
|
||||
// step rows; this handler collapses them into one daily-total row per
|
||||
// calendar day and soft-deletes the originals. Serial concurrency so
|
||||
// the populator never crowds the dashboard request pool.
|
||||
await boss.work<StepConsolidationPayload>(
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
{ localConcurrency: STEP_CONSOLIDATION_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { daysConsolidated, legacyRowsSoftDeleted } =
|
||||
await runStepConsolidationForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.39 W-MOOD — mood-rollup per-bucket worker. Folds the
|
||||
// WEEK / MONTH / YEAR buckets that the mood-entry write hooks
|
||||
// enqueue; the DAY bucket runs synchronously in the hook itself.
|
||||
@@ -2320,6 +2356,36 @@ export async function startReminderWorker() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.5.6 — fire-and-forget boot discovery for the legacy step
|
||||
// consolidation pass. Finds every user still holding live pre-v1.5.0
|
||||
// granular step rows and enqueues one consolidation job per account.
|
||||
// Idempotent across reboots: consolidated legacy rows are
|
||||
// soft-deleted, so the `deleted_at IS NULL` discovery predicate drops
|
||||
// them and the user falls off the list. Errors are returned through
|
||||
// the helper's result value — the worker boot never fails because of
|
||||
// a consolidation miss.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeStepConsolidation();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[step-consolidation] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[step-consolidation] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — fire-and-forget boot discovery for the mood
|
||||
// rollup tier. Mirrors the v1.4.35.1 measurement-rollup pattern:
|
||||
// one job per user with mood entries but no rollup coverage.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 — pg-boss queue + boot-time converging backfill for the
|
||||
* legacy step consolidation pass. Modelled on the
|
||||
* `rollup-full-backfill` boot-time pattern: a discovery query enqueues
|
||||
* one job per user still holding live legacy step rows, the per-user
|
||||
* handler runs `consolidateLegacySteps`, and the pass is idempotent
|
||||
* across reboots (consolidated rows are soft-deleted so they drop off
|
||||
* the discovery list).
|
||||
*
|
||||
* The queue name MUST be registered in `allQueues` in
|
||||
* `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot;
|
||||
* an unregistered queue silently never drains.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps";
|
||||
|
||||
export const STEP_CONSOLIDATION_QUEUE = "step-consolidation";
|
||||
|
||||
/**
|
||||
* Serial concurrency — the populator walks every legacy step row for a
|
||||
* user and writes in a transaction per day; concurrency-1 keeps it from
|
||||
* crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const STEP_CONSOLIDATION_CONCURRENCY = 1;
|
||||
|
||||
export interface StepConsolidationPayload {
|
||||
userId: string;
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user queue handler. Runs the consolidation for one account and
|
||||
* returns the summary totals so the worker can log them.
|
||||
*/
|
||||
export async function runStepConsolidationForUser(
|
||||
userId: string,
|
||||
): Promise<{ daysConsolidated: number; legacyRowsSoftDeleted: number }> {
|
||||
const summary = await consolidateLegacySteps(prisma, {
|
||||
userId,
|
||||
log: () => {
|
||||
// Silent inside the queue handler — the worker logs the totals.
|
||||
},
|
||||
});
|
||||
// Stable wide-event action name (`<surface>.<noun>.<verb>`). No-ops
|
||||
// outside a request/job event context.
|
||||
annotate({
|
||||
action: {
|
||||
name: "measurement.step.consolidate",
|
||||
details: {
|
||||
days: summary.totals.daysConsolidated,
|
||||
legacy_rows_soft_deleted: summary.totals.legacyRowsSoftDeleted,
|
||||
days_folded_into_existing: summary.totals.daysFoldedIntoExisting,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
daysConsolidated: summary.totals.daysConsolidated,
|
||||
legacyRowsSoftDeleted: summary.totals.legacyRowsSoftDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time discovery. Finds every user with at least one LIVE legacy
|
||||
* step row (an `ACTIVITY_STEPS` row whose `externalId` is NULL or does
|
||||
* NOT start with the daily-stats prefix, and that is not tombstoned)
|
||||
* and enqueues one consolidation job per account.
|
||||
*
|
||||
* Idempotent across reboots: once a user's legacy rows are
|
||||
* soft-deleted, the `deleted_at IS NULL` predicate drops them from the
|
||||
* discovery set and the user falls off the list. pg-boss `singletonKey`
|
||||
* coalesces duplicate sends within the queue so a fast restart while a
|
||||
* job is queued doesn't double up.
|
||||
*
|
||||
* Best-effort: errors are returned through the result value so the
|
||||
* worker boot never fails because of a consolidation miss.
|
||||
*/
|
||||
export async function enqueueBootTimeStepConsolidation(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// The daily-total externalId shape is `stats:<HK>:<YYYY-MM-DD>`. A
|
||||
// live row is a legacy candidate when its externalId does NOT match
|
||||
// that prefix (NULL externalId — a manual/legacy row — also
|
||||
// qualifies because `LIKE` against NULL is NULL/false). Splice-free:
|
||||
// the prefix is a constant compile-time literal, not user input.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT m."user_id" AS id
|
||||
FROM measurements m
|
||||
WHERE m."type" = 'ACTIVITY_STEPS'
|
||||
AND m."deleted_at" IS NULL
|
||||
AND (
|
||||
m."external_id" IS NULL
|
||||
OR m."external_id" NOT LIKE 'stats:HKQuantityTypeIdentifierStepCount:%'
|
||||
)
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: StepConsolidationPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(STEP_CONSOLIDATION_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `step-consolidation|${id}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,9 @@ async function flushLokiBuffer(): Promise<void> {
|
||||
headers,
|
||||
body: JSON.stringify({ streams }),
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
// Operator-configured Loki endpoint — pin the connect-time IP
|
||||
// against DNS rebinding.
|
||||
{ timeoutMs: 10_000, requirePublicHost: true },
|
||||
);
|
||||
} catch {
|
||||
// Events gehen verloren — akzeptabel fuer Logging
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* v1.5.6 — unit coverage for the extractable bucketing + summation
|
||||
* helpers behind the legacy step-consolidation pass. The DB-touching
|
||||
* end-to-end contract lives in
|
||||
* `tests/integration/consolidate-legacy-steps.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bucketLegacyStepRows,
|
||||
sumLegacyStepValues,
|
||||
STEP_DAILY_STATS_PREFIX,
|
||||
} from "../consolidate-legacy-steps";
|
||||
import type { PerSampleRow } from "../drain-per-sample-cumulative";
|
||||
|
||||
function row(
|
||||
partial: Partial<PerSampleRow> & { measuredAt: Date },
|
||||
): PerSampleRow {
|
||||
return {
|
||||
id: partial.id ?? "id-1",
|
||||
type: partial.type ?? "ACTIVITY_STEPS",
|
||||
value: partial.value ?? 100,
|
||||
measuredAt: partial.measuredAt,
|
||||
externalId: partial.externalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bucketLegacyStepRows", () => {
|
||||
it("groups rows by the user's calendar day", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const rows = [
|
||||
row({ id: "a", measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
row({ id: "b", measuredAt: new Date("2026-05-16T20:00:00.000Z") }),
|
||||
row({ id: "c", measuredAt: new Date("2026-05-17T06:00:00.000Z") }),
|
||||
];
|
||||
const byDay = bucketLegacyStepRows(rows, tz);
|
||||
expect(byDay.get("2026-05-16")).toHaveLength(2);
|
||||
expect(byDay.get("2026-05-17")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips rows already in the daily-stats shape", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const rows = [
|
||||
row({
|
||||
id: "total",
|
||||
externalId: `${STEP_DAILY_STATS_PREFIX}2026-05-16`,
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
}),
|
||||
row({ id: "legacy", measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
];
|
||||
const byDay = bucketLegacyStepRows(rows, tz);
|
||||
expect(byDay.get("2026-05-16")).toHaveLength(1);
|
||||
expect(byDay.get("2026-05-16")?.[0].id).toBe("legacy");
|
||||
});
|
||||
|
||||
it("buckets a late-evening UTC sample into the next local day", () => {
|
||||
// 23:30 UTC on 2026-05-16 is 01:30 on 2026-05-17 in Berlin (UTC+2).
|
||||
const byDay = bucketLegacyStepRows(
|
||||
[row({ id: "late", measuredAt: new Date("2026-05-16T23:30:00.000Z") })],
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(byDay.has("2026-05-17")).toBe(true);
|
||||
expect(byDay.has("2026-05-16")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumLegacyStepValues", () => {
|
||||
it("sums the values in a bucket", () => {
|
||||
const rows = [
|
||||
row({ value: 1200, measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
row({ value: 3400, measuredAt: new Date("2026-05-16T14:00:00.000Z") }),
|
||||
row({ value: 800, measuredAt: new Date("2026-05-16T20:00:00.000Z") }),
|
||||
];
|
||||
expect(sumLegacyStepValues(rows)).toBe(5400);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty bucket", () => {
|
||||
expect(sumLegacyStepValues([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* v1.5.6 — consolidate pre-v1.5.0 legacy step rows into one daily-total
|
||||
* row per user per calendar day.
|
||||
*
|
||||
* Pre-v1.5.0, HealthKit step samples landed at sampling granularity —
|
||||
* hundreds-to-thousands of `ACTIVITY_STEPS` rows per day. Post-v1.5.0,
|
||||
* iOS emits one overwrite-able daily-total row carrying the
|
||||
* `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD` externalId
|
||||
* (see `src/app/api/measurements/batch/route.ts` stats-overwrite logic).
|
||||
* This pass collapses the legacy granular rows into the same canonical
|
||||
* daily shape so historical step data reads consistently.
|
||||
*
|
||||
* Per user × calendar day (anchored to `User.timezone`):
|
||||
* 1. SELECT live (`deletedAt IS NULL`) `ACTIVITY_STEPS` rows whose
|
||||
* `externalId` does NOT start with the daily-stats prefix.
|
||||
* 2. Group into per-day buckets in the user's timezone.
|
||||
* 3. SUM the legacy values for the day.
|
||||
* 4. UPSERT the canonical daily-total row keyed by
|
||||
* `stats:HKQuantityTypeIdentifierStepCount:<dateKey>`. If a daily
|
||||
* total already exists for the day (post-v1.5.0 iOS already wrote
|
||||
* one), the existing total is the source of truth — it is NOT
|
||||
* overwritten and the legacy sum is NOT folded in (avoids double-
|
||||
* counting: the post-v1.5.0 row already represents the true daily
|
||||
* total HealthKit computed). The legacy granular rows are still
|
||||
* soft-deleted so they stop polluting the per-sample read path.
|
||||
* 5. SOFT-DELETE (set `deletedAt`) the legacy granular rows — they
|
||||
* stay in the table as an audit/backup trail. Sampling-granularity
|
||||
* loss is explicitly accepted.
|
||||
*
|
||||
* Idempotent: the discovery query (`enqueueBootTimeStepConsolidation`)
|
||||
* matches only users still holding live legacy step rows, so a second
|
||||
* run converges to zero work. Within a run, soft-deleted legacy rows
|
||||
* are excluded from the scan, so a re-run after a partial pass never
|
||||
* re-aggregates an already-consolidated day.
|
||||
*
|
||||
* Runs on pg-boss (`STEP_CONSOLIDATION_QUEUE`), not a CLI — the
|
||||
* production standalone image strips `tsx`. Modelled on the
|
||||
* `rollup-full-backfill` boot-time converging-backfill pattern.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { dailyStatsExternalId } from "./apple-health-mapping";
|
||||
import {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
type PerSampleRow,
|
||||
} from "./drain-per-sample-cumulative";
|
||||
|
||||
/** HealthLog measurement type that carries step counts. */
|
||||
const STEP_TYPE = "ACTIVITY_STEPS" as const;
|
||||
|
||||
/** Canonical HealthKit identifier for the step type. */
|
||||
const STEP_HK_IDENTIFIER = "HKQuantityTypeIdentifierStepCount";
|
||||
|
||||
/**
|
||||
* Daily-total externalId prefix. Legacy rows that do NOT start with
|
||||
* this are candidates for consolidation; the daily-total row this pass
|
||||
* mints carries the full `stats:HKQuantityTypeIdentifierStepCount:<day>`
|
||||
* externalId.
|
||||
*/
|
||||
export const STEP_DAILY_STATS_PREFIX = `stats:${STEP_HK_IDENTIFIER}:`;
|
||||
|
||||
/** Per-(user, day) action summary. */
|
||||
export interface StepConsolidationBucket {
|
||||
userId: string;
|
||||
/** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */
|
||||
dateKey: string;
|
||||
/** Number of legacy granular rows folded for this day. */
|
||||
legacyRowCount: number;
|
||||
/** SUM of the legacy granular values for the day. */
|
||||
legacySum: number;
|
||||
/** Whether a `stats:` daily total already existed for this day. */
|
||||
hadExistingTotal: boolean;
|
||||
/** ISO-8601 canonical timestamp (local-noon of the user's day). */
|
||||
canonicalTimestamp: string;
|
||||
/** Resulting daily-total `externalId`. */
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
export interface StepConsolidationSummary {
|
||||
/** Did the run actually write to the DB (false for `dryRun`). */
|
||||
dryRun: boolean;
|
||||
/** Per-user-day buckets the pass rewrote (or would rewrite). */
|
||||
buckets: StepConsolidationBucket[];
|
||||
totals: {
|
||||
usersScanned: number;
|
||||
daysConsolidated: number;
|
||||
legacyRowsSoftDeleted: number;
|
||||
dailyRowsUpserted: number;
|
||||
/** Days skipped because a `stats:` total already existed. */
|
||||
daysFoldedIntoExisting: number;
|
||||
/**
|
||||
* Days skipped because the per-day mint hit a unique-constraint
|
||||
* collision (P2002) and the transaction rolled back. Counted, logged,
|
||||
* and stepped over so a single poison day can never strand the rest
|
||||
* of the pass — the pass stays converging for every other day.
|
||||
*/
|
||||
daysSkippedOnConflict: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a Prisma unique-constraint violation (P2002). */
|
||||
function isUniqueConstraintViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
export interface StepConsolidationOptions {
|
||||
/** Limit the pass to a single user. Default = every user. */
|
||||
userId?: string;
|
||||
/** Preview-only mode — no DB writes. */
|
||||
dryRun?: boolean;
|
||||
/** Logger sink — defaults to `console.log`. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group legacy step rows into per-day buckets in the user's timezone.
|
||||
* Rows already in the daily-stats shape are skipped — they are the
|
||||
* canonical total, not a legacy sample. Exposed for unit testing the
|
||||
* bucketing semantics without booting Prisma.
|
||||
*/
|
||||
export function bucketLegacyStepRows(
|
||||
rows: readonly PerSampleRow[],
|
||||
tz: string,
|
||||
): Map<string, PerSampleRow[]> {
|
||||
const byDay = new Map<string, PerSampleRow[]>();
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.externalId !== null &&
|
||||
row.externalId.startsWith(STEP_DAILY_STATS_PREFIX)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = dayKeyForUserTz(row.measuredAt, tz);
|
||||
const slot = byDay.get(key) ?? [];
|
||||
slot.push(row);
|
||||
byDay.set(key, slot);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
/** SUM the values in a per-day legacy bucket. */
|
||||
export function sumLegacyStepValues(rows: readonly PerSampleRow[]): number {
|
||||
let acc = 0;
|
||||
for (const row of rows) acc += row.value;
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the step consolidation. Idempotent — re-invocation after a
|
||||
* successful pass reports zero days consolidated because the legacy
|
||||
* rows are soft-deleted (and so excluded from the live scan).
|
||||
*
|
||||
* Does NOT enforce any auth gate — the queue handler owns that concern.
|
||||
*/
|
||||
export async function consolidateLegacySteps(
|
||||
prismaClient: PrismaClient,
|
||||
options: StepConsolidationOptions = {},
|
||||
): Promise<StepConsolidationSummary> {
|
||||
const dryRun = options.dryRun ?? false;
|
||||
const log = options.log ?? ((line) => console.log(line));
|
||||
|
||||
const users = options.userId
|
||||
? await prismaClient.user.findMany({
|
||||
where: { id: options.userId },
|
||||
select: { id: true, timezone: true },
|
||||
})
|
||||
: await prismaClient.user.findMany({
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
|
||||
const summary: StepConsolidationSummary = {
|
||||
dryRun,
|
||||
buckets: [],
|
||||
totals: {
|
||||
usersScanned: users.length,
|
||||
daysConsolidated: 0,
|
||||
legacyRowsSoftDeleted: 0,
|
||||
dailyRowsUpserted: 0,
|
||||
daysFoldedIntoExisting: 0,
|
||||
daysSkippedOnConflict: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const user of users) {
|
||||
const tz =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: "Europe/Berlin";
|
||||
|
||||
// Live legacy step rows whose externalId is NOT the daily-stats
|
||||
// shape. The `stats:` daily-total row (if present) is read
|
||||
// separately below per day; we exclude it here so it is never
|
||||
// soft-deleted. Soft-deleted rows (`deletedAt IS NOT NULL`) are
|
||||
// excluded so a re-run after a partial pass never re-aggregates.
|
||||
const legacyRows = (await prismaClient.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
deletedAt: null,
|
||||
NOT: { externalId: { startsWith: STEP_DAILY_STATS_PREFIX } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
externalId: true,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
})) as PerSampleRow[];
|
||||
|
||||
if (legacyRows.length === 0) continue;
|
||||
|
||||
log(`[step-consolidation] user=${user.id} tz=${tz} legacyRows=${legacyRows.length}${dryRun ? " (dry-run)" : ""}`);
|
||||
|
||||
const byDay = bucketLegacyStepRows(legacyRows, tz);
|
||||
|
||||
for (const [dateKey, dayRows] of byDay) {
|
||||
if (dayRows.length === 0) continue;
|
||||
|
||||
const legacySum = sumLegacyStepValues(dayRows);
|
||||
const canonicalTs = canonicalDailyTimestamp(dateKey, tz);
|
||||
const externalId = dailyStatsExternalId(STEP_HK_IDENTIFIER, dateKey);
|
||||
|
||||
// Does a post-v1.5.0 daily total already exist for this day? If so
|
||||
// it is the source of truth (HealthKit's own daily aggregate) —
|
||||
// we do NOT overwrite it and do NOT add the legacy sum on top
|
||||
// (that would double-count). We still soft-delete the legacy rows.
|
||||
// `source` is deliberately omitted from this probe (the upsert below
|
||||
// pins `source: "MANUAL"`): a daily total written by iOS lands as
|
||||
// `APPLE_HEALTH`, and matching it here is what prevents minting a
|
||||
// second MANUAL total on top of it. The asymmetry with the upsert
|
||||
// `where` is intentional, not a bug.
|
||||
const existingTotal = await prismaClient.measurement.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const hadExistingTotal = existingTotal !== null;
|
||||
|
||||
const bucket: StepConsolidationBucket = {
|
||||
userId: user.id,
|
||||
dateKey,
|
||||
legacyRowCount: dayRows.length,
|
||||
legacySum,
|
||||
hadExistingTotal,
|
||||
canonicalTimestamp: canonicalTs.toISOString(),
|
||||
externalId,
|
||||
};
|
||||
summary.buckets.push(bucket);
|
||||
summary.totals.daysConsolidated += 1;
|
||||
if (hadExistingTotal) summary.totals.daysFoldedIntoExisting += 1;
|
||||
|
||||
const ids = dayRows.map((r) => r.id);
|
||||
|
||||
if (!dryRun) {
|
||||
try {
|
||||
await prismaClient.$transaction(async (tx) => {
|
||||
if (!hadExistingTotal) {
|
||||
// Mint the canonical daily-total row. The unique index
|
||||
// (userId, type, source, externalId) makes the upsert
|
||||
// idempotent across re-runs. Source is MANUAL — these are
|
||||
// historical rows whose original sampling source is no
|
||||
// longer meaningful once collapsed; the externalId carries
|
||||
// the canonical daily-stats shape. Built field-by-field
|
||||
// (no spread) per the no-mass-assignment convention.
|
||||
await tx.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
source: "MANUAL",
|
||||
externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
value: legacySum,
|
||||
unit: "steps",
|
||||
source: "MANUAL",
|
||||
measuredAt: canonicalTs,
|
||||
externalId,
|
||||
},
|
||||
update: {
|
||||
value: legacySum,
|
||||
measuredAt: canonicalTs,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
summary.totals.dailyRowsUpserted += 1;
|
||||
}
|
||||
|
||||
// Soft-delete the legacy granular rows in the same
|
||||
// transaction. Tombstone, never hard-delete — they remain as
|
||||
// an audit/backup trail. `deletedAt` excludes them from every
|
||||
// live read and from this pass's own re-run discovery.
|
||||
const del = await tx.measurement.updateMany({
|
||||
where: { id: { in: ids }, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
summary.totals.legacyRowsSoftDeleted += del.count;
|
||||
});
|
||||
} catch (err) {
|
||||
// A pre-existing row can collide on the second unique index
|
||||
// (userId, type, measuredAt, source, sleepStage) when a MANUAL
|
||||
// step row already sits at this day's canonical-noon instant
|
||||
// with a null externalId — the mint's `create` branch fires
|
||||
// (the upsert keys on externalId, which differs) and trips that
|
||||
// index, rolling back the soft-delete too. Skipping the day
|
||||
// (rather than aborting the whole user) keeps the pass
|
||||
// converging: the legacy rows stay live but every other day
|
||||
// still consolidates, and the run does not re-throw on every
|
||||
// boot. Non-P2002 errors are genuine failures — rethrow so
|
||||
// pg-boss retries.
|
||||
if (!isUniqueConstraintViolation(err)) throw err;
|
||||
summary.totals.daysConsolidated -= 1;
|
||||
summary.totals.daysSkippedOnConflict += 1;
|
||||
summary.buckets.pop();
|
||||
log(
|
||||
`[step-consolidation] user=${user.id} day=${dateKey} skipped — unique-constraint conflict on mint`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!hadExistingTotal) summary.totals.dailyRowsUpserted += 1;
|
||||
summary.totals.legacyRowsSoftDeleted += ids.length;
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`[step-consolidation] user=${user.id} complete days=${byDay.size}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
`[step-consolidation] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} legacyRowsSoftDeleted=${summary.totals.legacyRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFoldedIntoExisting=${summary.totals.daysFoldedIntoExisting} daysSkippedOnConflict=${summary.totals.daysSkippedOnConflict}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -123,14 +123,20 @@ async function tryEnvelope(
|
||||
config: ParsedGlitchtipDsn,
|
||||
body: string,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const response = await safeFetch(config.envelopeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
const response = await safeFetch(
|
||||
config.envelopeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host carrying the redacted wide-event
|
||||
// payload — pin the connect-time IP against DNS rebinding.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "envelope", status: response.status };
|
||||
@@ -152,14 +158,19 @@ async function tryStoreQuery(
|
||||
target.searchParams.set("sentry_version", "7");
|
||||
target.searchParams.set("sentry_client", "healthlog/1.0");
|
||||
|
||||
const response = await safeFetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
const response = await safeFetch(
|
||||
target,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "store-query", status: response.status };
|
||||
@@ -177,15 +188,20 @@ async function tryStoreHeader(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const sentryAuth = `Sentry sentry_version=7, sentry_key=${config.publicKey}, sentry_client=healthlog/1.0`;
|
||||
const response = await safeFetch(config.storeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-sentry-auth": sentryAuth,
|
||||
const response = await safeFetch(
|
||||
config.storeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-sentry-auth": sentryAuth,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "store-header", status: response.status };
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||||
|
||||
/**
|
||||
* Common envelopes — every HealthLog API response wraps payload in
|
||||
@@ -786,6 +787,29 @@ medicationExtractionSchema.meta({
|
||||
"Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.",
|
||||
});
|
||||
|
||||
// Insights tile layout — mirrors the Zod schema in
|
||||
// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived
|
||||
// from the same `INSIGHTS_TILE_IDS` source so the contract cannot drift.
|
||||
const insightsLayoutSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
tiles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.enum(INSIGHTS_TILE_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsLayoutBody",
|
||||
description:
|
||||
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version; tile ids are a closed enum derived from the server's tile registry.",
|
||||
});
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
const stdResponses = {
|
||||
@@ -1579,6 +1603,69 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/layout": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the calling user's Insights tile layout",
|
||||
description:
|
||||
"Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved layout (custom or default).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Insights"],
|
||||
summary: "Replace the calling user's Insights tile layout",
|
||||
description:
|
||||
"Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: insightsLayoutSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout saved; the normalised layout is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Reset the calling user's Insights tile layout",
|
||||
description:
|
||||
"Clears the persisted layout and returns the default layout. Idempotent.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout reset; the default layout is returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsLayoutSchema,
|
||||
"InsightsLayoutReset",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/chat/messages/{id}/feedback": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -100,9 +100,10 @@ export interface UseAnalyticsQueryOptions {
|
||||
async function fetchAnalytics(
|
||||
slice: AnalyticsSlice,
|
||||
): Promise<AnalyticsRawPayload> {
|
||||
const url =
|
||||
slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics";
|
||||
const res = await fetch(url);
|
||||
const res =
|
||||
slice === "summaries"
|
||||
? await fetch("/api/analytics?slice=summaries")
|
||||
: await fetch("/api/analytics");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load analytics (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Docs: https://developer.withings.com/api-reference
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -109,7 +110,7 @@ export async function exchangeCode(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_TOKEN_URL, {
|
||||
const res = await safeFetch(WITHINGS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
@@ -152,7 +153,7 @@ export async function refreshAccessToken(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_TOKEN_URL, {
|
||||
const res = await safeFetch(WITHINGS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
@@ -269,7 +270,7 @@ export async function fetchMeasurements(
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -346,7 +347,7 @@ export async function subscribeWebhook(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_NOTIFY_URL, {
|
||||
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -397,7 +398,7 @@ export async function unsubscribeWebhook(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_NOTIFY_URL, {
|
||||
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -148,7 +149,7 @@ export async function fetchWithingsActivity(
|
||||
offset: String(offset),
|
||||
});
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -118,7 +119,7 @@ export async function fetchWithingsSleep(
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_SLEEP_URL, {
|
||||
const res = await safeFetch(WITHINGS_SLEEP_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.5.6 — real-Postgres integration coverage for
|
||||
* `consolidateLegacySteps()`. Mirrors the
|
||||
* `drain-per-sample-cumulative` fixture: the testcontainer is hot
|
||||
* before this file loads, so the body truncates + seeds + consolidates.
|
||||
*
|
||||
* Pins the contracts the unit mocks can't:
|
||||
* - legacy granular rows collapse into one daily total with the
|
||||
* correct sum;
|
||||
* - the originals are SOFT-deleted (tombstoned, not gone);
|
||||
* - a day already holding a `stats:` total is not double-counted;
|
||||
* - the discovery query is idempotent (second run = no-op).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps";
|
||||
|
||||
const TEST_USER_ID = "user-step-consolidation";
|
||||
const STEP_TOTAL_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-05-16";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "step-consolidation",
|
||||
email: "step-consolidation@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("consolidateLegacySteps (real Postgres)", () => {
|
||||
it("collapses legacy granular step rows into one daily total and soft-deletes the originals", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-legacy-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-legacy-2",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
|
||||
externalId: "hk-legacy-3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await consolidateLegacySteps(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
expect(summary.totals.legacyRowsSoftDeleted).toBe(3);
|
||||
expect(summary.totals.dailyRowsUpserted).toBe(1);
|
||||
expect(summary.totals.daysFoldedIntoExisting).toBe(0);
|
||||
|
||||
// One live daily total carrying the canonical externalId + sum.
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].value).toBe(5400);
|
||||
expect(live[0].externalId).toBe(STEP_TOTAL_ID);
|
||||
|
||||
// Originals are tombstoned, NOT gone — audit trail preserved.
|
||||
const tombstoned = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(tombstoned).toHaveLength(3);
|
||||
expect(tombstoned.every((r) => r.externalId?.startsWith("hk-legacy-"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-count when a stats: daily total already exists for the day", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// Post-v1.5.0 daily total already written by iOS.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 9000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
externalId: STEP_TOTAL_ID,
|
||||
},
|
||||
});
|
||||
// Legacy granular rows for the same day.
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-legacy-a",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
|
||||
externalId: "hk-legacy-b",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await consolidateLegacySteps(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
expect(summary.totals.daysFoldedIntoExisting).toBe(1);
|
||||
// No new daily row minted — the existing total stands.
|
||||
expect(summary.totals.dailyRowsUpserted).toBe(0);
|
||||
expect(summary.totals.legacyRowsSoftDeleted).toBe(2);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
// The existing total is untouched — NOT 9000 + 1200 + 800.
|
||||
expect(live[0].value).toBe(9000);
|
||||
expect(live[0].externalId).toBe(STEP_TOTAL_ID);
|
||||
|
||||
// Both legacy rows tombstoned.
|
||||
const tombstoned = await prisma.measurement.count({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(tombstoned).toBe(2);
|
||||
});
|
||||
|
||||
it("is idempotent — a second run is a no-op", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 2000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T09:00:00.000Z"),
|
||||
externalId: "hk-legacy-x",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T18:00:00.000Z"),
|
||||
externalId: "hk-legacy-y",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await consolidateLegacySteps(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
expect(first.totals.daysConsolidated).toBe(1);
|
||||
expect(first.totals.legacyRowsSoftDeleted).toBe(2);
|
||||
|
||||
const second = await consolidateLegacySteps(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
expect(second.totals.daysConsolidated).toBe(0);
|
||||
expect(second.totals.legacyRowsSoftDeleted).toBe(0);
|
||||
expect(second.totals.dailyRowsUpserted).toBe(0);
|
||||
|
||||
// State is stable: one live total (5000), two tombstones.
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].value).toBe(5000);
|
||||
const tombstoned = await prisma.measurement.count({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(tombstoned).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user