feat(measurements): add custom metrics (define, log, chart)

Add a user-defined custom-metric store alongside the built-in measurements
on the Measurements surface. A user defines an arbitrary metric (name, unit,
optional target window, decimals, description), logs values, and charts them.

The store is a separate generic catalog + values pair, isolated from the
closed measurement-type system: not synced, not in FHIR, not in insights —
log and chart only. Names and units are plaintext, mirroring the biomarker
catalog. The inline list lives beneath the measurement list (no new nav
entry); tapping a metric opens a detail page with the trend chart, target
band, stat strip, and an editable value history.

Migration 0224 adds custom_metrics and custom_metric_entries.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 08:54:39 +02:00
1 parent 150b827d01
commit b3e6ba03d7
35 files changed
+4956

No files matched your search

+637
View File
@@ -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}"
}
}
}
@@ -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 {
@@ -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 };
}
@@ -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
>;