From b3e6ba03d783c35c082d8c78a76f6e3e60f6cd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 18:50:59 +0200 Subject: [PATCH 1/3] feat(measurements): add custom metrics (define, log, chart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-defined custom-metric store alongside the built-in measurements on the Measurements surface. A user defines an arbitrary metric (name, unit, optional target window, decimals, description), logs values, and charts them. The store is a separate generic catalog + values pair, isolated from the closed measurement-type system: not synced, not in FHIR, not in insights — log and chart only. Names and units are plaintext, mirroring the biomarker catalog. The inline list lives beneath the measurement list (no new nav entry); tapping a metric opens a detail page with the trend chart, target band, stat strip, and an editable value history. Migration 0224 adds custom_metrics and custom_metric_entries. --- docs/api/openapi.yaml | 637 ++++++++++++++++++ messages/de.json | 82 +++ messages/en.json | 82 +++ messages/es.json | 82 +++ messages/fr.json | 82 +++ messages/it.json | 82 +++ messages/pl.json | 82 +++ .../0224_custom_metrics/migration.sql | 81 +++ prisma/schema.prisma | 66 ++ .../[id]/__tests__/route.test.ts | 180 +++++ .../[id]/entries/[entryId]/route.ts | 126 ++++ .../[id]/entries/__tests__/route.test.ts | 161 +++++ .../api/custom-metrics/[id]/entries/route.ts | 157 +++++ src/app/api/custom-metrics/[id]/route.ts | 185 +++++ .../custom-metrics/__tests__/route.test.ts | 168 +++++ src/app/api/custom-metrics/route.ts | 172 +++++ src/app/custom-metrics/[id]/page.tsx | 54 ++ src/app/custom-metrics/[id]/values/page.tsx | 73 ++ src/app/measurements/page.tsx | 3 + .../__tests__/custom-metric-detail.test.tsx | 120 ++++ .../custom-metrics/custom-metric-chart.tsx | 215 ++++++ .../custom-metrics/custom-metric-detail.tsx | 355 ++++++++++ .../custom-metric-entry-form.tsx | 188 ++++++ .../custom-metrics/custom-metric-form.tsx | 249 +++++++ .../custom-metric-history-list.tsx | 333 +++++++++ .../custom-metrics/custom-metric-list.tsx | 148 ++++ .../custom-metric-values-list.tsx | 127 ++++ src/components/custom-metrics/format-value.ts | 17 + src/components/custom-metrics/types.ts | 42 ++ src/lib/custom-metrics/custom-metric-store.ts | 83 +++ src/lib/openapi/routes/custom-metrics.ts | 355 ++++++++++ src/lib/openapi/routes/index.ts | 2 + src/lib/query-keys/custom-metrics.ts | 19 + src/lib/query-keys/index.ts | 2 + src/lib/validations/custom-metrics.ts | 146 ++++ 35 files changed, 4956 insertions(+) create mode 100644 prisma/migrations/0224_custom_metrics/migration.sql create mode 100644 src/app/api/custom-metrics/[id]/__tests__/route.test.ts create mode 100644 src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts create mode 100644 src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts create mode 100644 src/app/api/custom-metrics/[id]/entries/route.ts create mode 100644 src/app/api/custom-metrics/[id]/route.ts create mode 100644 src/app/api/custom-metrics/__tests__/route.test.ts create mode 100644 src/app/api/custom-metrics/route.ts create mode 100644 src/app/custom-metrics/[id]/page.tsx create mode 100644 src/app/custom-metrics/[id]/values/page.tsx create mode 100644 src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx create mode 100644 src/components/custom-metrics/custom-metric-chart.tsx create mode 100644 src/components/custom-metrics/custom-metric-detail.tsx create mode 100644 src/components/custom-metrics/custom-metric-entry-form.tsx create mode 100644 src/components/custom-metrics/custom-metric-form.tsx create mode 100644 src/components/custom-metrics/custom-metric-history-list.tsx create mode 100644 src/components/custom-metrics/custom-metric-list.tsx create mode 100644 src/components/custom-metrics/custom-metric-values-list.tsx create mode 100644 src/components/custom-metrics/format-value.ts create mode 100644 src/components/custom-metrics/types.ts create mode 100644 src/lib/custom-metrics/custom-metric-store.ts create mode 100644 src/lib/openapi/routes/custom-metrics.ts create mode 100644 src/lib/query-keys/custom-metrics.ts create mode 100644 src/lib/validations/custom-metrics.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 876bb5459..dc7537400 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9116,6 +9116,352 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/custom-metrics: + get: + tags: + - Custom metrics + summary: List the caller's custom metrics + description: Returns every custom metric the caller has defined, name-ordered, each with its latest logged value and + total value count. + responses: + "200": + description: Custom-metric catalog. + content: + application/json: + schema: + $ref: "#/components/schemas/ListCustomMetricsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Custom metrics + summary: Define a custom metric + description: Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that + definition. Audits as `customMetric.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 120 + unit: + type: string + minLength: 1 + maxLength: 40 + targetLow: + type: number + targetHigh: + type: number + decimals: + type: integer + minimum: 0 + maximum: 6 + description: + type: string + maxLength: 2000 + required: + - name + - unit + responses: + "201": + description: Created custom metric. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCustomMetricResponse" + "401": *a1 + "409": &a15 + description: A custom metric with this name already exists for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/custom-metrics/{id}: + get: + tags: + - Custom metrics + summary: Fetch a single custom metric + description: Returns the custom-metric definition. Cross-user rows surface as 404. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Custom-metric detail. + content: + application/json: + schema: + $ref: "#/components/schemas/GetCustomMetricResponse" + "401": *a1 + "404": &a14 + description: Custom metric not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Custom metrics + summary: Edit a custom metric + description: Partial edit; omitted fields are untouched, an explicit null clears a target bound / decimals / + description. Audits as `customMetric.update`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 120 + unit: + type: string + minLength: 1 + maxLength: 40 + targetLow: + anyOf: + - type: number + - type: "null" + targetHigh: + anyOf: + - type: number + - type: "null" + decimals: + anyOf: + - type: integer + minimum: 0 + maximum: 6 + - type: "null" + description: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + responses: + "200": + description: Updated custom metric. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCustomMetricResponse" + "401": *a1 + "404": *a14 + "409": *a15 + "422": *a3 + "429": *a4 + delete: + tags: + - Custom metrics + summary: Delete a custom metric + description: Soft-deletes the metric (stamps `deletedAt`); its logged values are retained and re-creating the name + revives it. Audits as `customMetric.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteCustomMetricResponse" + "401": *a1 + "404": *a14 + "422": *a3 + "429": *a4 + /api/custom-metrics/{id}/entries: + get: + tags: + - Custom metrics + summary: List a custom metric's logged values + description: Offset-paginated value feed for one custom metric (the chart + history read). Cross-user / unknown metric + ids surface as 404. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortDir + schema: + type: string + enum: + - asc + - desc + responses: + "200": + description: Logged values. + content: + application/json: + schema: + $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" + "401": *a1 + "404": *a14 + "422": *a3 + "429": *a4 + post: + tags: + - Custom metrics + summary: Log a value + description: Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + required: + - value + - measuredAt + responses: + "201": + description: Created value. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCustomMetricEntryResponse" + "401": *a1 + "404": *a14 + "422": *a3 + "429": *a4 + /api/custom-metrics/{id}/entries/{entryId}: + patch: + tags: + - Custom metrics + summary: Edit a logged value + description: Partial edit of a value; omitted fields untouched, explicit null clears `note`. Audits as + `customMetricEntry.update`. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: entryId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + - type: "null" + responses: + "200": + description: Updated value. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" + "401": *a1 + "404": &a16 + description: Custom metric entry not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Custom metrics + summary: Delete a logged value + description: Hard-deletes the value. Audits as `customMetricEntry.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: entryId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" + "401": *a1 + "404": *a16 + "422": *a3 + "429": *a4 /api/onboarding/disclaimer: post: tags: @@ -25527,6 +25873,297 @@ components: description: "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit)." + ListCustomMetricsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ListCustomMetricsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListCustomMetricsResponse: + type: object + properties: + customMetrics: + type: array + items: + $ref: "#/components/schemas/CustomMetric" + required: + - customMetrics + additionalProperties: false + CustomMetric: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + targetLow: + anyOf: + - type: number + - type: "null" + targetHigh: + anyOf: + - type: number + - type: "null" + decimals: + anyOf: + - type: number + - type: "null" + description: + anyOf: + - type: string + - type: "null" + latest: + anyOf: + - type: object + properties: + value: + type: number + unit: + type: string + measuredAt: + type: string + required: + - value + - unit + - measuredAt + additionalProperties: false + - type: "null" + entryCount: + type: number + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - unit + - targetLow + - targetHigh + - decimals + - description + - latest + - entryCount + - createdAt + - updatedAt + additionalProperties: false + description: A user-defined custom metric. `latest` is the most recently logged value (or null); `entryCount` is the + total logged values. The optional target window is the user's own good range, charted as a reference band. + CreateCustomMetricResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CustomMetric" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetCustomMetricResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CustomMetric" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateCustomMetricResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CustomMetric" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteCustomMetricResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListCustomMetricEntriesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ListCustomMetricEntriesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListCustomMetricEntriesResponse: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/CustomMetricEntry" + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + required: + - total + - limit + - offset + additionalProperties: false + required: + - entries + - meta + additionalProperties: false + CustomMetricEntry: + type: object + properties: + id: + type: string + customMetricId: + type: string + value: + type: number + unit: + type: string + measuredAt: + type: string + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + required: + - id + - customMetricId + - value + - unit + - measuredAt + - note + - createdAt + additionalProperties: false + description: A single logged value for a custom metric. `unit` is a snapshot of the metric's unit at write time. + CreateCustomMetricEntryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CustomMetricEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateCustomMetricEntryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CustomMetricEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteCustomMetricEntryResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DisclaimerAckEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 12b60eee8..e25fd4009 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten." + }, + "customMetrics": { + "sectionTitle": "Eigene Metriken", + "sectionSubtitle": "Messwerte, die du selbst definierst und verfolgst.", + "add": "Eigene Metrik hinzufügen", + "addValue": "Wert hinzufügen", + "defineTitle": "Eigene Metrik definieren", + "defineDescription": "Benenne den Messwert, lege seine Einheit fest und optional einen Zielbereich.", + "editTitle": "Eigene Metrik bearbeiten", + "edit": "Bearbeiten", + "delete": "Löschen", + "deleteConfirmTitle": "Diese eigene Metrik löschen?", + "deleteConfirmDescription": "Die Metrik wird aus deiner Liste entfernt. Erfasste Werte bleiben erhalten.", + "deletedToast": "Eigene Metrik gelöscht", + "deleteError": "Eigene Metrik konnte nicht gelöscht werden", + "showAllValues": "Alle Werte anzeigen", + "emptyDescription": "Definiere eigene Messwerte, um sie zu verfolgen und darzustellen.", + "noValuesYet": "Noch keine Werte", + "loadError": "Eigene Metriken konnten nicht geladen werden", + "loadMore": "Weitere Werte laden", + "showingLatestOf": "Die neuesten {shown} von {total}", + "backToMeasurements": "Zurück zu den Messwerten", + "form": { + "name": "Name", + "namePlaceholder": "z. B. Griffkraft morgens", + "unit": "Einheit", + "unitPlaceholder": "z. B. kg", + "decimals": "Nachkommastellen", + "decimalsPlaceholder": "z. B. 1", + "targetLow": "Zielwert unten", + "targetLowPlaceholder": "Untere Grenze", + "targetHigh": "Zielwert oben", + "targetHighPlaceholder": "Obere Grenze", + "description": "Beschreibung", + "descriptionPlaceholder": "Was hier gemessen wird (optional)", + "targetHint": "Der Zielbereich ist optional und wird als Band im Diagramm dargestellt.", + "requiredError": "Name und Einheit sind erforderlich", + "rangeOrderError": "Der untere Zielwert darf den oberen nicht überschreiten", + "save": "Speichern", + "savedToast": "Eigene Metrik gespeichert", + "updatedToast": "Eigene Metrik aktualisiert", + "saveError": "Eigene Metrik konnte nicht gespeichert werden" + }, + "entry": { + "value": "Wert", + "measuredAt": "Datum", + "note": "Notiz", + "addDescription": "Erfasse einen Wert für diese Metrik.", + "edit": "Wert bearbeiten", + "editTitle": "Wert bearbeiten", + "editDescription": "Wert, Datum oder Notiz aktualisieren.", + "delete": "Wert löschen", + "deleteConfirmTitle": "Diesen Wert löschen?", + "deleteConfirmDescription": "Dieser Wert wird endgültig entfernt.", + "valueError": "Gib einen numerischen Wert ein", + "timestampError": "Gib ein gültiges Datum ein", + "savedToast": "Wert erfasst", + "saveError": "Wert konnte nicht erfasst werden", + "editedToast": "Wert aktualisiert", + "editError": "Wert konnte nicht aktualisiert werden", + "deletedToast": "Wert gelöscht", + "deleteError": "Wert konnte nicht gelöscht werden" + }, + "chart": { + "empty": "Keine Werte in diesem Zeitraum", + "valueLabel": "Wert", + "range": { + "30": "30T", + "90": "90T", + "365": "1J", + "all": "Alle" + } + }, + "detail": { + "title": "Eigene Metrik", + "genericDescription": "Ein Messwert, den du verfolgst. Erfasse Werte, um ihn über die Zeit zu beobachten.", + "emptyTitle": "Noch keine Werte", + "emptyDescription": "Erfasse deinen ersten Wert, um diese Metrik darzustellen.", + "valuesTitle": "{metric} — alle Werte", + "valuesDescription": "Alle erfassten Werte dieser Metrik.", + "backToMetric": "Zurück zu {metric}" + } } } diff --git a/messages/en.json b/messages/en.json index 10c753a21..ab971937a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data." + }, + "customMetrics": { + "sectionTitle": "Custom metrics", + "sectionSubtitle": "Measurements you define and track yourself.", + "add": "Add custom metric", + "addValue": "Add value", + "defineTitle": "Define a custom metric", + "defineDescription": "Name the measurement, set its unit, and optionally a target range.", + "editTitle": "Edit custom metric", + "edit": "Edit", + "delete": "Delete", + "deleteConfirmTitle": "Delete this custom metric?", + "deleteConfirmDescription": "The metric is removed from your list. Its logged values are kept.", + "deletedToast": "Custom metric deleted", + "deleteError": "Could not delete the custom metric", + "showAllValues": "Show all values", + "emptyDescription": "Define your own measurements to track and chart them.", + "noValuesYet": "No values yet", + "loadError": "Could not load custom metrics", + "loadMore": "Load more values", + "showingLatestOf": "Showing the latest {shown} of {total}", + "backToMeasurements": "Back to measurements", + "form": { + "name": "Name", + "namePlaceholder": "e.g. Morning grip strength", + "unit": "Unit", + "unitPlaceholder": "e.g. kg", + "decimals": "Decimal places", + "decimalsPlaceholder": "e.g. 1", + "targetLow": "Target low", + "targetLowPlaceholder": "Lower bound", + "targetHigh": "Target high", + "targetHighPlaceholder": "Upper bound", + "description": "Description", + "descriptionPlaceholder": "What this measures (optional)", + "targetHint": "The target range is optional and is drawn as a band on the chart.", + "requiredError": "Name and unit are required", + "rangeOrderError": "Target low must not exceed target high", + "save": "Save", + "savedToast": "Custom metric saved", + "updatedToast": "Custom metric updated", + "saveError": "Could not save the custom metric" + }, + "entry": { + "value": "Value", + "measuredAt": "Date", + "note": "Note", + "addDescription": "Log a value for this metric.", + "edit": "Edit value", + "editTitle": "Edit value", + "editDescription": "Update the value, date, or note.", + "delete": "Delete value", + "deleteConfirmTitle": "Delete this value?", + "deleteConfirmDescription": "This value is removed permanently.", + "valueError": "Enter a numeric value", + "timestampError": "Enter a valid date", + "savedToast": "Value logged", + "saveError": "Could not log the value", + "editedToast": "Value updated", + "editError": "Could not update the value", + "deletedToast": "Value deleted", + "deleteError": "Could not delete the value" + }, + "chart": { + "empty": "No values in this range", + "valueLabel": "Value", + "range": { + "30": "30d", + "90": "90d", + "365": "1y", + "all": "All" + } + }, + "detail": { + "title": "Custom metric", + "genericDescription": "A measurement you track. Add values to follow it over time.", + "emptyTitle": "No values yet", + "emptyDescription": "Add your first value to start charting this metric.", + "valuesTitle": "{metric} — all values", + "valuesDescription": "Every logged value for this metric.", + "backToMetric": "Back to {metric}" + } } } diff --git a/messages/es.json b/messages/es.json index 0d79f3c38..61dae0946 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud." + }, + "customMetrics": { + "sectionTitle": "Métricas personalizadas", + "sectionSubtitle": "Mediciones que defines y registras tú mismo.", + "add": "Añadir métrica personalizada", + "addValue": "Añadir valor", + "defineTitle": "Definir una métrica personalizada", + "defineDescription": "Da nombre a la medición, define su unidad y, opcionalmente, un rango objetivo.", + "editTitle": "Editar métrica personalizada", + "edit": "Editar", + "delete": "Eliminar", + "deleteConfirmTitle": "¿Eliminar esta métrica personalizada?", + "deleteConfirmDescription": "La métrica se quita de tu lista. Los valores registrados se conservan.", + "deletedToast": "Métrica personalizada eliminada", + "deleteError": "No se pudo eliminar la métrica personalizada", + "showAllValues": "Ver todos los valores", + "emptyDescription": "Define tus propias mediciones para registrarlas y representarlas.", + "noValuesYet": "Aún sin valores", + "loadError": "No se pudieron cargar las métricas personalizadas", + "loadMore": "Cargar más valores", + "showingLatestOf": "Mostrando los {shown} más recientes de {total}", + "backToMeasurements": "Volver a las mediciones", + "form": { + "name": "Nombre", + "namePlaceholder": "p. ej. Fuerza de agarre matinal", + "unit": "Unidad", + "unitPlaceholder": "p. ej. kg", + "decimals": "Decimales", + "decimalsPlaceholder": "p. ej. 1", + "targetLow": "Objetivo mínimo", + "targetLowPlaceholder": "Límite inferior", + "targetHigh": "Objetivo máximo", + "targetHighPlaceholder": "Límite superior", + "description": "Descripción", + "descriptionPlaceholder": "Qué mide esto (opcional)", + "targetHint": "El rango objetivo es opcional y se dibuja como una banda en el gráfico.", + "requiredError": "El nombre y la unidad son obligatorios", + "rangeOrderError": "El objetivo mínimo no puede superar al máximo", + "save": "Guardar", + "savedToast": "Métrica personalizada guardada", + "updatedToast": "Métrica personalizada actualizada", + "saveError": "No se pudo guardar la métrica personalizada" + }, + "entry": { + "value": "Valor", + "measuredAt": "Fecha", + "note": "Nota", + "addDescription": "Registra un valor para esta métrica.", + "edit": "Editar valor", + "editTitle": "Editar valor", + "editDescription": "Actualiza el valor, la fecha o la nota.", + "delete": "Eliminar valor", + "deleteConfirmTitle": "¿Eliminar este valor?", + "deleteConfirmDescription": "Este valor se elimina de forma permanente.", + "valueError": "Introduce un valor numérico", + "timestampError": "Introduce una fecha válida", + "savedToast": "Valor registrado", + "saveError": "No se pudo registrar el valor", + "editedToast": "Valor actualizado", + "editError": "No se pudo actualizar el valor", + "deletedToast": "Valor eliminado", + "deleteError": "No se pudo eliminar el valor" + }, + "chart": { + "empty": "Sin valores en este intervalo", + "valueLabel": "Valor", + "range": { + "30": "30d", + "90": "90d", + "365": "1a", + "all": "Todo" + } + }, + "detail": { + "title": "Métrica personalizada", + "genericDescription": "Una medición que registras. Añade valores para seguirla con el tiempo.", + "emptyTitle": "Aún sin valores", + "emptyDescription": "Añade tu primer valor para empezar a representar esta métrica.", + "valuesTitle": "{metric} — todos los valores", + "valuesDescription": "Todos los valores registrados de esta métrica.", + "backToMetric": "Volver a {metric}" + } } } diff --git a/messages/fr.json b/messages/fr.json index caface6c5..1bdaa1ba6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé." + }, + "customMetrics": { + "sectionTitle": "Mesures personnalisées", + "sectionSubtitle": "Des mesures que vous définissez et suivez vous-même.", + "add": "Ajouter une mesure personnalisée", + "addValue": "Ajouter une valeur", + "defineTitle": "Définir une mesure personnalisée", + "defineDescription": "Nommez la mesure, définissez son unité et éventuellement une plage cible.", + "editTitle": "Modifier la mesure personnalisée", + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirmTitle": "Supprimer cette mesure personnalisée ?", + "deleteConfirmDescription": "La mesure est retirée de votre liste. Les valeurs enregistrées sont conservées.", + "deletedToast": "Mesure personnalisée supprimée", + "deleteError": "Impossible de supprimer la mesure personnalisée", + "showAllValues": "Afficher toutes les valeurs", + "emptyDescription": "Définissez vos propres mesures pour les suivre et les représenter.", + "noValuesYet": "Aucune valeur pour l'instant", + "loadError": "Impossible de charger les mesures personnalisées", + "loadMore": "Charger plus de valeurs", + "showingLatestOf": "Affichage des {shown} dernières sur {total}", + "backToMeasurements": "Retour aux mesures", + "form": { + "name": "Nom", + "namePlaceholder": "p. ex. Force de préhension du matin", + "unit": "Unité", + "unitPlaceholder": "p. ex. kg", + "decimals": "Décimales", + "decimalsPlaceholder": "p. ex. 1", + "targetLow": "Cible basse", + "targetLowPlaceholder": "Borne inférieure", + "targetHigh": "Cible haute", + "targetHighPlaceholder": "Borne supérieure", + "description": "Description", + "descriptionPlaceholder": "Ce que cela mesure (facultatif)", + "targetHint": "La plage cible est facultative et s'affiche comme une bande sur le graphique.", + "requiredError": "Le nom et l'unité sont obligatoires", + "rangeOrderError": "La cible basse ne doit pas dépasser la cible haute", + "save": "Enregistrer", + "savedToast": "Mesure personnalisée enregistrée", + "updatedToast": "Mesure personnalisée mise à jour", + "saveError": "Impossible d'enregistrer la mesure personnalisée" + }, + "entry": { + "value": "Valeur", + "measuredAt": "Date", + "note": "Note", + "addDescription": "Enregistrez une valeur pour cette mesure.", + "edit": "Modifier la valeur", + "editTitle": "Modifier la valeur", + "editDescription": "Mettez à jour la valeur, la date ou la note.", + "delete": "Supprimer la valeur", + "deleteConfirmTitle": "Supprimer cette valeur ?", + "deleteConfirmDescription": "Cette valeur est supprimée définitivement.", + "valueError": "Saisissez une valeur numérique", + "timestampError": "Saisissez une date valide", + "savedToast": "Valeur enregistrée", + "saveError": "Impossible d'enregistrer la valeur", + "editedToast": "Valeur mise à jour", + "editError": "Impossible de mettre à jour la valeur", + "deletedToast": "Valeur supprimée", + "deleteError": "Impossible de supprimer la valeur" + }, + "chart": { + "empty": "Aucune valeur sur cette période", + "valueLabel": "Valeur", + "range": { + "30": "30j", + "90": "90j", + "365": "1a", + "all": "Tout" + } + }, + "detail": { + "title": "Mesure personnalisée", + "genericDescription": "Une mesure que vous suivez. Ajoutez des valeurs pour la suivre dans le temps.", + "emptyTitle": "Aucune valeur pour l'instant", + "emptyDescription": "Ajoutez votre première valeur pour commencer à représenter cette mesure.", + "valuesTitle": "{metric} — toutes les valeurs", + "valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.", + "backToMetric": "Retour à {metric}" + } } } diff --git a/messages/it.json b/messages/it.json index 6fe93d21a..35ad15289 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute." + }, + "customMetrics": { + "sectionTitle": "Metriche personalizzate", + "sectionSubtitle": "Misurazioni che definisci e monitori tu stesso.", + "add": "Aggiungi metrica personalizzata", + "addValue": "Aggiungi valore", + "defineTitle": "Definisci una metrica personalizzata", + "defineDescription": "Dai un nome alla misurazione, imposta l'unità ed eventualmente un intervallo obiettivo.", + "editTitle": "Modifica metrica personalizzata", + "edit": "Modifica", + "delete": "Elimina", + "deleteConfirmTitle": "Eliminare questa metrica personalizzata?", + "deleteConfirmDescription": "La metrica viene rimossa dal tuo elenco. I valori registrati vengono mantenuti.", + "deletedToast": "Metrica personalizzata eliminata", + "deleteError": "Impossibile eliminare la metrica personalizzata", + "showAllValues": "Mostra tutti i valori", + "emptyDescription": "Definisci le tue misurazioni per monitorarle e rappresentarle.", + "noValuesYet": "Ancora nessun valore", + "loadError": "Impossibile caricare le metriche personalizzate", + "loadMore": "Carica altri valori", + "showingLatestOf": "Mostrati gli ultimi {shown} di {total}", + "backToMeasurements": "Torna alle misurazioni", + "form": { + "name": "Nome", + "namePlaceholder": "es. Forza di presa al mattino", + "unit": "Unità", + "unitPlaceholder": "es. kg", + "decimals": "Cifre decimali", + "decimalsPlaceholder": "es. 1", + "targetLow": "Obiettivo minimo", + "targetLowPlaceholder": "Limite inferiore", + "targetHigh": "Obiettivo massimo", + "targetHighPlaceholder": "Limite superiore", + "description": "Descrizione", + "descriptionPlaceholder": "Cosa misura (facoltativo)", + "targetHint": "L'intervallo obiettivo è facoltativo e viene disegnato come una banda sul grafico.", + "requiredError": "Nome e unità sono obbligatori", + "rangeOrderError": "L'obiettivo minimo non può superare quello massimo", + "save": "Salva", + "savedToast": "Metrica personalizzata salvata", + "updatedToast": "Metrica personalizzata aggiornata", + "saveError": "Impossibile salvare la metrica personalizzata" + }, + "entry": { + "value": "Valore", + "measuredAt": "Data", + "note": "Nota", + "addDescription": "Registra un valore per questa metrica.", + "edit": "Modifica valore", + "editTitle": "Modifica valore", + "editDescription": "Aggiorna il valore, la data o la nota.", + "delete": "Elimina valore", + "deleteConfirmTitle": "Eliminare questo valore?", + "deleteConfirmDescription": "Questo valore viene eliminato definitivamente.", + "valueError": "Inserisci un valore numerico", + "timestampError": "Inserisci una data valida", + "savedToast": "Valore registrato", + "saveError": "Impossibile registrare il valore", + "editedToast": "Valore aggiornato", + "editError": "Impossibile aggiornare il valore", + "deletedToast": "Valore eliminato", + "deleteError": "Impossibile eliminare il valore" + }, + "chart": { + "empty": "Nessun valore in questo intervallo", + "valueLabel": "Valore", + "range": { + "30": "30g", + "90": "90g", + "365": "1a", + "all": "Tutti" + } + }, + "detail": { + "title": "Metrica personalizzata", + "genericDescription": "Una misurazione che monitori. Aggiungi valori per seguirla nel tempo.", + "emptyTitle": "Ancora nessun valore", + "emptyDescription": "Aggiungi il primo valore per iniziare a rappresentare questa metrica.", + "valuesTitle": "{metric} — tutti i valori", + "valuesDescription": "Tutti i valori registrati per questa metrica.", + "backToMetric": "Torna a {metric}" + } } } diff --git a/messages/pl.json b/messages/pl.json index c604ea577..f5d479989 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7985,5 +7985,87 @@ }, "selfDescription": { "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych." + }, + "customMetrics": { + "sectionTitle": "Metryki własne", + "sectionSubtitle": "Pomiary, które sam definiujesz i śledzisz.", + "add": "Dodaj metrykę własną", + "addValue": "Dodaj wartość", + "defineTitle": "Zdefiniuj metrykę własną", + "defineDescription": "Nazwij pomiar, ustaw jego jednostkę i opcjonalnie zakres docelowy.", + "editTitle": "Edytuj metrykę własną", + "edit": "Edytuj", + "delete": "Usuń", + "deleteConfirmTitle": "Usunąć tę metrykę własną?", + "deleteConfirmDescription": "Metryka zostanie usunięta z listy. Zapisane wartości pozostaną.", + "deletedToast": "Usunięto metrykę własną", + "deleteError": "Nie udało się usunąć metryki własnej", + "showAllValues": "Pokaż wszystkie wartości", + "emptyDescription": "Zdefiniuj własne pomiary, aby je śledzić i przedstawiać na wykresie.", + "noValuesYet": "Brak wartości", + "loadError": "Nie udało się wczytać metryk własnych", + "loadMore": "Wczytaj więcej wartości", + "showingLatestOf": "Wyświetlono najnowsze {shown} z {total}", + "backToMeasurements": "Powrót do pomiarów", + "form": { + "name": "Nazwa", + "namePlaceholder": "np. Siła chwytu rano", + "unit": "Jednostka", + "unitPlaceholder": "np. kg", + "decimals": "Miejsca dziesiętne", + "decimalsPlaceholder": "np. 1", + "targetLow": "Cel dolny", + "targetLowPlaceholder": "Dolna granica", + "targetHigh": "Cel górny", + "targetHighPlaceholder": "Górna granica", + "description": "Opis", + "descriptionPlaceholder": "Co to mierzy (opcjonalnie)", + "targetHint": "Zakres docelowy jest opcjonalny i jest rysowany jako pasmo na wykresie.", + "requiredError": "Nazwa i jednostka są wymagane", + "rangeOrderError": "Cel dolny nie może przekraczać celu górnego", + "save": "Zapisz", + "savedToast": "Zapisano metrykę własną", + "updatedToast": "Zaktualizowano metrykę własną", + "saveError": "Nie udało się zapisać metryki własnej" + }, + "entry": { + "value": "Wartość", + "measuredAt": "Data", + "note": "Notatka", + "addDescription": "Zapisz wartość dla tej metryki.", + "edit": "Edytuj wartość", + "editTitle": "Edytuj wartość", + "editDescription": "Zaktualizuj wartość, datę lub notatkę.", + "delete": "Usuń wartość", + "deleteConfirmTitle": "Usunąć tę wartość?", + "deleteConfirmDescription": "Ta wartość zostanie trwale usunięta.", + "valueError": "Podaj wartość liczbową", + "timestampError": "Podaj prawidłową datę", + "savedToast": "Zapisano wartość", + "saveError": "Nie udało się zapisać wartości", + "editedToast": "Zaktualizowano wartość", + "editError": "Nie udało się zaktualizować wartości", + "deletedToast": "Usunięto wartość", + "deleteError": "Nie udało się usunąć wartości" + }, + "chart": { + "empty": "Brak wartości w tym zakresie", + "valueLabel": "Wartość", + "range": { + "30": "30d", + "90": "90d", + "365": "1r", + "all": "Wszystko" + } + }, + "detail": { + "title": "Metryka własna", + "genericDescription": "Pomiar, który śledzisz. Dodawaj wartości, aby obserwować go w czasie.", + "emptyTitle": "Brak wartości", + "emptyDescription": "Dodaj pierwszą wartość, aby zacząć przedstawiać tę metrykę na wykresie.", + "valuesTitle": "{metric} — wszystkie wartości", + "valuesDescription": "Wszystkie zapisane wartości tej metryki.", + "backToMetric": "Powrót do {metric}" + } } } diff --git a/prisma/migrations/0224_custom_metrics/migration.sql b/prisma/migrations/0224_custom_metrics/migration.sql new file mode 100644 index 000000000..b2897c94b --- /dev/null +++ b/prisma/migrations/0224_custom_metrics/migration.sql @@ -0,0 +1,81 @@ +-- v1.25.5 — user-defined custom metrics: catalog + values. +-- +-- A self-hoster defines an arbitrary measurement (name, unit, optional target +-- window, decimals, description), logs values against it, and charts them. A +-- SEPARATE generic store from the closed `MeasurementType` enum + `measurements` +-- table: custom metrics are NOT synced, NOT in FHIR, NOT in AI insights — log + +-- chart only. `name` / `unit` / `description` stay plaintext (like the +-- biomarker catalog) so the list can sort + search. +-- +-- `custom_metrics` — the per-user catalog (soft-deleted via deleted_at). +-- `custom_metric_entries` — the logged values (hard-deleted; cascade from both +-- the owning user and the parent metric). +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. No backfill. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "custom_metric_entries"; +-- DROP TABLE IF EXISTS "custom_metrics"; +-- A roll-back drops the feature wholesale — no other domain depends on it. + +-- CreateTable: the per-user custom-metric catalog. +CREATE TABLE IF NOT EXISTS "custom_metrics" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "target_low" DOUBLE PRECISION, + "target_high" DOUBLE PRECISION, + "decimals" INTEGER, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "custom_metrics_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: the logged values for each custom metric. +CREATE TABLE IF NOT EXISTS "custom_metric_entries" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "custom_metric_id" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "measured_at" TIMESTAMP(3) NOT NULL, + "note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "custom_metric_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: owner scope on the catalog. +CREATE INDEX IF NOT EXISTS "custom_metrics_user_id_idx" ON "custom_metrics"("user_id"); + +-- CreateIndex: one catalog definition per (user, name). +CREATE UNIQUE INDEX IF NOT EXISTS "custom_metrics_user_id_name_key" ON "custom_metrics"("user_id", "name"); + +-- CreateIndex: the chart / list read path (owner + metric + time). +CREATE INDEX IF NOT EXISTS "custom_metric_entries_user_id_custom_metric_id_measured_at_idx" ON "custom_metric_entries"("user_id", "custom_metric_id", "measured_at"); + +-- AddForeignKey: catalog → user (cascade). +DO $$ BEGIN + ALTER TABLE "custom_metrics" ADD CONSTRAINT "custom_metrics_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey: entry → user (cascade). +DO $$ BEGIN + ALTER TABLE "custom_metric_entries" ADD CONSTRAINT "custom_metric_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey: entry → metric (cascade). +DO $$ BEGIN + ALTER TABLE "custom_metric_entries" ADD CONSTRAINT "custom_metric_entries_custom_metric_id_fkey" FOREIGN KEY ("custom_metric_id") REFERENCES "custom_metrics"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c65695f79..eb2ff9e3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -735,6 +735,11 @@ model User { // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per // marker); LabResult rows link to a Biomarker for the resolved range/unit. biomarkers Biomarker[] + // v1.25.5 — user-defined custom metrics. A separate generic store + // (catalog + values), fully isolated from the closed MeasurementType + // system: not synced, not in FHIR, not in AI insights — log + chart only. + customMetrics CustomMetric[] + customMetricEntries CustomMetricEntry[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. @@ -4150,6 +4155,67 @@ model Biomarker { @@map("biomarkers") } +// ─── Custom metrics ────────────────────────────────────────── + +/// v1.25.5 — user-defined custom metrics. A self-hoster defines an arbitrary +/// measurement (name, unit, optional target window, decimals, description), +/// logs values against it, and charts them. Deliberately a SEPARATE generic +/// store from the closed `MeasurementType` enum + `Measurement` table: custom +/// metrics are NOT synced (Withings / Apple Health), NOT in FHIR, NOT in AI +/// insights / correlations / health-score / dashboard tiles — log + chart only. +/// `name` / `unit` / `description` are PLAINTEXT (they may hold personal text +/// but, like the Biomarker catalog, stay clear-text so the list can sort + +/// search). Unique per user + name so there is never a second definition. +/// Soft-deleted. +model CustomMetric { + id String @id @default(cuid()) + userId String @map("user_id") + /// Free-text display name, e.g. "Morning grip strength". + name String + /// Free-text unit label, e.g. "kg", "reps", "score". + unit String + /// Optional target window. Both independently optional; when both present + /// the low bound must not exceed the high bound (charted as a reference band). + targetLow Float? @map("target_low") + targetHigh Float? @map("target_high") + /// Optional fixed decimal places for display (null = sensible default). + decimals Int? + /// Optional free-text description ("what this measures"). Plaintext. + description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + entries CustomMetricEntry[] + + @@unique([userId, name]) + @@index([userId]) + @@map("custom_metrics") +} + +/// v1.25.5 — a single logged value against a `CustomMetric`. The `unit` is a +/// snapshot of the metric's unit at write time (historical truth, mirroring the +/// LabResult per-row unit). Charts read these LIVE (no rollup tier). +model CustomMetricEntry { + id String @id @default(cuid()) + userId String @map("user_id") + customMetricId String @map("custom_metric_id") + value Float + /// Unit snapshot from the metric at write time. + unit String + measuredAt DateTime @map("measured_at") + note String? + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade) + + @@index([userId, customMetricId, measuredAt]) + @@map("custom_metric_entries") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement { diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..26ac5a335 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + customMetric: { findFirst: vi.fn(), update: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH, DELETE } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const EXISTING = { + id: "cm-1", + userId: "user-1", + name: "Grip strength", + unit: "kg", + targetLow: 10, + targetHigh: 60, + decimals: 1, + description: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + deletedAt: null, +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function patchReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/custom-metrics/cm-1", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/custom-metrics/cm-1", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/custom-metrics/[id]", () => { + it("returns the metric for its owner", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( + EXISTING as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/custom-metrics/cm-1"), + params("cm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { name: string } | null }; + expect(body.data?.name).toBe("Grip strength"); + }); + + it("404s a cross-user metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/custom-metrics/cm-1"), + params("cm-1"), + ); + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /api/custom-metrics/[id]", () => { + it("persists an updated target window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( + EXISTING as never, + ); + vi.mocked(prisma.customMetric.update).mockResolvedValue({ + ...EXISTING, + targetLow: 20, + targetHigh: 50, + } as never); + + const res = await PATCH( + patchReq({ targetLow: 20, targetHigh: 50 }), + params("cm-1"), + ); + expect(res.status).toBe(200); + const updateCall = vi.mocked(prisma.customMetric.update).mock + .calls[0][0] as { data: Record }; + expect(updateCall.data.targetLow).toBe(20); + expect(updateCall.data.targetHigh).toBe(50); + }); + + it("422s an inverted partial bound update without updating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // existing.targetHigh is 60; moving the low bound above it inverts. + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( + EXISTING as never, + ); + + const res = await PATCH(patchReq({ targetLow: 80 }), params("cm-1")); + expect(res.status).toBe(422); + expect(prisma.customMetric.update).not.toHaveBeenCalled(); + }); + + it("409s a rename onto another live metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst) + .mockResolvedValueOnce(EXISTING as never) + .mockResolvedValueOnce({ id: "cm-other" } as never); + + const res = await PATCH(patchReq({ name: "Other" }), params("cm-1")); + expect(res.status).toBe(409); + expect(prisma.customMetric.update).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/custom-metrics/[id]", () => { + it("soft-deletes by stamping deletedAt", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( + EXISTING as never, + ); + vi.mocked(prisma.customMetric.update).mockResolvedValue(EXISTING as never); + + const res = await DELETE(delReq(), params("cm-1")); + expect(res.status).toBe(200); + const updateCall = vi.mocked(prisma.customMetric.update).mock + .calls[0][0] as { data: { deletedAt: Date } }; + expect(updateCall.data.deletedAt).toBeInstanceOf(Date); + }); + + it("404s a cross-user metric without deleting", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await DELETE(delReq(), params("cm-1")); + expect(res.status).toBe(404); + expect(prisma.customMetric.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts new file mode 100644 index 000000000..4f12e9af2 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -0,0 +1,126 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics"; + +/** + * v1.25.5 — single logged value + * (`/api/custom-metrics/{id}/entries/{entryId}`). + * + * PATCH applies a partial edit (`data` built field-by-field; an explicit `null` + * on `note` clears it, an omitted key leaves the column untouched). DELETE + * hard-deletes the value. Every read/write is narrowed by both `userId` and the + * parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404. + */ + +type RouteParams = { params: Promise<{ id: string; entryId: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, entryId } = await params; + + const existing = await prisma.customMetricEntry.findFirst({ + where: { id: entryId, userId: user.id, customMetricId: id }, + }); + if (!existing) { + return apiError("Custom metric entry not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateCustomMetricEntrySchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "custom-metric.entry.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, entryId }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.entry.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, entryId }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on `note` clears it. + const data: Record = {}; + if (d.value !== undefined) data.value = d.value; + if (d.measuredAt !== undefined) data.measuredAt = d.measuredAt; + if (d.note !== undefined) data.note = d.note; + + const updated = await prisma.customMetricEntry.update({ + where: { id: entryId }, + data, + }); + + await auditLog("customMetricEntry.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id, entryId }, + }); + + annotate({ + action: { name: "custom-metric.entry.update" }, + meta: { customMetricId: id, entryId }, + }); + + return apiSuccess(serialiseCustomMetricEntry(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, entryId } = await params; + + const existing = await prisma.customMetricEntry.findFirst({ + where: { id: entryId, userId: user.id, customMetricId: id }, + }); + if (!existing) { + return apiError("Custom metric entry not found", 404); + } + + await prisma.customMetricEntry.delete({ where: { id: entryId } }); + + await auditLog("customMetricEntry.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id, entryId }, + }); + + annotate({ + action: { name: "custom-metric.entry.delete" }, + meta: { customMetricId: id, entryId }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts new file mode 100644 index 000000000..d02cd4a08 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + customMetric: { findFirst: vi.fn() }, + customMetricEntry: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: (fn: unknown) => fn, +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +const ENTRY = { + id: "e-1", + userId: "user-1", + customMetricId: "cm-1", + value: 42, + unit: "kg", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + note: null, + createdAt: new Date("2026-06-10T00:00:00.000Z"), +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/custom-metrics/[id]/entries", () => { + it("paginates the value feed and returns the total", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + id: "cm-1", + } as never); + vi.mocked(prisma.customMetricEntry.findMany).mockResolvedValue([ + ENTRY, + ] as never); + vi.mocked(prisma.customMetricEntry.count).mockResolvedValue(5 as never); + + const res = await GET( + new NextRequest( + "http://localhost/api/custom-metrics/cm-1/entries?limit=1&offset=0&sortDir=desc", + ), + params("cm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: unknown[]; meta: { total: number; limit: number } }; + }; + expect(body.data.entries).toHaveLength(1); + expect(body.data.meta.total).toBe(5); + expect(body.data.meta.limit).toBe(1); + const findCall = vi.mocked(prisma.customMetricEntry.findMany).mock + .calls[0][0] as { where: Record; take: number }; + expect(findCall.where.userId).toBe("user-1"); + expect(findCall.where.customMetricId).toBe("cm-1"); + expect(findCall.take).toBe(1); + }); + + it("404s a cross-user / unknown metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never); + + const res = await GET( + new NextRequest("http://localhost/api/custom-metrics/cm-x/entries"), + params("cm-x"), + ); + expect(res.status).toBe(404); + expect(prisma.customMetricEntry.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/custom-metrics/[id]/entries", () => { + it("logs a value, snapshotting the metric's unit", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + id: "cm-1", + unit: "kg", + } as never); + vi.mocked(prisma.customMetricEntry.create).mockResolvedValue( + ENTRY as never, + ); + + const res = await POST( + new NextRequest("http://localhost/api/custom-metrics/cm-1/entries", { + method: "POST", + body: JSON.stringify({ + value: 42, + measuredAt: "2026-06-10T00:00:00.000Z", + }), + headers: { "content-type": "application/json" }, + }), + params("cm-1"), + ); + expect(res.status).toBe(201); + const createCall = vi.mocked(prisma.customMetricEntry.create).mock + .calls[0][0] as { data: Record }; + expect(createCall.data.userId).toBe("user-1"); + expect(createCall.data.customMetricId).toBe("cm-1"); + expect(createCall.data.unit).toBe("kg"); + expect(createCall.data.value).toBe(42); + }); + + it("404s when the parent metric is not the caller's", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never); + + const res = await POST( + new NextRequest("http://localhost/api/custom-metrics/cm-x/entries", { + method: "POST", + body: JSON.stringify({ + value: 1, + measuredAt: "2026-06-10T00:00:00.000Z", + }), + headers: { "content-type": "application/json" }, + }), + params("cm-x"), + ); + expect(res.status).toBe(404); + expect(prisma.customMetricEntry.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts new file mode 100644 index 000000000..c91a5e695 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -0,0 +1,157 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { + createCustomMetricEntrySchema, + listCustomMetricEntriesSchema, +} from "@/lib/validations/custom-metrics"; + +/** + * v1.25.5 — logged values for one custom metric + * (`/api/custom-metrics/{id}/entries`). + * + * GET lists the metric's values with offset pagination (the chart + history + * feed). POST records a value, snapshotting the metric's current `unit` onto + * the entry at write time (historical truth). `userId` is always narrowed from + * the session and the parent metric is verified owner-scoped (a forged / foreign + * id is a 404) before any value is read or written. + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const metric = await prisma.customMetric.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!metric) { + return apiError("Custom metric not found", 404); + } + + const query = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listCustomMetricEntriesSchema.safeParse(query); + if (!parsed.success) { + annotate({ + action: { name: "custom-metric.entry.list.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { limit, offset, sortDir } = parsed.data; + const where = { userId: user.id, customMetricId: id }; + + const [rows, total] = await Promise.all([ + prisma.customMetricEntry.findMany({ + where, + orderBy: { measuredAt: sortDir }, + take: limit, + skip: offset, + }), + prisma.customMetricEntry.count({ where }), + ]); + + annotate({ + action: { name: "custom-metric.entry.list" }, + meta: { customMetricId: id, total, limit, offset }, + }); + + return apiSuccess({ + entries: rows.map(serialiseCustomMetricEntry), + meta: { total, limit, offset }, + }); + }, +); + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(postCustomMetricEntry), +); + +async function postCustomMetricEntry( + request: NextRequest, + { params }: RouteParams, +) { + const { user } = await requireAuth(); + const { id } = await params; + + const metric = await prisma.customMetric.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true, unit: true }, + }); + if (!metric) { + return apiError("Custom metric not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createCustomMetricEntrySchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "custom-metric.entry.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, customMetricId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.entry.create.validation-failed", + details: JSON.stringify({ issues: auditIssues, customMetricId: id }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { value, measuredAt, note } = parsed.data; + + // Field-by-field assignment — never spread `parsed.data`. The metric's + // current unit is snapshotted onto the entry as historical truth. + const created = await prisma.customMetricEntry.create({ + data: { + userId: user.id, + customMetricId: metric.id, + value, + unit: metric.unit, + measuredAt, + note: note ?? null, + }, + }); + + await auditLog("customMetricEntry.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id, entryId: created.id }, + }); + + annotate({ + action: { name: "custom-metric.entry.create" }, + meta: { customMetricId: id, entryId: created.id }, + }); + + return apiSuccess(serialiseCustomMetricEntry(created), 201); +} diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts new file mode 100644 index 000000000..4f3c709a7 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -0,0 +1,185 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics"; + +/** + * v1.25.5 — single custom-metric resource (`/api/custom-metrics/{id}`). + * + * GET returns the metric definition. PATCH applies a partial edit (`data` built + * field-by-field; an explicit `null` clears `targetLow` / `targetHigh` / + * `decimals` / `description`, an omitted key leaves it untouched). DELETE + * soft-deletes by stamping `deletedAt`. Cross-user rows surface as 404 + * (existence sealed). + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Resolve an effective bound: the parsed value when present, else the stored. */ +function effectiveBound( + parsed: number | null | undefined, + stored: number | null, +): number | null { + return parsed !== undefined ? parsed : stored; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.customMetric.findFirst({ + where: { id, deletedAt: null }, + }); + if (!row || row.userId !== user.id) { + return apiError("Custom metric not found", 404); + } + + annotate({ + action: { name: "custom-metric.metric.get" }, + meta: { customMetricId: id }, + }); + + return apiSuccess(serialiseCustomMetric(row)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.customMetric.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Custom metric not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateCustomMetricSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "custom-metric.metric.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, customMetricId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.update.validation-failed", + details: JSON.stringify({ + issues: auditIssues, + customMetricId: id, + }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // A rename must not collide with another of the caller's LIVE metrics. + if (d.name !== undefined && d.name !== existing.name) { + const clash = await prisma.customMetric.findFirst({ + where: { userId: user.id, name: d.name, deletedAt: null }, + select: { id: true }, + }); + if (clash) { + return apiError("A custom metric with this name already exists", 409); + } + } + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on a target bound / decimals / description clears it. + const data: Record = {}; + if (d.name !== undefined) data.name = d.name; + if (d.unit !== undefined) data.unit = d.unit; + if (d.targetLow !== undefined) data.targetLow = d.targetLow; + if (d.targetHigh !== undefined) data.targetHigh = d.targetHigh; + if (d.decimals !== undefined) data.decimals = d.decimals; + if (d.description !== undefined) data.description = d.description; + + // Inverted-range guard for a PARTIAL bound update. The schema refine only + // fires when both bounds arrive together; moving a single bound past the + // row's existing other bound would otherwise persist an inverted window. + const low = effectiveBound(d.targetLow, existing.targetLow); + const high = effectiveBound(d.targetHigh, existing.targetHigh); + if (low !== null && high !== null && low > high) { + return apiError("targetLow must not exceed targetHigh", 422); + } + + const updated = await prisma.customMetric.update({ where: { id }, data }); + + await auditLog("customMetric.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id }, + }); + + annotate({ + action: { name: "custom-metric.metric.update" }, + meta: { customMetricId: id }, + }); + + return apiSuccess(serialiseCustomMetric(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.customMetric.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Custom metric not found", 404); + } + + // Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`, so + // the metric is invisible from here on; its logged values are retained and + // a re-create under the same name revives the row (see POST). A re-delete + // is idempotent (the find above already filtered it out). + await prisma.customMetric.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("customMetric.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id }, + }); + + annotate({ + action: { name: "custom-metric.metric.delete" }, + meta: { customMetricId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts new file mode 100644 index 000000000..7e885592e --- /dev/null +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + customMetric: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + customMetricEntry: { findFirst: vi.fn(), count: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: (fn: unknown) => fn, +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/custom-metrics", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +const ROW = { + id: "cm-1", + userId: "user-1", + name: "Grip strength", + unit: "kg", + targetLow: null, + targetHigh: null, + decimals: null, + description: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + deletedAt: null, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/custom-metrics", () => { + it("lists the caller's metrics with the latest value", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findMany).mockResolvedValue([ + { + ...ROW, + entries: [ + { + value: 42, + unit: "kg", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + }, + ], + _count: { entries: 3 }, + }, + ] as never); + + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { customMetrics: Array<{ latest: { value: number } | null }> }; + }; + expect(body.data.customMetrics).toHaveLength(1); + expect(body.data.customMetrics[0].latest?.value).toBe(42); + }); +}); + +describe("POST /api/custom-metrics", () => { + it("creates a metric field-by-field", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.customMetric.create).mockResolvedValue(ROW as never); + + const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); + expect(res.status).toBe(201); + const createCall = vi.mocked(prisma.customMetric.create).mock + .calls[0][0] as { + data: Record; + }; + expect(createCall.data.userId).toBe("user-1"); + expect(createCall.data.name).toBe("Grip strength"); + expect("userId" in createCall.data).toBe(true); + }); + + it("409s a duplicate live name without creating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + id: "cm-existing", + deletedAt: null, + } as never); + + const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); + expect(res.status).toBe(409); + expect(prisma.customMetric.create).not.toHaveBeenCalled(); + }); + + it("revives a soft-deleted metric of the same name", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + id: "cm-old", + deletedAt: new Date("2026-05-01T00:00:00.000Z"), + } as never); + vi.mocked(prisma.customMetric.update).mockResolvedValue({ + ...ROW, + id: "cm-old", + } as never); + vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.customMetricEntry.count).mockResolvedValue(0 as never); + + const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); + expect(res.status).toBe(201); + const updateCall = vi.mocked(prisma.customMetric.update).mock + .calls[0][0] as { + data: Record; + }; + expect(updateCall.data.deletedAt).toBeNull(); + expect(prisma.customMetric.create).not.toHaveBeenCalled(); + }); + + it("422s an inverted target range", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await POST( + postReq({ name: "X", unit: "kg", targetLow: 80, targetHigh: 20 }), + ); + expect(res.status).toBe(422); + expect(prisma.customMetric.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts new file mode 100644 index 000000000..2dc7e8a59 --- /dev/null +++ b/src/app/api/custom-metrics/route.ts @@ -0,0 +1,172 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { createCustomMetricSchema } from "@/lib/validations/custom-metrics"; + +/** + * v1.25.5 — user-defined custom-metric catalog (`/api/custom-metrics`). + * + * A SEPARATE generic store from the closed `MeasurementType` system: not synced, + * not in FHIR, not in AI insights — log + chart only. GET lists the caller's + * metrics with their latest logged value; POST defines a new one. `userId` is + * always narrowed from the session — never a body field — and the write `data` + * object is built field-by-field (no mass assignment). The `@@unique([userId, + * name])` index means there is never a second definition of the same name. + */ + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rows = await prisma.customMetric.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { name: "asc" }, + include: { + entries: { + orderBy: { measuredAt: "desc" }, + take: 1, + select: { value: true, unit: true, measuredAt: true }, + }, + _count: { select: { entries: true } }, + }, + }); + + annotate({ + action: { name: "custom-metric.metric.list" }, + meta: { total: rows.length }, + }); + + return apiSuccess({ + customMetrics: rows.map((row) => + serialiseCustomMetricWithLatest({ + ...row, + latest: row.entries[0] ?? null, + entryCount: row._count.entries, + }), + ), + }); +}); + +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postCustomMetric), +); + +async function postCustomMetric(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createCustomMetricSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "custom-metric.metric.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { name, unit, targetLow, targetHigh, decimals, description } = + parsed.data; + + // The `@@unique([userId, name])` index is the structural backstop and spans + // soft-deleted rows too. Resolve the name up front: a LIVE collision is a + // clean 409; a row that was previously soft-deleted under this name is + // REVIVED (un-deleted + redefined) rather than blocked by the index, so the + // user can re-create a metric they once removed. + const existing = await prisma.customMetric.findFirst({ + where: { userId: user.id, name }, + select: { id: true, deletedAt: true }, + }); + if (existing && existing.deletedAt === null) { + return apiError("A custom metric with this name already exists", 409); + } + + // Field-by-field assignment — never spread `parsed.data`. + const created = existing + ? await prisma.customMetric.update({ + where: { id: existing.id }, + data: { + unit, + targetLow: targetLow ?? null, + targetHigh: targetHigh ?? null, + decimals: decimals ?? null, + description: description ?? null, + deletedAt: null, + }, + }) + : await prisma.customMetric.create({ + data: { + userId: user.id, + name, + unit, + targetLow: targetLow ?? null, + targetHigh: targetHigh ?? null, + decimals: decimals ?? null, + description: description ?? null, + }, + }); + + await auditLog("customMetric.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: created.id }, + }); + + annotate({ + action: { name: "custom-metric.metric.create" }, + meta: { customMetricId: created.id, revived: existing !== null }, + }); + + // A freshly created metric has no values; a revived one may retain entries + // logged before it was soft-deleted, so resolve the latest + count for it. + let latest: { value: number; unit: string; measuredAt: Date } | null = null; + let entryCount = 0; + if (existing) { + const [latestEntry, count] = await Promise.all([ + prisma.customMetricEntry.findFirst({ + where: { userId: user.id, customMetricId: created.id }, + orderBy: { measuredAt: "desc" }, + select: { value: true, unit: true, measuredAt: true }, + }), + prisma.customMetricEntry.count({ + where: { userId: user.id, customMetricId: created.id }, + }), + ]); + latest = latestEntry ?? null; + entryCount = count; + } + + return apiSuccess( + serialiseCustomMetricWithLatest({ ...created, latest, entryCount }), + 201, + ); +} diff --git a/src/app/custom-metrics/[id]/page.tsx b/src/app/custom-metrics/[id]/page.tsx new file mode 100644 index 000000000..39e787634 --- /dev/null +++ b/src/app/custom-metrics/[id]/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +/** + * v1.25.5 — per-custom-metric detail page (`/custom-metrics/[id]`). + * + * Owns auth + the back link to the Measurements surface and hands the metric id + * to ``, which carries the heading, the trend chart with + * the target band, the stat strip, and the value controls. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { CustomMetricDetail } from "@/components/custom-metrics/custom-metric-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function CustomMetricDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/custom-metrics/[id]/values/page.tsx b/src/app/custom-metrics/[id]/values/page.tsx new file mode 100644 index 000000000..321c3f87c --- /dev/null +++ b/src/app/custom-metrics/[id]/values/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +/** + * v1.25.5 — `/custom-metrics/[id]/values`. + * + * The "show all values" sub-page for one custom metric, mirroring the labs + * values sub-page. The detail page keeps the numbers-first spine and links here + * for the raw, editable value feed. Owns auth + the back link to the detail. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { CustomMetricValuesList } from "@/components/custom-metrics/custom-metric-values-list"; +import type { CustomMetricDto } from "@/components/custom-metrics/types"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export default function CustomMetricValuesPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + const { data: metric } = useQuery({ + queryKey: queryKeys.customMetricDetail(id), + queryFn: () => apiGet(`/api/custom-metrics/${id}`), + enabled: isAuthenticated && id.length > 0, + }); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + const metricName = metric?.name ?? ""; + + return ( + + } + > + + + ); +} diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 8411579ba..cb506e45e 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -10,6 +10,7 @@ import { resolveAddToken, } from "@/components/measurements/measurement-form"; import { MeasurementList } from "@/components/measurements/measurement-list"; +import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { Button } from "@/components/ui/button"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; @@ -177,6 +178,8 @@ export default function MeasurementsPage() { onAddFirst={() => setDialogOpen(true)} initialType={initialType} /> + + ); } diff --git a/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx b/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx new file mode 100644 index 000000000..3619d9fae --- /dev/null +++ b/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { + CustomMetricDto, + CustomMetricEntryDto, + CustomMetricEntryListResponse, +} from "../types"; + +/** + * The custom-metric detail page mirrors the labs biomarker detail: a + * numbers-first stat strip over the values, the (lazy) chart, and the value + * controls. This test seeds the metric + value queries and asserts the heading, + * the stat strip, the chart placeholder, and the controls all paint. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), + apiPost: vi.fn(), + apiPatch: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +import { CustomMetricDetail } from "../custom-metric-detail"; + +const METRIC_ID = "cm-1"; + +const METRIC: CustomMetricDto = { + id: METRIC_ID, + name: "Morning grip strength", + unit: "kg", + targetLow: 20, + targetHigh: 60, + decimals: 1, + description: "Right-hand grip measured first thing.", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", +}; + +function entry( + id: string, + value: number, + measuredAt: string, +): CustomMetricEntryDto { + return { + id, + customMetricId: METRIC_ID, + value, + unit: "kg", + measuredAt, + note: null, + createdAt: measuredAt, + }; +} + +const LIST: CustomMetricEntryListResponse = { + entries: [ + entry("e3", 45, "2026-06-20T08:00:00.000Z"), + entry("e2", 43, "2026-05-20T08:00:00.000Z"), + entry("e1", 41, "2026-04-20T08:00:00.000Z"), + ], + meta: { total: 3, limit: 200, offset: 0 }, +}; + +function renderWithCache() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.customMetricDetail(METRIC_ID), METRIC); + queryClient.setQueryData( + queryKeys.customMetricEntries({ + customMetricId: METRIC_ID, + sortDir: "desc", + }), + { pages: [LIST], pageParams: [0] }, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + const html = renderWithCache(); + + it("paints the metric heading", () => { + expect(html).toContain("Morning grip strength"); + }); + + it("paints the user description", () => { + expect(html).toContain("Right-hand grip measured first thing."); + }); + + it("paints the numbers-first stat strip", () => { + expect(html).toContain('data-slot="metric-stat-strip"'); + }); + + it("paints the chart placeholder (lazy chart shell)", () => { + expect(html).toContain('data-slot="skeleton"'); + }); + + it("offers the edit + delete controls with hover tooltips", () => { + expect(html).toContain('title="Edit"'); + expect(html).toContain('title="Delete"'); + }); +}); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx new file mode 100644 index 000000000..f669b3fb6 --- /dev/null +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + CartesianGrid, + ComposedChart, + Line, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Button } from "@/components/ui/button"; +import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { formatDateShort } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; + +import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; +import { formatMetricValue } from "./format-value"; +import type { CustomMetricEntryDto } from "./types"; + +/** + * v1.25.5 — per-custom-metric trend chart, in the dashboard's visual language. + * + * A single line over time, the optional target window painted as a shaded + * `ReferenceArea` band with dashed bound lines, range tabs (30 / 90 / 365 / + * all), and the shared rich tooltip. The band uses a MUTED primary fill — never + * a danger colour. Mirrors `lab-biomarker-chart`. Reads entries LIVE (no + * rollup tier). + */ + +const RANGE_DAYS = [ + { key: "30", days: 30 }, + { key: "90", days: 90 }, + { key: "365", days: 365 }, + { key: "all", days: 0 }, +] as const; + +type RangeKey = (typeof RANGE_DAYS)[number]["key"]; + +interface ChartPoint { + timestamp: number; + value: number; +} + +export function CustomMetricChart({ + entries, + unit, + targetLow, + targetHigh, + decimals, +}: { + /** Entries for this metric, any order (sorted internally). */ + entries: CustomMetricEntryDto[]; + unit: string; + targetLow: number | null; + targetHigh: number | null; + decimals: number | null; +}) { + const { t } = useTranslations(); + const [range, setRange] = useState("365"); + const [nowMs] = useState(() => Date.now()); + + const points = useMemo(() => { + const sorted = [...entries].sort( + (a, b) => + new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime(), + ); + const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0; + const filtered = + days > 0 + ? sorted.filter((r) => { + const ageMs = nowMs - new Date(r.measuredAt).getTime(); + return ageMs <= days * 24 * 60 * 60 * 1000; + }) + : sorted; + return filtered.map((r) => ({ + timestamp: new Date(r.measuredAt).getTime(), + value: r.value, + })); + }, [entries, range, nowMs]); + + const yDomain = useMemo<[number, number]>(() => { + const candidates = points.map((p) => p.value); + if (targetLow != null) candidates.push(targetLow); + if (targetHigh != null) candidates.push(targetHigh); + if (candidates.length === 0) return [0, 1]; + const min = Math.min(...candidates); + const max = Math.max(...candidates); + const pad = (max - min || Math.abs(max) || 1) * 0.1; + return [min - pad, max + pad]; + }, [points, targetLow, targetHigh]); + + const animate = !prefersReducedMotion(); + const primary = "var(--primary)"; + + return ( +
+
+ {RANGE_DAYS.map((r) => ( + + ))} +
+ + {points.length === 0 ? ( +

+ {t("customMetrics.chart.empty")} +

+ ) : ( +
+ + + + formatDateShort(new Date(ts))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={32} + /> + formatMetricValue(v, decimals)} + /> + {targetLow != null && targetHigh != null ? ( + + ) : null} + {targetLow != null ? ( + + ) : null} + {targetHigh != null ? ( + + ) : null} + { + const active = props.active ?? false; + const payload = props.payload as + ReadonlyArray<{ payload?: ChartPoint }> | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rows: RichTooltipRow[] = [ + { + name: t("customMetrics.chart.valueLabel"), + value: `${formatMetricValue(point.value, decimals)} ${unit}`, + color: primary, + }, + ]; + return ( + + ); + }} + /> + + + +
+ )} +
+ ); +} diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx new file mode 100644 index 000000000..e0f51a54a --- /dev/null +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import { DeleteButton } from "@/components/data-list"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { summarize, type DataSummary } from "@/lib/analytics/trends"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; + +import { CustomMetricEntryForm } from "./custom-metric-entry-form"; +import { CustomMetricForm } from "./custom-metric-form"; +import type { + CustomMetricDto, + CustomMetricEntryDto, + CustomMetricEntryListResponse, +} from "./types"; + +// Defer the recharts chart through `next/dynamic` (ssr:false) so recharts is +// off the detail page's first-load JS; the skeleton shell holds the layout. +const CustomMetricChartLazy = dynamic( + () => + importWithRetry(() => import("./custom-metric-chart")).then((mod) => ({ + default: mod.CustomMetricChart, + })), + { ssr: false, loading: () => }, +); +function CustomMetricChart( + props: ComponentProps, +) { + return ( + + + + ); +} + +const ENTRIES_PAGE_SIZE = 200; + +/** + * v1.25.5 — per-custom-metric detail: heading + unit + static description, + * numbers-first stat strip, the LIVE trend chart with the target band, and the + * "show all values" sub-page link. Controls, left → right: Delete · Edit · Show + * all values · Add value. Numeric-only. Mirrors the labs biomarker detail. + */ +export function CustomMetricDetail({ + customMetricId, +}: { + customMetricId: string; +}) { + const { t } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const [addOpen, setAddOpen] = useState(false); + const [addFooterEl, setAddFooterEl] = useState(null); + const [editOpen, setEditOpen] = useState(false); + const [editFooterEl, setEditFooterEl] = useState(null); + + const { + data: metric, + isError: metricError, + refetch: refetchMetric, + } = useQuery({ + queryKey: queryKeys.customMetricDetail(customMetricId), + queryFn: () => + apiGet(`/api/custom-metrics/${customMetricId}`), + }); + + const { + data: list, + isLoading, + isError: listError, + refetch: refetchList, + } = useInfiniteQuery({ + queryKey: queryKeys.customMetricEntries({ + customMetricId, + sortDir: "desc", + }), + initialPageParam: 0, + queryFn: ({ pageParam }) => + apiGet( + `/api/custom-metrics/${encodeURIComponent( + customMetricId, + )}/entries?limit=${ENTRIES_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`, + ), + getNextPageParam: (lastPage) => { + const next = lastPage.meta.offset + lastPage.meta.limit; + return next < lastPage.meta.total ? next : undefined; + }, + }); + + const entries: CustomMetricEntryDto[] = useMemo( + () => list?.pages.flatMap((p) => p.entries) ?? [], + [list], + ); + + const summary = useMemo( + () => + entries.length > 0 + ? summarize( + entries.map((r) => ({ + date: new Date(r.measuredAt), + value: r.value, + })), + ) + : null, + [entries], + ); + + function afterAdd() { + setAddOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.customMetricEntries({ + customMetricId, + sortDir: "desc", + }), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.customMetricDetail(customMetricId), + }); + } + + function afterEditMetric() { + setEditOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.customMetricDetail(customMetricId), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); + } + + const deleteMetric = useMutation({ + mutationFn: () => apiDelete(`/api/custom-metrics/${customMetricId}`), + onSuccess: () => { + toast.success(t("customMetrics.deletedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); + router.push("/measurements"); + }, + onError: () => toast.error(t("customMetrics.deleteError")), + }); + + const description = metric?.description?.trim() + ? metric.description.trim() + : t("customMetrics.detail.genericDescription"); + + if (metricError || listError) { + return ( +
+
+

