Merge pull request #391 from MBombeck/release/v1.25.9

release: v1.25.9 — custom metrics
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-30 09:57:35 +02:00
commit 9bba6204b4
40 files changed
+5017 -12

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.25.9] — 2026-06-30 — Custom metrics
### Added
- **Custom metrics.** Define your own measurement — a name, a unit, optional target range, decimal places, and a short description — then log values against it and chart the trend over time, right alongside the built-in measurements. Useful for anything the standard catalog doesn't cover (a symptom score you track, a device reading, a habit count). Each definition and its values are private to your account; they are a deliberately separate, log-and-chart store, so they are not synced to Withings or Apple Health, not exported to FHIR, and not used by AI Insights.
### Fixed
- The settings pages could intermittently render with non-interactive controls (a disclosure that wouldn't open, an export button that did nothing) when the section navigation resolved a different set of entries on the server than on the client. The navigation now renders identically on both passes and applies its module-based filtering once after load, so every settings control stays responsive.
## [1.25.8] — 2026-06-29 — Carrier in the sign-in overview
A focused configuration release. No schema change.
+638 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.25.8
version: 1.25.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -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:
+82
View File
@@ -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}"
}
}
}
+82
View File
@@ -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}"
}
}
}
+82
View File
@@ -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}"
}
}
}
+82
View File
@@ -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}"
}
}
}
+82
View File
@@ -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}"
}
}
}
+82
View File
@@ -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}"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.25.8",
"version": "1.25.9",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -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 $$;
+66
View File
@@ -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 {
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.25.8";
/* @sw-version-fallback */ "v1.25.9";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -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<string, unknown> };
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();
});
});
@@ -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<string, unknown> = {};
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 });
},
);
@@ -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<string, unknown>; 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<string, unknown> };
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();
});
});
@@ -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);
}
+185
View File
@@ -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<string, unknown> = {};
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 });
},
);
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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();
});
});
+172
View File
@@ -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,
);
}
+54
View File
@@ -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 `<CustomMetricDetail>`, 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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<div className="space-y-6">
<BackLink
href="/measurements"
label={t("customMetrics.backToMeasurements")}
/>
<CustomMetricDetail customMetricId={id} />
</div>
);
}
@@ -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<CustomMetricDto>(`/api/custom-metrics/${id}`),
enabled: isAuthenticated && id.length > 0,
});
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
const metricName = metric?.name ?? "";
return (
<SubPageShell
title={t("customMetrics.detail.valuesTitle", { metric: metricName })}
description={t("customMetrics.detail.valuesDescription")}
backLink={
<BackLink
href={`/custom-metrics/${id}`}
label={t("customMetrics.detail.backToMetric", { metric: metricName })}
dataSlot="custom-metric-values-back"
/>
}
>
<CustomMetricValuesList customMetricId={id} />
</SubPageShell>
);
}
+3
View File
@@ -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}
/>
<CustomMetricList />
</div>
);
}
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<CustomMetricDetail customMetricId={METRIC_ID} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<CustomMetricDetail>", () => {
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"');
});
});
@@ -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<RangeKey>("365");
const [nowMs] = useState(() => Date.now());
const points = useMemo<ChartPoint[]>(() => {
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 (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`customMetrics.chart.range.${r.key}`)}
</Button>
))}
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("customMetrics.chart.empty")}
</p>
) : (
<div>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
/>
{targetLow != null && targetHigh != null ? (
<ReferenceArea
y1={targetLow}
y2={targetHigh}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{targetLow != null ? (
<ReferenceLine
y={targetLow}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{targetHigh != null ? (
<ReferenceLine
y={targetHigh}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("customMetrics.chart.valueLabel"),
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
color: primary,
},
];
return (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
rows={rows}
/>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -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: () => <ChartSkeleton /> },
);
function CustomMetricChart(
props: ComponentProps<typeof CustomMetricChartLazy>,
) {
return (
<ChartErrorBoundary>
<CustomMetricChartLazy {...props} />
</ChartErrorBoundary>
);
}
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<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const {
data: metric,
isError: metricError,
refetch: refetchMetric,
} = useQuery({
queryKey: queryKeys.customMetricDetail(customMetricId),
queryFn: () =>
apiGet<CustomMetricDto>(`/api/custom-metrics/${customMetricId}`),
});
const {
data: list,
isLoading,
isError: listError,
refetch: refetchList,
} = useInfiniteQuery({
queryKey: queryKeys.customMetricEntries({
customMetricId,
sortDir: "desc",
}),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
apiGet<CustomMetricEntryListResponse>(
`/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<DataSummary | null>(
() =>
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 (
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? t("customMetrics.detail.title")}
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-destructive text-sm">
{t("customMetrics.loadError")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
if (metricError) void refetchMetric();
if (listError) void refetchList();
}}
>
{t("common.retry")}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{metric?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{metric.unit}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
onConfirm={() => 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"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!metric}
aria-label={t("customMetrics.edit")}
title={t("customMetrics.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{entries.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="custom-metric-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/custom-metrics/${customMetricId}/values`}
aria-label={t("customMetrics.showAllValues")}
title={t("customMetrics.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("customMetrics.addValue")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">
{t("customMetrics.addValue")}
</span>
</Button>
</div>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
{isLoading ? (
<Card>
<CardContent className="pt-6">
<Skeleton className="h-60 w-full" />
</CardContent>
</Card>
) : entries.length === 0 ? (
<Card>
<CardContent className="pt-6">
<EmptyState
icon={<Gauge className="size-6" />}
title={t("customMetrics.detail.emptyTitle")}
description={t("customMetrics.detail.emptyDescription")}
action={
<Button onClick={() => setAddOpen(true)}>
{t("customMetrics.addValue")}
</Button>
}
/>
</CardContent>
</Card>
) : (
<>
<MetricStatStrip
summary={summary}
unit={metric?.unit ?? ""}
seriesLabel={metric?.name}
icon={Gauge}
/>
<Card>
<CardContent className="pt-6">
<CustomMetricChart
entries={entries}
unit={metric?.unit ?? ""}
targetLow={metric?.targetLow ?? null}
targetHigh={metric?.targetHigh ?? null}
decimals={metric?.decimals ?? null}
/>
</CardContent>
</Card>
</>
)}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("customMetrics.addValue")}
description={t("customMetrics.entry.addDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
}
>
{metric ? (
<CustomMetricEntryForm
customMetricId={customMetricId}
unit={metric.unit}
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
) : null}
</ResponsiveSheet>
<ResponsiveSheet
open={editOpen}
onOpenChange={setEditOpen}
title={t("customMetrics.editTitle")}
description={t("customMetrics.defineDescription")}
footer={
<div
ref={setEditFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
{metric ? (
<CustomMetricForm
existing={metric}
footerSlot={editFooterEl}
onSuccess={afterEditMetric}
onCancel={() => setEditOpen(false)}
/>
) : null}
</ResponsiveSheet>
</div>
);
}
@@ -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<string | null>(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<CustomMetricEntryDto>(
`/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 ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</>
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="custom-metric-value">
{t("customMetrics.entry.value")}
{unit ? ` (${unit})` : ""}
</Label>
<Input
id="custom-metric-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-measuredAt">
{t("customMetrics.entry.measuredAt")}
</Label>
<DateTimeField
id="custom-metric-measuredAt"
value={measuredAt}
onChange={setMeasuredAt}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-note">
{t("customMetrics.entry.note")}
</Label>
<Textarea
id="custom-metric-note"
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
/>
</div>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { CustomMetricDto } from "./types";
const DESCRIPTION_MAX_LENGTH = 2000;
/** Parse a free-text decimal that may use a comma separator. */
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;
}
/** Parse a free-text non-negative integer (display decimals). */
function parseInteger(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isInteger(n) && n >= 0 ? n : null;
}
interface CustomMetricFormProps {
/** When set the form edits this metric; otherwise it defines a new one. */
existing?: CustomMetricDto;
onSuccess?: (saved: CustomMetricDto) => void;
onCancel?: () => void;
/**
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
* footer slot element here; the Cancel / Save row portals into it so the
* bottom-sheet branch can sticky-pin it.
*/
footerSlot?: HTMLElement | null;
}
/**
* v1.25.5 — define or edit a custom metric.
*
* A metric is the feature's reusable definition: free-text name + unit, an
* optional target window (set ONCE), optional display decimals, and an optional
* description. Mirrors the biomarker form; numeric-only, no qualitative path.
*/
export function CustomMetricForm({
existing,
onSuccess,
onCancel,
footerSlot,
}: CustomMetricFormProps) {
const { t } = useTranslations();
const formId = useId();
const [name, setName] = useState(existing?.name ?? "");
const [unit, setUnit] = useState(existing?.unit ?? "");
const [low, setLow] = useState(
existing?.targetLow != null ? String(existing.targetLow) : "",
);
const [high, setHigh] = useState(
existing?.targetHigh != null ? String(existing.targetHigh) : "",
);
const [decimals, setDecimals] = useState(
existing?.decimals != null ? String(existing.decimals) : "",
);
const [description, setDescription] = useState(existing?.description ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || !unit.trim()) {
setError(t("customMetrics.form.requiredError"));
return;
}
const lowVal = parseDecimal(low);
const highVal = parseDecimal(high);
if (lowVal !== null && highVal !== null && lowVal > highVal) {
setError(t("customMetrics.form.rangeOrderError"));
return;
}
const decimalsVal = parseInteger(decimals);
const body = {
name: name.trim(),
unit: unit.trim(),
...(lowVal !== null ? { targetLow: lowVal } : {}),
...(highVal !== null ? { targetHigh: highVal } : {}),
...(decimalsVal !== null ? { decimals: decimalsVal } : {}),
...(description.trim() ? { description: description.trim() } : {}),
};
setSubmitting(true);
try {
const saved = existing
? await apiPatch<CustomMetricDto>(
`/api/custom-metrics/${existing.id}`,
body,
)
: await apiPost<CustomMetricDto>("/api/custom-metrics", body);
toast.success(
existing
? t("customMetrics.form.updatedToast")
: t("customMetrics.form.savedToast"),
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("customMetrics.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("customMetrics.form.save")}
</Button>
</>
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="custom-metric-name">
{t("customMetrics.form.name")}
</Label>
<Input
id="custom-metric-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("customMetrics.form.namePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-unit">
{t("customMetrics.form.unit")}
</Label>
<Input
id="custom-metric-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("customMetrics.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-decimals">
{t("customMetrics.form.decimals")}
</Label>
<Input
id="custom-metric-decimals"
inputMode="numeric"
value={decimals}
onChange={(e) => setDecimals(e.target.value)}
placeholder={t("customMetrics.form.decimalsPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-low">
{t("customMetrics.form.targetLow")}
</Label>
<Input
id="custom-metric-low"
inputMode="decimal"
value={low}
onChange={(e) => setLow(e.target.value)}
placeholder={t("customMetrics.form.targetLowPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-high">
{t("customMetrics.form.targetHigh")}
</Label>
<Input
id="custom-metric-high"
inputMode="decimal"
value={high}
onChange={(e) => setHigh(e.target.value)}
placeholder={t("customMetrics.form.targetHighPlaceholder")}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="custom-metric-description">
{t("customMetrics.form.description")}
</Label>
<Textarea
id="custom-metric-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("customMetrics.form.descriptionPlaceholder")}
maxLength={DESCRIPTION_MAX_LENGTH}
rows={2}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("customMetrics.form.targetHint")}
</p>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
@@ -0,0 +1,333 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton, SortableHead } from "@/components/data-list";
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 { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { formatMetricValue } from "./format-value";
import type { CustomMetricEntryDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
const DESC_COLUMNS = new Set(["measuredAt"]);
function toDateTimeLocal(iso: string): string {
const d = new Date(iso);
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 16);
}
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;
}
/**
* v1.25.5 — reverse-chronological value history for one custom metric, with
* full edit + delete. An edit overwrites in place; delete hard-deletes (the
* value store has no soft-delete tier).
*/
export function CustomMetricHistoryList({
customMetricId,
entries,
unit,
decimals,
}: {
customMetricId: string;
entries: CustomMetricEntryDto[];
unit: string;
decimals: number | null;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { sortBy, sortDir, toggleSort } = useTableSort({
defaultColumn: "measuredAt",
defaultDir: "desc",
descColumns: DESC_COLUMNS,
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editMeasuredAt, setEditMeasuredAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editError, setEditError] = useState<string | null>(null);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() });
queryClient.invalidateQueries({
queryKey: queryKeys.customMetricDetail(customMetricId),
});
queryClient.invalidateQueries({
queryKey: ["custom-metric-entries", customMetricId],
});
}
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/custom-metrics/${customMetricId}/entries/${id}`),
onSuccess: () => {
invalidate();
toast.success(t("customMetrics.entry.deletedToast"));
},
onError: () => toast.error(t("customMetrics.entry.deleteError")),
});
const updateMutation = useMutation({
mutationFn: (body: {
id: string;
value: number;
measuredAt: string;
note: string | null;
}) =>
apiPatch<CustomMetricEntryDto>(
`/api/custom-metrics/${customMetricId}/entries/${body.id}`,
{
value: body.value,
measuredAt: body.measuredAt,
note: body.note,
},
),
onSuccess: () => {
invalidate();
toast.success(t("customMetrics.entry.editedToast"));
closeEdit();
},
onError: (err) => {
setEditError(
err instanceof ApiError
? err.message
: t("customMetrics.entry.editError"),
);
},
});
function openEdit(entry: CustomMetricEntryDto) {
setEditingId(entry.id);
setEditValue(String(entry.value));
setEditMeasuredAt(toDateTimeLocal(entry.measuredAt));
setEditNote(entry.note ?? "");
setEditError(null);
}
function closeEdit() {
if (updateMutation.isPending) return;
setEditingId(null);
setEditError(null);
}
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
const numeric = parseDecimal(editValue);
if (numeric === null) {
setEditError(t("customMetrics.entry.valueError"));
return;
}
const measuredDate = new Date(editMeasuredAt);
if (Number.isNaN(measuredDate.getTime())) {
setEditError(t("customMetrics.entry.timestampError"));
return;
}
updateMutation.mutate({
id: editingId,
value: numeric,
measuredAt: measuredDate.toISOString(),
note: editNote.trim() ? editNote.trim() : null,
});
}
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...entries].sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case "value":
cmp = a.value - b.value;
break;
case "note":
cmp = Number(Boolean(a.note)) - Number(Boolean(b.note));
break;
case "measuredAt":
default:
cmp =
new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime();
break;
}
if (cmp === 0) {
cmp =
new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime();
}
return cmp * dir;
});
}, [entries, sortBy, sortDir]);
return (
<>
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("customMetrics.entry.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="measuredAt"
label={t("customMetrics.entry.measuredAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("customMetrics.entry.note")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("customMetrics.entry.edit")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
{formatMetricValue(r.value, decimals)}
{unit ? ` ${unit}` : ""}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.measuredAt, true)}
</TableCell>
<TableCell className="text-muted-foreground max-w-[12rem] truncate text-sm">
{r.note ?? ""}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="size-11 sm:size-9"
onClick={() => openEdit(r)}
aria-label={t("customMetrics.entry.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("customMetrics.entry.deleteConfirmTitle")}
description={t(
"customMetrics.entry.deleteConfirmDescription",
)}
confirmLabel={t("customMetrics.entry.delete")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<ResponsiveSheet
open={editingId !== null}
onOpenChange={(open) => {
if (!open) closeEdit();
}}
title={t("customMetrics.entry.editTitle")}
description={t("customMetrics.entry.editDescription")}
>
<form onSubmit={submitEdit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="custom-metric-edit-value">
{t("customMetrics.entry.value")}
{unit ? ` (${unit})` : ""}
</Label>
<Input
id="custom-metric-edit-value"
inputMode="decimal"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-edit-measuredAt">
{t("customMetrics.entry.measuredAt")}
</Label>
<DateTimeField
id="custom-metric-edit-measuredAt"
value={editMeasuredAt}
onChange={setEditMeasuredAt}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-metric-edit-note">
{t("customMetrics.entry.note")}
</Label>
<Textarea
id="custom-metric-edit-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
/>
</div>
{editError ? (
<p className="text-destructive text-sm" role="alert">
{editError}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeEdit}
disabled={updateMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</form>
</ResponsiveSheet>
</>
);
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Gauge, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CustomMetricForm } from "./custom-metric-form";
import { formatMetricValue } from "./format-value";
import type { CustomMetricDto, CustomMetricListResponse } from "./types";
/**
* v1.25.5 — inline list of the user's custom metrics, mounted ON the
* Measurements surface beneath the built-in measurement list (NOT a separate
* module or nav entry). Each row shows the latest logged value and links to the
* metric's detail + chart page; an "+ add custom metric" affordance opens the
* define-metric sheet.
*/
export function CustomMetricList() {
const { t } = useTranslations();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.customMetrics(),
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
});
const metrics: CustomMetricDto[] = data?.customMetrics ?? [];
function afterAdd(saved: CustomMetricDto) {
setAddOpen(false);
// Land the user on the new metric's detail page so the next step (logging
// a value) is one tap away.
if (typeof window !== "undefined") {
window.location.assign(`/custom-metrics/${saved.id}`);
}
}
return (
<section className="space-y-3" data-slot="custom-metric-list">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight">
{t("customMetrics.sectionTitle")}
</h2>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("customMetrics.sectionSubtitle")}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setAddOpen(true)}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
</Button>
</div>
{isLoading ? (
<Card aria-hidden="true">
<CardContent className="space-y-2 py-4">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</CardContent>
</Card>
) : isError ? (
<p role="alert" className="text-destructive py-4 text-center text-sm">
{t("customMetrics.loadError")}
</p>
) : metrics.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
<Gauge className="text-muted-foreground size-6" />
<p className="text-muted-foreground text-sm">
{t("customMetrics.emptyDescription")}
</p>
<Button size="sm" onClick={() => setAddOpen(true)}>
{t("customMetrics.add")}
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{metrics.map((metric) => (
<Link
key={metric.id}
href={`/custom-metrics/${metric.id}`}
className="hover:bg-muted/40 flex items-center justify-between gap-3 px-4 py-2.5 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{metric.name}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
{metric.latest ? (
<>
<span className="text-foreground font-semibold tabular-nums">
{formatMetricValue(
metric.latest.value,
metric.decimals,
)}
{metric.unit ? ` ${metric.unit}` : ""}
</span>
<span>{formatDate(metric.latest.measuredAt)}</span>
</>
) : (
<span>{t("customMetrics.noValuesYet")}</span>
)}
</div>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
))}
</CardContent>
</Card>
)}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("customMetrics.defineTitle")}
description={t("customMetrics.defineDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
}
>
<CustomMetricForm
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
</section>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useMemo } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CustomMetricHistoryList } from "./custom-metric-history-list";
import type {
CustomMetricDto,
CustomMetricEntryDto,
CustomMetricEntryListResponse,
} from "./types";
const ENTRIES_PAGE_SIZE = 200;
/**
* v1.25.5 — the full value history for one custom metric, on its own
* `/custom-metrics/[id]/values` route (mirroring the labs values sub-page).
* Offset-paginated infinite query with "Load more".
*/
export function CustomMetricValuesList({
customMetricId,
}: {
customMetricId: string;
}) {
const { t } = useTranslations();
const { data: metric } = useQuery({
queryKey: queryKeys.customMetricDetail(customMetricId),
queryFn: () =>
apiGet<CustomMetricDto>(`/api/custom-metrics/${customMetricId}`),
});
const {
data: list,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: queryKeys.customMetricEntries({
customMetricId,
sortDir: "desc",
}),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
apiGet<CustomMetricEntryListResponse>(
`/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 total = list?.pages[0]?.meta.total ?? 0;
if (isError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("customMetrics.loadError")}
</p>
);
}
if (isLoading) {
return <Skeleton className="h-60 w-full" />;
}
if (entries.length === 0) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{t("customMetrics.detail.emptyTitle")}
</p>
);
}
return (
<div className="space-y-2">
{hasNextPage ? (
<p className="text-muted-foreground text-xs">
{t("customMetrics.showingLatestOf", {
shown: entries.length,
total,
})}
</p>
) : null}
<Card>
<CardContent className="py-0">
<CustomMetricHistoryList
customMetricId={customMetricId}
entries={entries}
unit={metric?.unit ?? ""}
decimals={metric?.decimals ?? null}
/>
</CardContent>
</Card>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("customMetrics.loadMore")}
</Button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,17 @@
/**
* v1.25.5 — format a custom-metric value for display.
*
* When the metric defines a fixed number of decimals, honour it; otherwise fall
* back to a compact representation that trims trailing zeros (up to 3 places).
*/
export function formatMetricValue(
value: number,
decimals: number | null | undefined,
): string {
if (!Number.isFinite(value)) return "—";
if (decimals != null) {
return value.toFixed(decimals);
}
// Compact default: round to at most 3 places and drop trailing zeros.
return String(Math.round(value * 1000) / 1000);
}
+42
View File
@@ -0,0 +1,42 @@
/** The latest logged value carried on a custom-metric list row. */
export interface CustomMetricLatest {
value: number;
unit: string;
measuredAt: string;
}
/** A user-defined custom metric as the API serialises it. */
export interface CustomMetricDto {
id: string;
name: string;
unit: string;
targetLow: number | null;
targetHigh: number | null;
decimals: number | null;
description: string | null;
/** Present on the list + create reads; absent on the bare detail read. */
latest?: CustomMetricLatest | null;
entryCount?: number;
createdAt: string;
updatedAt: string;
}
export interface CustomMetricListResponse {
customMetrics: CustomMetricDto[];
}
/** A single logged custom-metric value as the API serialises it. */
export interface CustomMetricEntryDto {
id: string;
customMetricId: string;
value: number;
unit: string;
measuredAt: string;
note: string | null;
createdAt: string;
}
export interface CustomMetricEntryListResponse {
entries: CustomMetricEntryDto[];
meta: { total: number; limit: number; offset: number };
}
@@ -248,17 +248,27 @@ describe("<SettingsShell>", () => {
);
});
it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => {
// Fail-open default: coach undefined → entry shown.
it("surfaces the Coach entry on the fail-open default (v1.18.0 S5)", () => {
// coach undefined → entry shown.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/coach"',
);
// Explicitly disabled → entry hidden.
const disabled = renderShell({
active: "account",
modules: { coach: false },
});
expect(disabled).not.toContain('href="/settings/coach"');
});
it("renders the full fail-open nav on the server pass, even for a disabled module (v1.25.9)", () => {
// v1.25.9 — the module filter must NOT run during SSR / the first client
// paint. `useAuth().user.modules` resolves at different times on the
// server-rendered pass and the hydrating client pass, so filtering in the
// SSR markup produced a different nav `<li>` count than the client's first
// render — a React #418 hydration mismatch that regenerated the whole tree
// and left every settings control non-interactive (dead disclosure /
// download buttons). The shell now emits the full fail-open list on both
// the server pass and the first client paint, and applies the module
// filter once, after mount. So a disabled module's entry is STILL present
// in the SSR markup here; its client-side removal after hydration is
// covered by the e2e suite (settings pages must stay interactive).
const ssr = renderShell({ active: "account", modules: { coach: false } });
expect(ssr).toContain('href="/settings/coach"');
});
it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => {
+30 -1
View File
@@ -294,6 +294,10 @@ function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug {
return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug;
}
// Stable no-op subscribe for the hydration probe below — the value never
// changes after the first commit, so there is nothing to subscribe to.
const subscribeNoop = () => () => {};
function deriveActiveSlug(
pathname: string | null,
override?: SettingsSectionSlug,
@@ -324,9 +328,34 @@ export function SettingsShell({
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
// reads as enabled so an entry never silently disappears. Entries with
// no `moduleGate` (global / CORE) are always shown.
//
// v1.25.9 — the module filter must NOT run during SSR / the first client
// paint. `useAuth().user` resolves from the `/api/auth/me` query, which is
// populated on the server-rendered pass and the hydrating client pass at
// DIFFERENT times (a cache race): the server may render the full list while
// the client hydrates with `modules` already resolved (or vice versa),
// producing a different number of nav `<li>`s and a React #418 hydration
// mismatch. On a mismatch React discards and regenerates the whole tree,
// which dropped the event handlers on every settings page (dead toggles /
// download buttons). Gate the filter on a post-mount flag so SSR and the
// first client render ALWAYS emit the same fail-open list; the real filter
// applies once, after hydration, as an ordinary client update.
// `useSyncExternalStore` is the SSR-safe hydration probe: it returns the
// server snapshot (`false`) on the server render AND the first client paint,
// then the client snapshot (`true`) after hydration commits. No setState in
// an effect (which the lint rule flags as a cascading render), and no risk
// of the two passes disagreeing.
const hydrated = React.useSyncExternalStore(
subscribeNoop,
() => true,
() => false,
);
const modules = user?.modules;
const visibleSections = SETTINGS_SECTIONS.filter(
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
(section) =>
!hydrated ||
!section.moduleGate ||
modules?.[section.moduleGate] !== false,
);
const activeSection = visibleSections.find(
(section) => section.slug === activeSlug,
@@ -0,0 +1,83 @@
/**
* v1.25.5 — server-side serialisers for the user-defined custom-metric store.
*
* Custom metrics are PLAINTEXT (name / unit / description), so — unlike the
* Biomarker catalog with its AES-256-GCM `contextEncrypted` codec — there is no
* encrypt/decrypt layer here. These helpers just map a Prisma row into the
* stable wire DTO the web + iOS clients render.
*
* The store is deliberately ISOLATED from the closed `MeasurementType` system:
* no rollup, no sync, no FHIR, no insights. Charts read entries LIVE.
*/
/** A custom-metric catalog row as the API serialises it. */
export interface CustomMetricRow {
id: string;
name: string;
unit: string;
targetLow: number | null;
targetHigh: number | null;
decimals: number | null;
description: string | null;
createdAt: Date;
updatedAt: Date;
}
/** A custom-metric catalog row plus its latest logged value (list read). */
export interface CustomMetricRowWithLatest extends CustomMetricRow {
latest: { value: number; unit: string; measuredAt: Date } | null;
entryCount: number;
}
/** A logged custom-metric value as the API serialises it. */
export interface CustomMetricEntryRow {
id: string;
customMetricId: string;
value: number;
unit: string;
measuredAt: Date;
note: string | null;
createdAt: Date;
}
export function serialiseCustomMetric(row: CustomMetricRow) {
return {
id: row.id,
name: row.name,
unit: row.unit,
targetLow: row.targetLow,
targetHigh: row.targetHigh,
decimals: row.decimals,
description: row.description,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function serialiseCustomMetricWithLatest(
row: CustomMetricRowWithLatest,
) {
return {
...serialiseCustomMetric(row),
latest: row.latest
? {
value: row.latest.value,
unit: row.latest.unit,
measuredAt: row.latest.measuredAt.toISOString(),
}
: null,
entryCount: row.entryCount,
};
}
export function serialiseCustomMetricEntry(row: CustomMetricEntryRow) {
return {
id: row.id,
customMetricId: row.customMetricId,
value: row.value,
unit: row.unit,
measuredAt: row.measuredAt.toISOString(),
note: row.note,
createdAt: row.createdAt.toISOString(),
};
}
+355
View File
@@ -0,0 +1,355 @@
/**
* OpenAPI route table for the user-defined custom-metric store
* (`/api/custom-metrics`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies reuse the runtime Zod schemas from `@/lib/validations/custom-metrics`
* so the wire contract stays single-source. The response shapes are declared
* here.
*
* Custom metrics are a SEPARATE generic store from the closed measurement
* system: not synced, not in FHIR, not in AI insights — log + chart only. All
* fields are plaintext.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createCustomMetricEntrySchema,
createCustomMetricSchema,
updateCustomMetricEntrySchema,
updateCustomMetricSchema,
} from "@/lib/validations/custom-metrics";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
createCustomMetricSchema.meta({
id: "CreateCustomMetricRequest",
description:
"Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, and an optional `description`. The name is unique per user. Logging a value later just picks this metric — its unit is snapshotted onto the value at write time. Isolated from the closed measurement system: not synced, not in FHIR, not in insights.",
});
updateCustomMetricSchema.meta({
id: "UpdateCustomMetricRequest",
description:
"Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. A rename that collides with another of the caller's live metrics is rejected 409.",
});
createCustomMetricEntrySchema.meta({
id: "CreateCustomMetricEntryRequest",
description:
"Log a value against a custom metric: numeric `value`, ISO 8601 `measuredAt`, optional free-text `note`. The metric's current unit is snapshotted onto the entry server-side.",
});
updateCustomMetricEntrySchema.meta({
id: "UpdateCustomMetricEntryRequest",
description:
"Partial edit of a logged value. An omitted key leaves the column untouched; an explicit `null` on `note` clears it.",
});
const latestValue = z
.object({
value: z.number(),
unit: z.string(),
measuredAt: z.string(),
})
.nullable();
const customMetricRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
targetLow: z.number().nullable(),
targetHigh: z.number().nullable(),
decimals: z.number().nullable(),
description: z.string().nullable(),
latest: latestValue,
entryCount: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "CustomMetric",
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.",
});
const entryRow = z
.object({
id: z.string(),
customMetricId: z.string(),
value: z.number(),
unit: z.string(),
measuredAt: z.string(),
note: z.string().nullable(),
createdAt: z.string(),
})
.meta({
id: "CustomMetricEntry",
description:
"A single logged value for a custom metric. `unit` is a snapshot of the metric's unit at write time.",
});
const listResponse = z
.object({ customMetrics: z.array(customMetricRow) })
.meta({ id: "ListCustomMetricsResponse" });
const entriesResponse = z
.object({
entries: z.array(entryRow),
meta: z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
})
.meta({ id: "ListCustomMetricEntriesResponse" });
const conflict = {
"409": {
description:
"A custom metric with this name already exists for the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description: "Custom metric not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
const entryNotFound = {
"404": {
description: "Custom metric entry not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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: dataEnvelope(listResponse, "ListCustomMetricsEnvelope"),
},
},
},
...stdResponses,
},
},
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: createCustomMetricSchema } },
},
responses: {
"201": {
description: "Created custom metric.",
content: {
"application/json": {
schema: dataEnvelope(
customMetricRow,
"CreateCustomMetricResponse",
),
},
},
},
...conflict,
...stdResponses,
},
},
},
"/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.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Custom-metric detail.",
content: {
"application/json": {
schema: dataEnvelope(customMetricRow, "GetCustomMetricResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
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`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomMetricSchema } },
},
responses: {
"200": {
description: "Updated custom metric.",
content: {
"application/json": {
schema: dataEnvelope(
customMetricRow,
"UpdateCustomMetricResponse",
),
},
},
},
...conflict,
...notFound,
...stdResponses,
},
},
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`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteCustomMetricResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
"/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.",
requestParams: {
path: z.object({ id: z.string() }),
query: z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: z.coerce.number().int().min(0).optional(),
sortDir: z.enum(["asc", "desc"]).optional(),
}),
},
responses: {
"200": {
description: "Logged values.",
content: {
"application/json": {
schema: dataEnvelope(
entriesResponse,
"ListCustomMetricEntriesEnvelope",
),
},
},
},
...notFound,
...stdResponses,
},
},
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`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: createCustomMetricEntrySchema },
},
},
responses: {
"201": {
description: "Created value.",
content: {
"application/json": {
schema: dataEnvelope(entryRow, "CreateCustomMetricEntryResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
},
"/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`.",
requestParams: {
path: z.object({ id: z.string(), entryId: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": { schema: updateCustomMetricEntrySchema },
},
},
responses: {
"200": {
description: "Updated value.",
content: {
"application/json": {
schema: dataEnvelope(entryRow, "UpdateCustomMetricEntryResponse"),
},
},
},
...entryNotFound,
...stdResponses,
},
},
delete: {
tags: ["Custom metrics"],
summary: "Delete a logged value",
description:
"Hard-deletes the value. Audits as `customMetricEntry.delete`.",
requestParams: {
path: z.object({ id: z.string(), entryId: z.string() }),
},
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteCustomMetricEntryResponse",
),
},
},
},
...entryNotFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -32,6 +32,7 @@ import {
coachSuggestedActionPaths,
} from "./coach";
import { consentPaths } from "./consent";
import { customMetricPaths } from "./custom-metrics";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
@@ -91,6 +92,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...allergyPaths,
...familyHistoryPaths,
...inboundDocumentPaths,
...customMetricPaths,
...onboardingPaths,
};
+19
View File
@@ -0,0 +1,19 @@
/**
* v1.25.5 — TanStack Query keys for the user-defined custom-metric store.
*
* `customMetricEntries` bakes the metric id + sort direction into the key so an
* infinite-query accumulation never collides with another metric's feed (the
* project's queryKey-factory invariant). Mutations invalidate `customMetrics()`
* (the catalog list) and the per-metric detail / entry keys.
*/
export const customMetrics = {
/** The caller's custom-metric catalog list (with latest value). */
customMetrics: () => ["custom-metrics"] as const,
/** A single custom-metric detail. */
customMetricDetail: (id: string) => ["custom-metrics", "detail", id] as const,
/** Paginated (offset) value feed for one custom metric. */
customMetricEntries: (params: { customMetricId: string; sortDir: string }) =>
["custom-metric-entries", params.customMetricId, params.sortDir] as const,
};
+2
View File
@@ -19,6 +19,7 @@ import { allergyKeys } from "./allergies";
import { authKeys } from "./auth";
import { coachKeys } from "./coach";
import { familyHistoryKeys } from "./family-history";
import { customMetrics } from "./custom-metrics";
import { cycleKeys } from "./cycle";
import { dashboardKeys } from "./dashboard";
import { environmentKeys } from "./environment";
@@ -56,6 +57,7 @@ export const queryKeys = {
...familyHistoryKeys,
...environmentKeys,
...documentKeys,
...customMetrics,
};
/**
+146
View File
@@ -0,0 +1,146 @@
/**
* v1.25.5 — user-defined custom-metric validation.
*
* A `CustomMetric` defines an arbitrary measurement ONCE: free-text name + unit,
* an optional target window, optional display decimals, and an optional
* description. Logging a value later just picks the metric — the unit is
* snapshotted onto the entry server-side at write time.
*
* Deliberately a SEPARATE generic store from the closed `MeasurementType`
* system (mirrors the Biomarker catalog ↔ LabResult split): not synced, not in
* FHIR, not in AI insights — log + chart only. All fields are plaintext.
*
* The optional target window is the user's own "good range" and is charted as a
* reference band; both bounds are independently optional and, when both are
* present, the low bound must not exceed the high bound.
*/
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/** Trimmed, bounded free-text field with a min-length guard. */
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const targetLow = z.number().finite().optional();
const targetHigh = z.number().finite().optional();
/** Optional display decimals, capped at a sane precision. */
const decimals = z.number().int().min(0).max(6).optional();
const optionalDescription = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only description normalises to "none" so a blank
// field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
export const createCustomMetricSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
targetLow,
targetHigh,
decimals,
description: optionalDescription,
})
.refine(
(d) =>
d.targetLow === undefined ||
d.targetHigh === undefined ||
d.targetLow <= d.targetHigh,
{
message: "targetLow must not exceed targetHigh",
path: ["targetLow"],
},
);
export type CreateCustomMetricInput = z.infer<typeof createCustomMetricSchema>;
/**
* Update schema: every field optional (partial edit). `targetLow` /
* `targetHigh` / `decimals` / `description` accept an explicit `null` to clear
* the stored value — distinct from an omitted key, which leaves it untouched.
*/
export const updateCustomMetricSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
targetLow: targetLow.or(z.null()),
targetHigh: targetHigh.or(z.null()),
decimals: decimals.or(z.null()),
description: optionalDescription.or(z.null()),
})
.refine(
(d) =>
d.targetLow === undefined ||
d.targetLow === null ||
d.targetHigh === undefined ||
d.targetHigh === null ||
d.targetLow <= d.targetHigh,
{
message: "targetLow must not exceed targetHigh",
path: ["targetLow"],
},
);
export type UpdateCustomMetricInput = z.infer<typeof updateCustomMetricSchema>;
/**
* Far-past floor for a custom-metric entry. A self-hoster legitimately
* backdates an old log, so the window is generous (50 years) but still rejects
* epoch-zero / corrupted instants via the shared 1900 floor.
*/
const ENTRY_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared `measuredAt` field: ISO string → Date, plausibility-bounded. */
const measuredAtField = validateEntryInstant(
z
.string()
.datetime({ message: "measuredAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
{
maxAgeMs: ENTRY_MAX_AGE_MS,
pastMessage: "Measurement date is too far in the past",
},
);
const optionalNote = z
.string()
.trim()
.max(2000)
.optional()
.or(z.literal("").transform(() => undefined));
export const createCustomMetricEntrySchema = z.object({
value: z.number().finite(),
measuredAt: measuredAtField,
note: optionalNote,
});
export type CreateCustomMetricEntryInput = z.infer<
typeof createCustomMetricEntrySchema
>;
/** Update an entry: value / measuredAt / note partial edit. */
export const updateCustomMetricEntrySchema = z.object({
value: z.number().finite().optional(),
measuredAt: measuredAtField.optional(),
note: optionalNote.or(z.null()),
});
export type UpdateCustomMetricEntryInput = z.infer<
typeof updateCustomMetricEntrySchema
>;
/** List query for the entry feed: offset pagination + sort direction. */
export const listCustomMetricEntriesSchema = z.object({
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
sortDir: z.enum(["asc", "desc"]).default("desc"),
});
export type ListCustomMetricEntriesInput = z.infer<
typeof listCustomMetricEntriesSchema
>;