+ {metric?.name ?? t("customMetrics.detail.title")} +

+

+ {description} +

+
+ + +

+ {t("customMetrics.loadError")} +

+ +
+
+
+ ); + } + + return ( +
+
+
+

+ {metric?.name ?? } +

+ {metric?.unit ? ( +

+ {metric.unit} +

+ ) : null} +
+
+ {/* Controls, left → right: Delete · Edit · Show-all-values · Add. */} + deleteMetric.mutate()} + title={t("customMetrics.deleteConfirmTitle")} + description={t("customMetrics.deleteConfirmDescription")} + confirmLabel={t("customMetrics.delete")} + triggerTitle={t("customMetrics.delete")} + className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" + iconClassName="h-4 w-4" + /> + + {entries.length > 0 ? ( + + ) : null} + +
+
+ +

+ {description} +

+ + {isLoading ? ( + + + + + + ) : entries.length === 0 ? ( + + + } + title={t("customMetrics.detail.emptyTitle")} + description={t("customMetrics.detail.emptyDescription")} + action={ + + } + /> + + + ) : ( + <> + + + + + + + + )} + + + } + > + {metric ? ( + setAddOpen(false)} + /> + ) : null} + + + + } + > + {metric ? ( + setEditOpen(false)} + /> + ) : null} + +
+ ); +} diff --git a/src/components/custom-metrics/custom-metric-entry-form.tsx b/src/components/custom-metrics/custom-metric-entry-form.tsx new file mode 100644 index 000000000..da9c661bf --- /dev/null +++ b/src/components/custom-metrics/custom-metric-entry-form.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import type { CustomMetricEntryDto } from "./types"; + +const NOTE_MAX_LENGTH = 2000; + +function parseDecimal(raw: string): number | null { + const trimmed = raw.trim().replace(",", "."); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +/** Local datetime string (`YYYY-MM-DDTHH:mm`) for "now", for the field default. */ +function nowLocal(): string { + const d = new Date(); + const offset = d.getTimezoneOffset(); + return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 16); +} + +interface CustomMetricEntryFormProps { + customMetricId: string; + unit: string; + onSuccess?: () => void; + onCancel?: () => void; + footerSlot?: HTMLElement | null; +} + +/** + * v1.25.5 — log a value against a custom metric. Numeric value + timestamp + + * optional note. The server snapshots the metric's unit onto the entry. + */ +export function CustomMetricEntryForm({ + customMetricId, + unit, + onSuccess, + onCancel, + footerSlot, +}: CustomMetricEntryFormProps) { + const { t } = useTranslations(); + const formId = useId(); + const queryClient = useQueryClient(); + + const [value, setValue] = useState(""); + const [measuredAt, setMeasuredAt] = useState(() => nowLocal()); + const [note, setNote] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const numeric = parseDecimal(value); + if (numeric === null) { + setError(t("customMetrics.entry.valueError")); + return; + } + const measuredDate = new Date(measuredAt); + if (Number.isNaN(measuredDate.getTime())) { + setError(t("customMetrics.entry.timestampError")); + return; + } + + const body = { + value: numeric, + measuredAt: measuredDate.toISOString(), + ...(note.trim() ? { note: note.trim() } : {}), + }; + + setSubmitting(true); + try { + await apiPost( + `/api/custom-metrics/${customMetricId}/entries`, + body, + ); + toast.success(t("customMetrics.entry.savedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.customMetricDetail(customMetricId), + }); + queryClient.invalidateQueries({ + queryKey: ["custom-metric-entries", customMetricId], + }); + onSuccess?.(); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : t("customMetrics.entry.saveError"); + setError(message); + } finally { + setSubmitting(false); + } + } + + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + + return ( +
+
+
+ + setValue(e.target.value)} + autoFocus + required + /> +
+
+ + +
+
+
+ +