mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications,coach,insights): record the dose you actually took
An intake can carry the dose that was actually taken — editable in the quick-add and log dialogs, shown as a quiet deviation line in the history. The measurements and mood lists refresh by pull on touch, the insight sub-pages gain the same customize entry as the overview, and an answered coach follow-up offers to adopt the answer into the matching self-context field.
This commit is contained in:
1 parent
ce3b4c70c8
commit
7e7e17c68c
29 files changed
+886
-744
No files matched your search
@@ -4709,6 +4709,13 @@ components:
|
||||
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|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
doseTaken:
|
||||
description: Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured
|
||||
dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under
|
||||
the medication's configured dose.
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 50
|
||||
required:
|
||||
- medicationId
|
||||
description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "Konnte nicht geladen werden",
|
||||
"error": "Fehler",
|
||||
"saved": "Gespeichert",
|
||||
"pullToRefresh": "Wird aktualisiert …",
|
||||
"no": "Nein",
|
||||
"noData": "Keine Daten vorhanden",
|
||||
"optional": "Optional",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Medikament auswählen",
|
||||
"doseLabel": "Dosis",
|
||||
"dosePlaceholder": "z. B. 5 mg",
|
||||
"doseHint": "Aktuell wird die Standarddosis erfasst. Abweichende Dosen kommen in einer kommenden Version.",
|
||||
"doseHint": "Vorbelegt mit der Standarddosis. Passe sie an, um eine abweichende Dosis nur für diese Einnahme zu erfassen.",
|
||||
"timeLabel": "Zeitpunkt",
|
||||
"saveError": "Einnahme konnte nicht gespeichert werden.",
|
||||
"emptyTitle": "Noch keine Medikamente angelegt",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Medikament",
|
||||
"slotLabel": "Einnahmeslot",
|
||||
"slotNone": "Kein bestimmter Slot",
|
||||
"doseLabel": "Dosis",
|
||||
"doseHint": "Vorbelegt mit der Dosis des Medikaments bzw. Slots. Passe sie an, um eine abweichende Dosis nur für diese Einnahme zu erfassen.",
|
||||
"takenAtLabel": "Datum und Uhrzeit",
|
||||
"takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.",
|
||||
"skippedLabel": "Als übersprungen erfassen",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Als übersprungen markieren",
|
||||
"adHocTag": "außerplanmäßig",
|
||||
"takenDetail": "geplant {planned} · genommen {taken}",
|
||||
"doseTaken": "Dosis: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "pünktlich",
|
||||
"taken_late": "verspätet",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
|
||||
"selfContextChipsLabel": "Der Coach würde gern wissen:",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Antwort in deine Selbstauskunft übernehmen?",
|
||||
"confirm": "Übernehmen",
|
||||
"dismiss": "Verwerfen",
|
||||
"done": "In die Selbstauskunft übernommen",
|
||||
"duplicate": "Bereits in der Selbstauskunft enthalten",
|
||||
"failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"selfContextChipDismiss": "Frage verwerfen",
|
||||
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
|
||||
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Error",
|
||||
"saved": "Saved",
|
||||
"pullToRefresh": "Refreshing…",
|
||||
"no": "No",
|
||||
"noData": "No data available",
|
||||
"optional": "Optional",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Select a medication",
|
||||
"doseLabel": "Dose",
|
||||
"dosePlaceholder": "e.g. 5 mg",
|
||||
"doseHint": "Currently the standard dose is recorded. Variable doses are coming in a later version.",
|
||||
"doseHint": "Pre-filled with the standard dose. Edit it to record a deviating dose for this intake only.",
|
||||
"timeLabel": "Time taken",
|
||||
"saveError": "Could not save medication intake.",
|
||||
"emptyTitle": "No medications yet",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Medication",
|
||||
"slotLabel": "Schedule slot",
|
||||
"slotNone": "No specific slot",
|
||||
"doseLabel": "Dose",
|
||||
"doseHint": "Pre-filled with the medication or slot dose. Edit it to record a deviating dose for this intake only.",
|
||||
"takenAtLabel": "Date and time",
|
||||
"takenAtHint": "Defaults to now; pick an earlier date and time to log a past dose.",
|
||||
"skippedLabel": "Logged as skipped",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Mark as skipped",
|
||||
"adHocTag": "off-schedule",
|
||||
"takenDetail": "planned {planned} · taken {taken}",
|
||||
"doseTaken": "Dose: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "on time",
|
||||
"taken_late": "late",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
"nudgeBubbleLabel": "New message from the Coach — open chat",
|
||||
"selfContextChipsLabel": "The Coach would like to know:",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Add this answer to your self-context?",
|
||||
"confirm": "Add",
|
||||
"dismiss": "Dismiss",
|
||||
"done": "Added to your self-context",
|
||||
"duplicate": "Already part of your self-context",
|
||||
"failed": "Could not add. Please try again."
|
||||
},
|
||||
"selfContextChipDismiss": "Dismiss question",
|
||||
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
|
||||
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "No se pudo cargar",
|
||||
"error": "Error",
|
||||
"saved": "Guardado",
|
||||
"pullToRefresh": "Actualizando…",
|
||||
"no": "No",
|
||||
"noData": "Sin datos disponibles",
|
||||
"optional": "Opcional",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Selecciona un medicamento",
|
||||
"doseLabel": "Dosis",
|
||||
"dosePlaceholder": "p. ej. 5 mg",
|
||||
"doseHint": "Por ahora se registra la dosis estándar. Las dosis variables llegarán en una próxima versión.",
|
||||
"doseHint": "Precargado con la dosis estándar. Edítala para registrar una dosis diferente solo para esta toma.",
|
||||
"timeLabel": "Hora de la toma",
|
||||
"saveError": "No se ha podido guardar la toma.",
|
||||
"emptyTitle": "Aún no hay medicamentos",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Medicamento",
|
||||
"slotLabel": "Franja del horario",
|
||||
"slotNone": "Sin franja concreta",
|
||||
"doseLabel": "Dosis",
|
||||
"doseHint": "Precargado con la dosis del medicamento o de la franja. Edítala para registrar una dosis diferente solo para esta toma.",
|
||||
"takenAtLabel": "Fecha y hora",
|
||||
"takenAtHint": "Por defecto es ahora; elige una fecha y hora anteriores para registrar una dosis pasada.",
|
||||
"skippedLabel": "Registrar como omitida",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Marcar como omitida",
|
||||
"adHocTag": "fuera de horario",
|
||||
"takenDetail": "prevista {planned} · tomada {taken}",
|
||||
"doseTaken": "Dosis: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "a tiempo",
|
||||
"taken_late": "con retraso",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Pregúntame sobre tus datos…",
|
||||
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
|
||||
"selfContextChipsLabel": "Al Coach le gustaría saber:",
|
||||
"selfContextAdopt": {
|
||||
"offer": "¿Añadir esta respuesta a tu información personal?",
|
||||
"confirm": "Añadir",
|
||||
"dismiss": "Descartar",
|
||||
"done": "Añadido a tu información personal",
|
||||
"duplicate": "Ya está en tu información personal",
|
||||
"failed": "No se pudo añadir. Inténtalo de nuevo."
|
||||
},
|
||||
"selfContextChipDismiss": "Descartar pregunta",
|
||||
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
|
||||
"threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.",
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "Impossible de charger",
|
||||
"error": "Erreur",
|
||||
"saved": "Enregistré",
|
||||
"pullToRefresh": "Actualisation…",
|
||||
"no": "Non",
|
||||
"noData": "Aucune donnée disponible",
|
||||
"optional": "Facultatif",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Sélectionner un médicament",
|
||||
"doseLabel": "Dose",
|
||||
"dosePlaceholder": "p. ex. 5 mg",
|
||||
"doseHint": "Pour l’instant, la dose standard est enregistrée. Les doses variables arriveront dans une prochaine version.",
|
||||
"doseHint": "Prérempli avec la dose standard. Modifiez-la pour enregistrer une dose différente pour cette prise uniquement.",
|
||||
"timeLabel": "Heure de la prise",
|
||||
"saveError": "Impossible d’enregistrer la prise.",
|
||||
"emptyTitle": "Aucun médicament pour l’instant",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Médicament",
|
||||
"slotLabel": "Créneau du planning",
|
||||
"slotNone": "Aucun créneau précis",
|
||||
"doseLabel": "Dose",
|
||||
"doseHint": "Prérempli avec la dose du médicament ou du créneau. Modifiez-la pour enregistrer une dose différente pour cette prise uniquement.",
|
||||
"takenAtLabel": "Date et heure",
|
||||
"takenAtHint": "Par défaut maintenant ; choisissez une date et une heure antérieures pour enregistrer une dose passée.",
|
||||
"skippedLabel": "Enregistrer comme ignorée",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Marquer comme sautée",
|
||||
"adHocTag": "hors planning",
|
||||
"takenDetail": "prévue {planned} · prise {taken}",
|
||||
"doseTaken": "Dose : {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "à l'heure",
|
||||
"taken_late": "en retard",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Pose-moi une question sur tes données…",
|
||||
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
|
||||
"selfContextChipsLabel": "Le Coach aimerait savoir :",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Ajouter cette réponse à vos informations personnelles ?",
|
||||
"confirm": "Ajouter",
|
||||
"dismiss": "Ignorer",
|
||||
"done": "Ajouté à vos informations personnelles",
|
||||
"duplicate": "Déjà présent dans vos informations personnelles",
|
||||
"failed": "Échec de l’ajout. Veuillez réessayer."
|
||||
},
|
||||
"selfContextChipDismiss": "Ignorer la question",
|
||||
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
|
||||
"threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.",
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "Impossibile caricare",
|
||||
"error": "Errore",
|
||||
"saved": "Salvato",
|
||||
"pullToRefresh": "Aggiornamento…",
|
||||
"no": "No",
|
||||
"noData": "Nessun dato disponibile",
|
||||
"optional": "Opzionale",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Seleziona un farmaco",
|
||||
"doseLabel": "Dose",
|
||||
"dosePlaceholder": "es. 5 mg",
|
||||
"doseHint": "Al momento viene registrata la dose standard. Le dosi variabili arriveranno in una prossima versione.",
|
||||
"doseHint": "Precompilato con la dose standard. Modificala per registrare una dose diversa solo per questa assunzione.",
|
||||
"timeLabel": "Orario di assunzione",
|
||||
"saveError": "Impossibile salvare l’assunzione.",
|
||||
"emptyTitle": "Ancora nessun farmaco",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Farmaco",
|
||||
"slotLabel": "Fascia della pianificazione",
|
||||
"slotNone": "Nessuna fascia specifica",
|
||||
"doseLabel": "Dose",
|
||||
"doseHint": "Precompilato con la dose del farmaco o della fascia. Modificala per registrare una dose diversa solo per questa assunzione.",
|
||||
"takenAtLabel": "Data e ora",
|
||||
"takenAtHint": "Per impostazione predefinita è adesso; scegli una data e un'ora precedenti per registrare una dose passata.",
|
||||
"skippedLabel": "Registra come saltata",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Segna come saltata",
|
||||
"adHocTag": "fuori programma",
|
||||
"takenDetail": "prevista {planned} · assunta {taken}",
|
||||
"doseTaken": "Dose: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "puntuale",
|
||||
"taken_late": "in ritardo",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
|
||||
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
|
||||
"selfContextChipsLabel": "Il Coach vorrebbe sapere:",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Aggiungere questa risposta alle tue informazioni personali?",
|
||||
"confirm": "Aggiungi",
|
||||
"dismiss": "Ignora",
|
||||
"done": "Aggiunto alle tue informazioni personali",
|
||||
"duplicate": "Già presente nelle tue informazioni personali",
|
||||
"failed": "Aggiunta non riuscita. Riprova."
|
||||
},
|
||||
"selfContextChipDismiss": "Ignora la domanda",
|
||||
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
|
||||
"threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.",
|
||||
|
||||
+13
-1
@@ -11,6 +11,7 @@
|
||||
"loadFailed": "Nie udało się załadować",
|
||||
"error": "Błąd",
|
||||
"saved": "Zapisano",
|
||||
"pullToRefresh": "Odświeżanie…",
|
||||
"no": "Nie",
|
||||
"noData": "Brak danych",
|
||||
"optional": "Opcjonalne",
|
||||
@@ -305,7 +306,7 @@
|
||||
"medicationPlaceholder": "Wybierz lek",
|
||||
"doseLabel": "Dawka",
|
||||
"dosePlaceholder": "np. 5 mg",
|
||||
"doseHint": "Obecnie zapisywana jest dawka standardowa. Dawki zmienne pojawią się w jednej z kolejnych wersji.",
|
||||
"doseHint": "Wstępnie wypełnione standardową dawką. Zmień ją, aby zapisać inną dawkę tylko dla tego przyjęcia.",
|
||||
"timeLabel": "Godzina przyjęcia",
|
||||
"saveError": "Nie udało się zapisać przyjęcia.",
|
||||
"emptyTitle": "Brak leków",
|
||||
@@ -641,6 +642,8 @@
|
||||
"medicationLabel": "Lek",
|
||||
"slotLabel": "Przedział harmonogramu",
|
||||
"slotNone": "Bez konkretnego przedziału",
|
||||
"doseLabel": "Dawka",
|
||||
"doseHint": "Wstępnie wypełnione dawką leku lub przedziału. Zmień ją, aby zapisać inną dawkę tylko dla tego przyjęcia.",
|
||||
"takenAtLabel": "Data i godzina",
|
||||
"takenAtHint": "Domyślnie teraz; wybierz wcześniejszą datę i godzinę, aby zarejestrować przeszłą dawkę.",
|
||||
"skippedLabel": "Zarejestruj jako pominięte",
|
||||
@@ -1350,6 +1353,7 @@
|
||||
"markAsSkipped": "Oznacz jako pominiętą",
|
||||
"adHocTag": "poza planem",
|
||||
"takenDetail": "planowo {planned} · przyjęto {taken}",
|
||||
"doseTaken": "Dawka: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "na czas",
|
||||
"taken_late": "z opóźnieniem",
|
||||
@@ -1982,6 +1986,14 @@
|
||||
"composerPlaceholder": "Zapytaj o swoje dane…",
|
||||
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
|
||||
"selfContextChipsLabel": "Coach chciałby wiedzieć:",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Dodać tę odpowiedź do Twoich informacji o sobie?",
|
||||
"confirm": "Dodaj",
|
||||
"dismiss": "Odrzuć",
|
||||
"done": "Dodano do informacji o sobie",
|
||||
"duplicate": "Już znajduje się w informacjach o sobie",
|
||||
"failed": "Nie udało się dodać. Spróbuj ponownie."
|
||||
},
|
||||
"selfContextChipDismiss": "Odrzuć pytanie",
|
||||
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
|
||||
"threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- v1.16.4 — per-intake dose override.
|
||||
--
|
||||
-- A user who takes half a tablet (or a doubled dose on a titration day)
|
||||
-- could only record the take as-is; the ledger then implied the scheduled
|
||||
-- dose was consumed. `dose_taken` records the free-text dose actually
|
||||
-- consumed for THIS event when it deviates from (or simply documents) the
|
||||
-- medication / schedule dose. NULL = no override recorded — every legacy
|
||||
-- row and every take logged without touching the dose field stays NULL,
|
||||
-- and read paths fall back to the medication's configured dose.
|
||||
ALTER TABLE "medication_intake_events" ADD COLUMN "dose_taken" TEXT;
|
||||
@@ -1570,6 +1570,11 @@ model MedicationIntakeEvent {
|
||||
/// is the only surface that writes this column). Existing rows stay
|
||||
/// NULL forever and the read paths treat NULL as "no site recorded".
|
||||
injectionSite InjectionSite? @map("injection_site")
|
||||
/// v1.16.4 — per-intake dose override. Free text (mirrors
|
||||
/// `Medication.dose`), populated only when the user edits the dose at
|
||||
/// log time (half tablet, titration day). NULL = the configured
|
||||
/// medication / schedule dose applies; read paths fall back to it.
|
||||
doseTaken String? @map("dose_taken")
|
||||
// v1.7.0 sync — server-set mutation timestamp. The model had no
|
||||
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
|
||||
// added here. `@updatedAt` makes Prisma maintain it on every write.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* POST /api/coach/about-me/adopt — fold a clarifying-question answer
|
||||
* back into the stored self-context.
|
||||
*
|
||||
* v1.16.4 — closes the questions loop: the Coach composer's pending
|
||||
* chips (`/api/coach/about-me/questions`) insert a question the user
|
||||
* answers in their own words; this endpoint lets that answer flow back
|
||||
* into the matching structured self-context field instead of living
|
||||
* only in the chat transcript. Additive — the existing GET/PUT
|
||||
* `/api/coach/about-me` contract is untouched.
|
||||
*
|
||||
* Semantics:
|
||||
* - The target field is picked from the QUESTION wording (allergy /
|
||||
* condition keywords across the six UI locales; everything else
|
||||
* lands on `coachFocus`, the "what the Coach should know" slot).
|
||||
* - Dedupe: an answer already contained in the target field (or in
|
||||
* the free-text `aboutMe`) is a no-op (`adopted: false`) — tapping
|
||||
* the offer twice or re-answering the same chip cannot stack
|
||||
* duplicate prose.
|
||||
* - Append, never replace: existing text stays; the answer joins on
|
||||
* its own line, encrypted at rest like every self-context write.
|
||||
* A structured field at its cap overflows into `aboutMe`.
|
||||
* - Audit rows carry lengths only — the text is free-form health
|
||||
* prose and must not land in plaintext audit details.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
import {
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ABOUT_ME_MAX_CHARS,
|
||||
aboutMeAdoptSchema,
|
||||
} from "@/lib/validations/about-me";
|
||||
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type StructuredField = "conditions" | "allergies" | "coachFocus";
|
||||
|
||||
/**
|
||||
* Keyword match against the question wording, covering the six UI
|
||||
* locales the question generator writes in (de / en / es / fr / it /
|
||||
* pl). Stems, not words, so inflections match. Anything that is not
|
||||
* clearly an allergy or condition question lands on `coachFocus` — the
|
||||
* generic "worth knowing for the Coach" slot.
|
||||
*/
|
||||
function matchSelfContextField(question: string): StructuredField {
|
||||
const q = question.toLowerCase();
|
||||
const allergyStems = [
|
||||
"allerg", // de/en/fr/it
|
||||
"alergi", // es/pl
|
||||
"uczulen", // pl
|
||||
"intoleran", // de/en/es/it
|
||||
"unverträglich", // de
|
||||
];
|
||||
if (allergyStems.some((s) => q.includes(s))) return "allergies";
|
||||
const conditionStems = [
|
||||
"erkrank", // de
|
||||
"krankheit", // de
|
||||
"diagnos", // de/en/es/fr/it/pl
|
||||
"condition", // en
|
||||
"chronic", // en
|
||||
"chronisch", // de
|
||||
"enfermedad", // es
|
||||
"maladie", // fr
|
||||
"malatt", // it
|
||||
"chorob", // pl
|
||||
];
|
||||
if (conditionStems.some((s) => q.includes(s))) return "conditions";
|
||||
return "coachFocus";
|
||||
}
|
||||
|
||||
/** Whitespace-collapsed lowercase form for containment dedupe. */
|
||||
function normalise(s: string): string {
|
||||
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-about-me:adopt:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = aboutMeAdoptSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { question, answer } = parsed.data;
|
||||
const ctx = await getSelfContextForUser(user.id);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
const normAnswer = normalise(answer);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
const targetExisting = ctx[field];
|
||||
if (
|
||||
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
|
||||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopt_deduped" },
|
||||
meta: { field },
|
||||
});
|
||||
return apiSuccess({ adopted: false, field, reason: "duplicate" });
|
||||
}
|
||||
|
||||
// Append on its own line; a structured field at its cap overflows
|
||||
// into the free-text aboutMe (4000 chars) rather than failing the
|
||||
// adoption outright.
|
||||
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
|
||||
field = "aboutMe";
|
||||
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
|
||||
return apiError("Self-context is full", 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field assembly (no mass assignment): exactly one encrypted
|
||||
// column is written per adoption.
|
||||
const column = (
|
||||
{
|
||||
conditions: "conditionsEncrypted",
|
||||
allergies: "allergiesEncrypted",
|
||||
coachFocus: "coachFocusEncrypted",
|
||||
aboutMe: "aboutMeEncrypted",
|
||||
} as const
|
||||
)[field];
|
||||
const payload = { [column]: encryptToBytes(nextValue) };
|
||||
|
||||
await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...payload },
|
||||
update: payload,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
|
||||
await auditLog("coach.about_me.adopted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { field, answerLength: answer.length },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopted" },
|
||||
meta: { field, answer_length: answer.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ adopted: true, field });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -89,6 +89,8 @@ interface SerializedDoseHistoryRow {
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
autoMissed: boolean;
|
||||
/** v1.16.4 — per-intake dose override; null = configured dose. */
|
||||
doseTaken: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -161,6 +163,8 @@ export const GET = apiHandler(
|
||||
autoMissed: true,
|
||||
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,6 +175,7 @@ export const GET = apiHandler(
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
doseTaken: e.doseTaken,
|
||||
}));
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
@@ -242,6 +247,7 @@ export const GET = apiHandler(
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.pinned,
|
||||
doseTaken: e.doseTaken,
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
@@ -266,6 +272,7 @@ export const GET = apiHandler(
|
||||
takenAt: row.intake.takenAt?.toISOString() ?? null,
|
||||
skipped: row.intake.skipped,
|
||||
autoMissed: row.intake.autoMissed ?? false,
|
||||
doseTaken: row.intake.doseTaken ?? null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
@@ -91,8 +91,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
idempotencyKey,
|
||||
injectionSite,
|
||||
forceSlotInstant,
|
||||
doseTaken,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.16.4 — the dose override documents a consumed dose, so it only
|
||||
// applies to a taken (non-skipped) write; on a skip it is silently
|
||||
// dropped (the slot consumed nothing).
|
||||
const resolvedDoseTaken = !skipped && doseTaken ? doseTaken : null;
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site. Load
|
||||
// the medication's delivery form + tracking opt-in + per-medication
|
||||
// allowed sites, plus the user's global exclusion deny-list. A site
|
||||
@@ -295,6 +301,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
// v1.16.4 — per-intake dose override (null unless a taken write
|
||||
// carried one).
|
||||
doseTaken: resolvedDoseTaken,
|
||||
});
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
@@ -342,6 +351,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
createSource: "WEB",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: resolvedDoseTaken,
|
||||
});
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
@@ -371,6 +381,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// v1.16.4 — dose override only on a taken write carrying one.
|
||||
...(resolvedDoseTaken !== null && {
|
||||
doseTaken: resolvedDoseTaken,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
|
||||
@@ -96,6 +96,11 @@ const bulkEntrySchema = z.object({
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.16.4 — per-entry dose override, mirroring the single intake
|
||||
// route: free text (max 50 chars, like `Medication.dose`), persisted
|
||||
// only on a taken entry. Absent = the configured medication dose
|
||||
// applies.
|
||||
doseTaken: z.string().trim().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
@@ -381,6 +386,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
createSource: "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
// v1.16.4 — dose override only documents a consumed dose.
|
||||
doseTaken: (!entry.skipped && entry.doseTaken) || null,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// C2 — pending echo onto an already-actioned slot. Report it as a
|
||||
@@ -451,6 +458,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
createSource: "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: (!entry.skipped && entry.doseTaken) || null,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
duplicates += 1;
|
||||
@@ -485,6 +493,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// v1.16.4 — dose override only on a taken entry carrying one.
|
||||
...(!entry.skipped &&
|
||||
entry.doseTaken && { doseTaken: entry.doseTaken }),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import {
|
||||
MeasurementForm,
|
||||
resolveAddToken,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -16,6 +19,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -34,6 +38,19 @@ export default function MeasurementsPage() {
|
||||
// the Sheet branch can sticky-pin Save / Cancel.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
if (addParam && addParam !== consumedAddParam) {
|
||||
setConsumedAddParam(addParam);
|
||||
// v1.4.34 IW-G — `resolveAddToken` is the single source of truth
|
||||
@@ -66,6 +83,7 @@ export default function MeasurementsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
|
||||
+19
-1
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,6 +17,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
@@ -21,6 +25,19 @@ export default function MoodPage() {
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
@@ -37,6 +54,7 @@ export default function MoodPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CoachInput } from "./coach-input";
|
||||
import { HistoryRail } from "./history-rail";
|
||||
import { MessageThread } from "./message-thread";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
|
||||
import { SelfContextChips } from "./self-context-chips";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
@@ -113,6 +114,17 @@ export function CoachConversation({
|
||||
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
|
||||
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
|
||||
// v1.16.4 — self-context backflow. `activeChipQuestion` remembers the
|
||||
// clarifying-question chip the user tapped (the chip pre-fills the
|
||||
// composer); when the next message answers it, `pendingAdopt` raises a
|
||||
// quiet offer to fold the answer back into the Selbstauskunft.
|
||||
const [activeChipQuestion, setActiveChipQuestion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingAdopt, setPendingAdopt] = useState<{
|
||||
question: string;
|
||||
answer: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: conversation } = useCoachConversation(currentConversationId);
|
||||
const send = useSendCoachMessage({
|
||||
@@ -137,16 +149,32 @@ export function CoachConversation({
|
||||
async function handleSubmit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || send.isStreaming) return;
|
||||
// v1.16.4 — when this message answers a tapped clarifying-question
|
||||
// chip, peel the inserted question off the front so only the user's
|
||||
// own words form the adoptable answer. An empty remainder (the user
|
||||
// deleted their answer or sent the bare question) raises no offer.
|
||||
const chipQuestion = activeChipQuestion;
|
||||
setActiveChipQuestion(null);
|
||||
const answer = chipQuestion
|
||||
? trimmed.startsWith(chipQuestion)
|
||||
? trimmed.slice(chipQuestion.length).trim()
|
||||
: trimmed
|
||||
: null;
|
||||
setInputValue("");
|
||||
await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
});
|
||||
if (chipQuestion && answer) {
|
||||
setPendingAdopt({ question: chipQuestion, answer });
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
setActiveChipQuestion(null);
|
||||
setPendingAdopt(null);
|
||||
send.reset();
|
||||
}
|
||||
|
||||
@@ -239,9 +267,21 @@ export function CoachConversation({
|
||||
chips. Tapping inserts the question into the composer
|
||||
(the user answers it in their own words) and dismisses
|
||||
the chip. Renders nothing when no questions pend. */}
|
||||
{/* v1.16.4 — quiet adopt-into-self-context offer once a chip
|
||||
question has been answered. Self-removes after settle. */}
|
||||
{pendingAdopt && !send.isStreaming ? (
|
||||
<SelfContextAdoptOffer
|
||||
question={pendingAdopt.question}
|
||||
answer={pendingAdopt.answer}
|
||||
onDismiss={() => setPendingAdopt(null)}
|
||||
/>
|
||||
) : null}
|
||||
<SelfContextChips
|
||||
disabled={send.isStreaming}
|
||||
onPick={(question) => setInputValue(`${question}\n`)}
|
||||
onPick={(question) => {
|
||||
setActiveChipQuestion(question);
|
||||
setInputValue(`${question}\n`);
|
||||
}}
|
||||
/>
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.16.4 — quiet "fold this answer back into your self-context" offer.
|
||||
*
|
||||
* When the user answers a pending clarifying-question chip (the chip
|
||||
* inserted the question into the composer; the sent message is the
|
||||
* answer), this strip appears above the composer and offers to adopt
|
||||
* the answer into the matching Selbstauskunft field via
|
||||
* `POST /api/coach/about-me/adopt`. One tap adopts (the server picks
|
||||
* the field, dedupes, appends encrypted); the ✕ declines. After an
|
||||
* adoption (or a server-side dedupe) a short confirmation lingers for a
|
||||
* few seconds, then the strip removes itself — no modal, no toast, no
|
||||
* vertical cost once settled.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { BookmarkPlus, Check, Loader2, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelfContextAdoptOfferProps {
|
||||
/** The clarifying question the user just answered. */
|
||||
question: string;
|
||||
/** The composer message that answered it. */
|
||||
answer: string;
|
||||
/** Remove the strip (declined, or confirmation elapsed). */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function SelfContextAdoptOffer({
|
||||
question,
|
||||
answer,
|
||||
onDismiss,
|
||||
}: SelfContextAdoptOfferProps) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
|
||||
const adopt = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/coach/about-me/adopt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question, answer }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).data as { adopted: boolean };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSettled(data.adopted ? "adopted" : "duplicate");
|
||||
},
|
||||
});
|
||||
|
||||
// Let the confirmation linger briefly, then fold the strip away.
|
||||
useEffect(() => {
|
||||
if (!settled) return;
|
||||
const handle = window.setTimeout(onDismiss, 4000);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [settled, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-self-context-adopt"
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 mb-2 flex items-center gap-2 rounded-lg border px-3 py-2",
|
||||
)}
|
||||
>
|
||||
{settled ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check
|
||||
className="text-dracula-green size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(
|
||||
settled === "adopted"
|
||||
? "insights.coach.selfContextAdopt.done"
|
||||
: "insights.coach.selfContextAdopt.duplicate",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<BookmarkPlus
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-muted-foreground min-w-0 flex-1 text-xs">
|
||||
{t("insights.coach.selfContextAdopt.offer")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-self-context-adopt-confirm"
|
||||
disabled={adopt.isPending}
|
||||
onClick={() => adopt.mutate()}
|
||||
className={cn(
|
||||
"text-dracula-purple inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{adopt.isPending ? (
|
||||
<Loader2
|
||||
className="size-3 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("insights.coach.selfContextAdopt.confirm")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("insights.coach.selfContextAdopt.dismiss")}
|
||||
data-slot="coach-self-context-adopt-dismiss"
|
||||
onClick={onDismiss}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{adopt.isError ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("insights.coach.selfContextAdopt.failed")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { ListOrdered, Settings2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -227,6 +227,32 @@ export function SubPageShell({
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<TargetAdjustButton />
|
||||
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
|
||||
{/* v1.16.4 — customise cog on every routed sub-page,
|
||||
mirroring the mother page's tab-strip cog: same Settings2
|
||||
glyph, same `/settings/insights` destination (overview
|
||||
arrange + pill sort). Rightmost in the cluster so it sits
|
||||
where the mother page's cog sits — far top-right — and
|
||||
clearly apart from the self-gating target-adjust gear. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="insights-subpage-customize"
|
||||
className={cn(
|
||||
// Match the cluster's 40 px icon box + extended hit area
|
||||
// (see `<TargetAdjustButton>` for the WCAG 2.5.5 note).
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/settings/insights"
|
||||
aria-label={t("insights.customize")}
|
||||
title={t("insights.customize")}
|
||||
>
|
||||
<Settings2 className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{explainerMetric ? (
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface LedgerRow {
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
autoMissed: boolean;
|
||||
/** v1.16.4 — per-intake dose override; null/absent = configured dose. */
|
||||
doseTaken?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -659,6 +659,19 @@ function LedgerRowItem({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.16.4 — quiet per-intake dose override note. Only rows whose
|
||||
take recorded a deviating dose carry one; the configured dose
|
||||
stays implicit, so the ledger's rhythm is undisturbed. */}
|
||||
{intake?.doseTaken && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-dose-taken"
|
||||
>
|
||||
{t("medications.detail.verlauf.doseTaken", {
|
||||
dose: intake.doseTaken,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{dueContext && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
|
||||
@@ -127,6 +127,11 @@ export function LogIntakeDialog({
|
||||
);
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// v1.16.4 — per-intake dose override. `null` = untouched: the slot's
|
||||
// schedule dose (when a slot is pinned and carries one) or the
|
||||
// medication's configured dose prefills the field and nothing extra is
|
||||
// submitted. An edit that deviates travels as `doseTaken`.
|
||||
const [doseOverride, setDoseOverride] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => medications.find((m) => m.id === medicationId),
|
||||
@@ -134,11 +139,30 @@ export function LogIntakeDialog({
|
||||
);
|
||||
const slotTimes = useMemo(() => slotTimesFor(selected), [selected]);
|
||||
|
||||
// Configured baseline for the picked slot: a pinned slot prefers its
|
||||
// schedule's own dose (titration schedules differ per slot); otherwise
|
||||
// the medication's catalogue dose.
|
||||
const configuredDose = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
if (slot !== NO_SLOT) {
|
||||
const scheduleForSlot = selected.schedules.find((s) =>
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay.includes(slot)
|
||||
: s.windowStart === slot,
|
||||
);
|
||||
if (scheduleForSlot?.dose) return scheduleForSlot.dose;
|
||||
}
|
||||
return selected.dose ?? "";
|
||||
}, [selected, slot]);
|
||||
const dose = doseOverride ?? configuredDose;
|
||||
|
||||
function handleMedicationChange(id: string) {
|
||||
setMedicationId(id);
|
||||
// Reset the slot when switching medications — the prior slot may not
|
||||
// exist on the new schedule.
|
||||
// exist on the new schedule. The dose override resets too so the new
|
||||
// medication's configured dose flows through.
|
||||
setSlot(NO_SLOT);
|
||||
setDoseOverride(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -158,12 +182,20 @@ export function LogIntakeDialog({
|
||||
scheduledFor = iso;
|
||||
}
|
||||
setBusy(true);
|
||||
// v1.16.4 — only a deliberate deviation travels; an untouched or
|
||||
// configured-dose value keeps the row NULL (configured dose applies).
|
||||
const trimmedDose = dose.trim();
|
||||
const doseDeviates =
|
||||
!skipped &&
|
||||
trimmedDose.length > 0 &&
|
||||
trimmedDose !== configuredDose.trim();
|
||||
try {
|
||||
const ok = await runLogIntake({
|
||||
medication: { id: med.id, name: med.name },
|
||||
skipped,
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
scheduledFor,
|
||||
...(doseDeviates && { doseTaken: trimmedDose }),
|
||||
t,
|
||||
queryClient,
|
||||
});
|
||||
@@ -214,7 +246,12 @@ export function LogIntakeDialog({
|
||||
<NativeSelect
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => setSlot(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSlot(e.target.value);
|
||||
// The configured baseline tracks the slot's schedule
|
||||
// dose, so an untouched field re-prefills on change.
|
||||
setDoseOverride(null);
|
||||
}}
|
||||
>
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
@@ -228,6 +265,24 @@ export function LogIntakeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-dose">
|
||||
{t("medications.logIntake.doseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-dose"
|
||||
value={dose}
|
||||
maxLength={50}
|
||||
onChange={(e) => setDoseOverride(e.target.value)}
|
||||
placeholder={configuredDose}
|
||||
autoComplete="off"
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.doseHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
|
||||
@@ -181,14 +181,22 @@ export async function runLogIntake(deps: {
|
||||
* slot. Omit/undefined to route through the unscheduled/PRN path.
|
||||
*/
|
||||
scheduledFor?: string;
|
||||
/**
|
||||
* v1.16.4 — per-intake dose override. Sent only on a taken write; the
|
||||
* caller passes it only when the user's dose edit deviates from the
|
||||
* configured medication / schedule dose.
|
||||
*/
|
||||
doseTaken?: string;
|
||||
t: Translator;
|
||||
queryClient: QueryClient;
|
||||
}): Promise<boolean> {
|
||||
const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps;
|
||||
const { medication, skipped, takenAt, scheduledFor, doseTaken, t, queryClient } =
|
||||
deps;
|
||||
try {
|
||||
const body: Record<string, unknown> = { skipped };
|
||||
if (!skipped) body.takenAt = takenAt;
|
||||
if (scheduledFor) body.scheduledFor = scheduledFor;
|
||||
if (!skipped && doseTaken) body.doseTaken = doseTaken;
|
||||
const res = await fetch(`/api/medications/${medication.id}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PullToRefreshState } from "@/hooks/use-pull-to-refresh";
|
||||
|
||||
/**
|
||||
* v1.16.4 — quiet pull-to-refresh affordance for the PWA list pages.
|
||||
*
|
||||
* A single small disc that descends from under the app bar as the user
|
||||
* pulls: an arrow while the gesture is short of the threshold, flipping
|
||||
* to a spinner once armed / refreshing. Fixed overlay — the page content
|
||||
* never shifts, so the gesture costs no layout. Renders nothing at rest;
|
||||
* the disc's opacity tracks the pull so a barely-started drag stays
|
||||
* barely visible. Touch-only by construction (the hook ignores mouse).
|
||||
*/
|
||||
export function PullToRefreshIndicator({
|
||||
pullDistance,
|
||||
refreshing,
|
||||
armed,
|
||||
threshold,
|
||||
}: PullToRefreshState) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (pullDistance <= 0 && !refreshing) return null;
|
||||
|
||||
// Progress drives both the descent and the fade-in; while refreshing the
|
||||
// disc parks at its full-descent resting point.
|
||||
const progress = refreshing ? 1 : Math.min(pullDistance / threshold, 1);
|
||||
const offset = refreshing ? threshold * 0.75 : pullDistance * 0.75;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="pull-to-refresh-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed inset-x-0 top-2 z-50 flex justify-center"
|
||||
style={{
|
||||
transform: `translateY(${offset}px)`,
|
||||
opacity: 0.35 + progress * 0.65,
|
||||
transition: refreshing ? "transform 150ms ease-out" : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-card text-muted-foreground flex size-8 items-center justify-center rounded-full border shadow-sm",
|
||||
(armed || refreshing) && "text-primary border-primary/40",
|
||||
)}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDown
|
||||
className="size-4 transition-transform duration-150"
|
||||
style={{ transform: armed ? "rotate(180deg)" : undefined }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{refreshing ? t("common.pullToRefresh") : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* v1.16.4 — touch-only pull-to-refresh for the PWA's list surfaces.
|
||||
*
|
||||
* Listens for a downward touch drag that starts with the page scrolled to
|
||||
* the very top, applies a 0.5 resistance factor so the gesture reads
|
||||
* "elastic", and — when the resisted distance passes `threshold` on
|
||||
* release — runs `onRefresh` (typically an "invalidate the visible
|
||||
* queries" call). Mouse and trackpad users are untouched: only `touch*`
|
||||
* events are observed, so desktop scrolling never arms the gesture.
|
||||
*
|
||||
* Deliberately does NOT `preventDefault()` the touch moves: the listener
|
||||
* stays passive (no scroll-jank), and standalone-PWA contexts already
|
||||
* suppress the browser's native refresh via `overscroll-behavior`. The
|
||||
* indicator is an overlay, so the content never shifts.
|
||||
*/
|
||||
|
||||
export interface UsePullToRefreshOptions {
|
||||
/** Refresh action; the indicator spins until the promise settles. */
|
||||
onRefresh: () => Promise<unknown>;
|
||||
/** Resisted pull distance (px) that arms the refresh. Default 64. */
|
||||
threshold?: number;
|
||||
/** True suspends the listeners entirely (e.g. while a sheet is open). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PullToRefreshState {
|
||||
/** Resisted pull distance in px (0 when idle). */
|
||||
pullDistance: number;
|
||||
/** True from a passed-threshold release until `onRefresh` settles. */
|
||||
refreshing: boolean;
|
||||
/** True while the live pull is past the release threshold. */
|
||||
armed: boolean;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export function usePullToRefresh({
|
||||
onRefresh,
|
||||
threshold = 64,
|
||||
disabled = false,
|
||||
}: UsePullToRefreshOptions): PullToRefreshState {
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// The latest refresh callback rides a ref so the touch listeners bind
|
||||
// once per `disabled` / `threshold` change, not on every parent render.
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
useEffect(() => {
|
||||
onRefreshRef.current = onRefresh;
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || typeof window === "undefined") return;
|
||||
|
||||
let startY: number | null = null;
|
||||
let pulling = false;
|
||||
let busy = false;
|
||||
let lastDistance = 0;
|
||||
|
||||
const atTop = () =>
|
||||
(document.scrollingElement?.scrollTop ?? window.scrollY) <= 0;
|
||||
|
||||
const reset = () => {
|
||||
startY = null;
|
||||
pulling = false;
|
||||
lastDistance = 0;
|
||||
setPullDistance(0);
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (busy || e.touches.length !== 1 || !atTop()) return;
|
||||
startY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (busy || startY === null) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy <= 0 || !atTop()) {
|
||||
// Upward drag or the page started scrolling — stand down quietly.
|
||||
if (pulling) reset();
|
||||
return;
|
||||
}
|
||||
pulling = true;
|
||||
// 0.5 resistance + a soft cap keeps the indicator calm.
|
||||
lastDistance = Math.min(dy * 0.5, threshold * 1.5);
|
||||
setPullDistance(lastDistance);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (busy || !pulling) {
|
||||
startY = null;
|
||||
return;
|
||||
}
|
||||
const release = lastDistance;
|
||||
reset();
|
||||
if (release < threshold) return;
|
||||
busy = true;
|
||||
setRefreshing(true);
|
||||
void Promise.resolve(onRefreshRef.current())
|
||||
.catch(() => {
|
||||
// A failed refetch surfaces through the queries themselves; the
|
||||
// gesture just winds down.
|
||||
})
|
||||
.finally(() => {
|
||||
busy = false;
|
||||
setRefreshing(false);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
window.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
window.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("touchstart", onTouchStart);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onTouchEnd);
|
||||
window.removeEventListener("touchcancel", onTouchEnd);
|
||||
};
|
||||
}, [disabled, threshold]);
|
||||
|
||||
return {
|
||||
pullDistance,
|
||||
refreshing,
|
||||
armed: pullDistance >= threshold,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,12 @@ export interface HistoryIntake {
|
||||
* anchor-binds. Optional so legacy callers / fixtures default to AUTO.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* v1.16.4 — per-intake dose override the user recorded at log time.
|
||||
* Optional free text; absent / null = the medication's configured dose
|
||||
* applies. Pure pass-through for the read ledger (no status impact).
|
||||
*/
|
||||
doseTaken?: string | null;
|
||||
}
|
||||
|
||||
export type DoseHistoryStatus =
|
||||
|
||||
@@ -72,6 +72,14 @@ export interface ApplyCanonicalSlotWriteInput {
|
||||
* carry a binding decision).
|
||||
*/
|
||||
attributionSource?: "AUTO" | "USER_PIN";
|
||||
/**
|
||||
* v1.16.4 — per-intake dose override to persist on a taken write.
|
||||
* `null` / omitted = no override (the column stays untouched on an
|
||||
* update, NULL on a create). Never clears a previously-recorded
|
||||
* override — like `injectionSite`, a pending echo or skip cannot wash
|
||||
* a recorded dose away.
|
||||
*/
|
||||
doseTaken?: string | null;
|
||||
}
|
||||
|
||||
export interface ApplyCanonicalSlotWriteResult {
|
||||
@@ -174,6 +182,7 @@ export async function applyCanonicalSlotWrite(
|
||||
createSource,
|
||||
injectionSite = null,
|
||||
attributionSource,
|
||||
doseTaken = null,
|
||||
} = input;
|
||||
|
||||
const rows = await findSlotRows(client, userId, medicationId, canonicalSlot);
|
||||
@@ -204,6 +213,8 @@ export async function applyCanonicalSlotWrite(
|
||||
...(injectionSite !== null && { injectionSite }),
|
||||
// v1.15.20 — binding provenance; absent → schema default AUTO.
|
||||
...(attributionSource !== undefined && { attributionSource }),
|
||||
// v1.16.4 — dose override only when the write carries one.
|
||||
...(doseTaken !== null && { doseTaken }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
@@ -250,6 +261,7 @@ async function applyToExisting(
|
||||
idempotencyKey,
|
||||
injectionSite = null,
|
||||
attributionSource,
|
||||
doseTaken = null,
|
||||
} = input;
|
||||
|
||||
const existingActioned = existing.takenAt !== null || existing.skipped;
|
||||
@@ -297,6 +309,9 @@ async function applyToExisting(
|
||||
// decision (pin / band re-attribution). A pending echo or skip never
|
||||
// overwrites a recorded USER_PIN.
|
||||
...(attributionSource !== undefined && { attributionSource }),
|
||||
// v1.16.4 — dose override only when this write carries one; never
|
||||
// clear a previously-recorded override with a null re-post.
|
||||
...(doseTaken !== null && { doseTaken }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
|
||||
@@ -1,731 +0,0 @@
|
||||
/**
|
||||
* Centralized TanStack Query key factory.
|
||||
*
|
||||
* Every useQuery/invalidateQueries call should go through this factory so that
|
||||
* mutations invalidate the right consumers. Hard-coded string arrays drifted in
|
||||
* the past (e.g. ["measurements"] didn't invalidate ["analytics"] on the
|
||||
* dashboard), so treat this file as the single source of truth.
|
||||
*/
|
||||
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryKeys = {
|
||||
auth: () => ["auth"] as const,
|
||||
/**
|
||||
* v1.4.40 W-RSC — the `useAuth` hook's `["auth", "me"]` shape was a
|
||||
* recurring source of factory drift (audit H1 — "`useAuth` uses
|
||||
* `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`"). Both
|
||||
* shapes share the `["auth"]` prefix so existing prefix-invalidations
|
||||
* still match, but the centralised name makes the call site
|
||||
* obviously factory-routed.
|
||||
*/
|
||||
authMe: () => ["auth", "me"] as const,
|
||||
authRegistrationStatus: () => ["auth", "registration-status"] as const,
|
||||
/**
|
||||
* v1.4.40 W-RSC — Settings → AI surfaces and the targets editor
|
||||
* subscribe to the user-thresholds API. Centralise the key so a
|
||||
* future rename (e.g. `["user","limits"]`) doesn't drift across the
|
||||
* three call sites (settings/thresholds-editor-section,
|
||||
* settings/ai-section, targets/target-edit-sheet).
|
||||
*/
|
||||
userThresholds: () => ["user", "thresholds"] as const,
|
||||
/**
|
||||
* v1.7.0 — Settings → Display metric/imperial control reads its
|
||||
* current value from `GET /api/auth/me/unit-preference`. The PATCH
|
||||
* mutation also invalidates `authMe()` so `useAuth().unitPreference`
|
||||
* (and every chart display transform that keys off it) re-renders
|
||||
* without a manual reload.
|
||||
*/
|
||||
userUnitPreference: () => ["user", "unit-preference"] as const,
|
||||
|
||||
measurements: () => ["measurements"] as const,
|
||||
moodEntries: () => ["mood-entries"] as const,
|
||||
|
||||
/**
|
||||
* v1.15.13 — the measurements management-list read with its full filter
|
||||
* + sort + pagination state baked into the key so the cache slot is
|
||||
* correct for every filter combination (a `sourceEq` / date-range /
|
||||
* type / page / sort change re-keys instead of poisoning a shared
|
||||
* slot). Rides under the `["measurements"]` prefix so
|
||||
* `measurementDependentKeys` (and a bulk-delete invalidation) reaches
|
||||
* every slot at once. `mode` distinguishes the synthetic day-grouped /
|
||||
* sleep-night branches from the plain `raw` list.
|
||||
*/
|
||||
measurementsList: (params: {
|
||||
type: string | undefined;
|
||||
sourceEq: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
page: number;
|
||||
sortBy: string;
|
||||
sortDir: string;
|
||||
mode: "raw" | "groupBy=day" | "sleep-night";
|
||||
}) =>
|
||||
[
|
||||
"measurements",
|
||||
"list",
|
||||
params.type ?? null,
|
||||
params.sourceEq ?? null,
|
||||
params.from ?? null,
|
||||
params.to ?? null,
|
||||
params.page,
|
||||
params.sortBy,
|
||||
params.sortDir,
|
||||
params.mode,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.15.13 — the mood management-list read with its filter + sort +
|
||||
* pagination state baked into the key, mirroring `measurementsList`.
|
||||
* Rides under the `["mood-entries"]` prefix so `moodDependentKeys`
|
||||
* (and a bulk-delete invalidation) reaches every slot.
|
||||
*/
|
||||
moodEntriesList: (params: {
|
||||
mood: string | undefined;
|
||||
source: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
page: number;
|
||||
sortBy: string;
|
||||
sortDir: string;
|
||||
}) =>
|
||||
[
|
||||
"mood-entries",
|
||||
"list",
|
||||
params.mood ?? null,
|
||||
params.source ?? null,
|
||||
params.from ?? null,
|
||||
params.to ?? null,
|
||||
params.page,
|
||||
params.sortBy,
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date`
|
||||
* discriminator lets the night-picker step back through recent nights
|
||||
* without colliding caches; `undefined` is the most-recent night.
|
||||
*/
|
||||
sleepNight: (date?: string) => ["sleep-night", date ?? "latest"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.33 IW2 — the analytics queryKey now optionally carries a
|
||||
* `slice` discriminator so the dashboard tile-strip can subscribe to
|
||||
* the slim `?slice=summaries` server slice (IW1 / C1) without
|
||||
* colliding with the thick-payload consumers on the Insights tree.
|
||||
* Calling `queryKeys.analytics()` without a slice keeps the legacy
|
||||
* shape `["analytics"]` so mutation invalidations and the bulk-key
|
||||
* lists below stay byte-identical.
|
||||
*/
|
||||
analytics: (slice?: "summaries") =>
|
||||
slice ? (["analytics", slice] as const) : (["analytics"] as const),
|
||||
/**
|
||||
* v1.9.0 — single-metric period-over-period range read
|
||||
* (`GET /api/analytics/range`). A dedicated cache slot per `(type, range)`
|
||||
* so switching the time-range pills is a cheap cache hit after the first
|
||||
* fetch and never collides with the shared `["analytics", "summaries"]`
|
||||
* slot the dashboard tile-strip subscribes to. `["analytics"]` is a prefix
|
||||
* so a blanket `queryKeys.analytics()` invalidation still reaches it.
|
||||
*/
|
||||
analyticsRange: (type: string, range: string) =>
|
||||
["analytics", "range", type, range] as const,
|
||||
moodAnalytics: () => ["mood-analytics"] as const,
|
||||
/**
|
||||
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
|
||||
* weekday, tag breakdown, cross-metric correlations) for the Mood
|
||||
* Insights page. Read-only; invalidated on a mood write through the
|
||||
* `moodDependentKeys` fan-out below.
|
||||
*/
|
||||
moodInsights: () => ["mood-insights"] as const,
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag taxonomy catalog (global reference
|
||||
* data, identical for every user). Read by the mood-logging form's
|
||||
* tag-category capture surface. Not invalidated on a mood write — the
|
||||
* catalog only changes on a migration / admin edit, so a long
|
||||
* `staleTime` is fine.
|
||||
*/
|
||||
moodTagCatalog: () => ["mood-tag-catalog"] as const,
|
||||
|
||||
/**
|
||||
* v1.7.0 W6 — unified dashboard first-paint snapshot. One client cell
|
||||
* hydrates every above-the-fold tile from `GET /api/dashboard/snapshot`,
|
||||
* replacing the four independent analytics-slim / analytics-thick /
|
||||
* mood / widget-layout cells. A measurement / mood / medication /
|
||||
* widget / insight write evicts the matching server cache bucket via
|
||||
* `src/lib/cache/invalidate.ts`; the client read carries the same
|
||||
* 60 s `staleTime` as `DASHBOARD_QUERY_OPTS` so a warm return-to-
|
||||
* dashboard is a free cache hit.
|
||||
*/
|
||||
dashboardSnapshot: () => ["dashboard", "snapshot"] as const,
|
||||
|
||||
insightsRoot: () => ["insights"] as const,
|
||||
insightsComprehensive: () => ["insights", "comprehensive"] as const,
|
||||
insightsTargets: () => ["insights", "targets"] as const,
|
||||
/**
|
||||
* Shared cache key for the rich `/api/insights/generate` advisor
|
||||
* payload. Every surface that subscribes under this key shares the
|
||||
* same cache so a regenerate on one surface refreshes the others
|
||||
* without a second LLM round-trip.
|
||||
*/
|
||||
insightsAdvisor: () => ["insights", "advisor"] as const,
|
||||
/**
|
||||
* v1.11.0 — period-narrative summary (`/api/insights/narrative?period=…`).
|
||||
* Keyed by period + locale so the week and month summaries cache
|
||||
* independently and a locale switch fetches the matching prose.
|
||||
*/
|
||||
insightsNarrative: (period: string, locale: string) =>
|
||||
["insights", "narrative", period, locale] as const,
|
||||
insightsBpStatus: (locale: string) =>
|
||||
["insights", "blood-pressure-status", locale] as const,
|
||||
insightsWeightStatus: (locale: string) =>
|
||||
["insights", "weight-status", locale] as const,
|
||||
insightsPulseStatus: (locale: string) =>
|
||||
["insights", "pulse-status", locale] as const,
|
||||
insightsBmiStatus: (locale: string) =>
|
||||
["insights", "bmi-status", locale] as const,
|
||||
insightsMoodStatus: (locale: string) =>
|
||||
["insights", "mood-status", locale] as const,
|
||||
insightsMedicationComplianceStatus: (locale: string) =>
|
||||
["insights", "medication-compliance-status", locale] as const,
|
||||
/**
|
||||
* v1.8.7.1 — per-metric insight assessment for the HealthKit metric
|
||||
* sub-pages. One generic route (`/api/insights/metric-status?metric=…`)
|
||||
* backs every HealthKit metric, so the key is parameterised by the
|
||||
* metric id alongside the locale rather than minting a bespoke factory
|
||||
* entry per metric. The seven bespoke `*-status` routes above keep
|
||||
* their own keys; this one covers the ~29 generic HealthKit pages.
|
||||
*/
|
||||
insightsMetricStatus: (metric: string, locale: string) =>
|
||||
["insights", "metric-status", metric, locale] as const,
|
||||
/**
|
||||
* v1.8.7.1 — mutationKey for the on-demand full-warm POST
|
||||
* (`/api/insights/pregenerate`). Lives in the factory so the bare-array
|
||||
* ESLint rule stays satisfied; the warm enqueues every assessment
|
||||
* generation on the worker, so there is no read cache to invalidate
|
||||
* here — the existing status queries pick up the warmed text via their
|
||||
* own stale-while-revalidate.
|
||||
*/
|
||||
insightsPregenerate: () => ["insights", "pregenerate"] as const,
|
||||
/**
|
||||
* v1.10.0 — categorical events (WX-B). The device-flagged event
|
||||
* awareness timeline (`/api/insights/rhythm-events`). No locale segment
|
||||
* — the payload is verdicts + timestamps; the surface localises its own
|
||||
* prose. The `["insights"]` prefix keeps it in the standard insights
|
||||
* invalidation fan-out (an Apple Health batch write that lands an event
|
||||
* row busts it via the prefix).
|
||||
*/
|
||||
insightsRhythmEvents: () => ["insights", "rhythm-events"] as const,
|
||||
/**
|
||||
* v1.10.0 — derived-wellness metrics. One generic route
|
||||
* (`/api/insights/derived?metric=…[&type=…]`) backs the vitals
|
||||
* dashboard tiles, the composite score-anatomy view, and the home
|
||||
* wellness strip — all through the one `useDerivedMetric` hook.
|
||||
*
|
||||
* One generic route backs every derived metric (sleep score, readiness,
|
||||
* coincident-deviation, vitals baseline, …); the optional sub-type is the
|
||||
* chosen vital for `VITALS_BASELINE`. The fourth tuple slot is always
|
||||
* present (`null` when no sub-type) so the cache never poisons between a
|
||||
* 3-element and a 4-element shape for the same metric prefix. Pure compute
|
||||
* over the rollup tier — a measurement write keeps it fresh by construction
|
||||
* (next read sees new buckets), so the `["insights"]` prefix carries the
|
||||
* invalidation fan-out.
|
||||
*/
|
||||
insightsDerived: (metric: string, type?: string | null) =>
|
||||
["insights", "derived", metric, type ?? null] as const,
|
||||
/**
|
||||
* v1.10.0 — batched derived-metric read (`/api/insights/derived/batch`).
|
||||
* One request resolves the whole dashboard grid server-side instead of N
|
||||
* concurrent single-metric reads sharing the Prisma pool. Keyed by the
|
||||
* sorted token list so the same grid always hits one cache entry; sits
|
||||
* under the `["insights", "derived"]` prefix so a measurement-write
|
||||
* invalidation fan-out reaches it exactly like the single-metric reads.
|
||||
*/
|
||||
insightsDerivedBatch: (tokens: readonly string[]) =>
|
||||
["insights", "derived", "batch", [...tokens].sort().join(",")] as const,
|
||||
/**
|
||||
* v1.10.0 — FDR-controlled correlation discovery
|
||||
* (`/api/insights/correlations`). Read-only descriptive surface; the
|
||||
* `["insights"]` prefix keeps it in the existing invalidation fan-out.
|
||||
*/
|
||||
insightsCorrelations: () => ["insights", "correlations"] as const,
|
||||
|
||||
medications: () => ["medications"] as const,
|
||||
medicationDetail: (id: string) => ["medications", id] as const,
|
||||
medicationComplianceChart: (medicationId: string) =>
|
||||
["compliance-chart-inline", medicationId] as const,
|
||||
/**
|
||||
* v1.4.42 W3-QUERYKEY-LONGTAIL — per-medication compliance KPI used
|
||||
* by `medication-card` + `glp1-medication-card`. Centralising lets
|
||||
* the intake mutation invalidate every compliance read through the
|
||||
* `["medications"]` prefix instead of one bare-literal at a time.
|
||||
*/
|
||||
medicationCompliance: (medicationId: string) =>
|
||||
["medications", medicationId, "compliance"] as const,
|
||||
medicationCadence: (medicationId: string) =>
|
||||
["medications", medicationId, "cadence"] as const,
|
||||
medicationGlp1Details: (medicationId: string) =>
|
||||
["medications", medicationId, "glp1-details"] as const,
|
||||
/**
|
||||
* v1.15.18 — per-medication pen/vial inventory list, consumed by the
|
||||
* detail page's Bestand (supply) tab. Rides under the `["medications"]`
|
||||
* prefix so an intake mutation that decrements a pen invalidates it.
|
||||
*/
|
||||
medicationInventory: (medicationId: string) =>
|
||||
["medications", medicationId, "inventory"] as const,
|
||||
/**
|
||||
* v1.15.18 — per-medication dose-history ledger
|
||||
* (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the
|
||||
* detail page's Verlauf tab. Keyed by the `(from, to)` window so paging the
|
||||
* range caches each span independently; rides under the `["medications"]`
|
||||
* prefix so an intake mutation (take / skip / edit / delete) reaches it
|
||||
* through `medicationDependentKeys`. The Verlauf tab also mutates this slot
|
||||
* optimistically on Genommen / Übersprungen so the row status + the
|
||||
* Übersicht headline % flip in the same paint, then reconciles on the
|
||||
* authoritative refetch.
|
||||
*/
|
||||
medicationDoseHistory: (
|
||||
medicationId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
) => ["medications", medicationId, "dose-history", from, to] as const,
|
||||
medicationIntakeDrugLevelChart: (medicationId: string) =>
|
||||
["medications", medicationId, "intake", "drug-level-chart"] as const,
|
||||
/**
|
||||
* v1.4.42 — intake-history list with sort / paging / status filter.
|
||||
* The opaque params object lives at index 4 so the
|
||||
* `["medications", id, "intake", "list"]` prefix invalidates every
|
||||
* sort/page combination on an intake mutation.
|
||||
*/
|
||||
medicationIntakeList: (
|
||||
medicationId: string,
|
||||
params: {
|
||||
sortBy: string;
|
||||
sortDir: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
status: string;
|
||||
},
|
||||
) =>
|
||||
[
|
||||
"medications",
|
||||
medicationId,
|
||||
"intake",
|
||||
"list",
|
||||
params.sortBy,
|
||||
params.sortDir,
|
||||
params.limit,
|
||||
params.offset,
|
||||
params.status,
|
||||
] as const,
|
||||
/**
|
||||
* v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate
|
||||
* across every scheduled medication) was a bare `["medication-
|
||||
* compliance-chart", days]` key; route it through the factory so
|
||||
* `medicationDependentKeys` invalidates it on intake-mutation just
|
||||
* like the per-medication compliance-chart-inline tile. `days` is the
|
||||
* range (7 / 30 / 90); kept as the only param so the prefix
|
||||
* `["dashboard-medication-compliance"]` invalidates every range at
|
||||
* once.
|
||||
*/
|
||||
dashboardMedicationCompliance: (days: number) =>
|
||||
["dashboard-medication-compliance", days] as const,
|
||||
medicationPhaseConfig: (medicationId: string) =>
|
||||
["phase-config", medicationId] as const,
|
||||
/**
|
||||
* v1.5.5 F-1 H-2 — per-medication api-endpoint status (enabled +
|
||||
* active-token-count) used by the detail-page Externe Integration
|
||||
* row. The key rides under the `["medications", id, …]` prefix so
|
||||
* `medicationDependentKeys` catches it on token mint / disable.
|
||||
* Centralising the tuple closes the bare-array bypass the
|
||||
* useMemo inside `<ApiTokensRow>` was using.
|
||||
*/
|
||||
medicationApiEndpoint: (medicationId: string) =>
|
||||
["medications", medicationId, "api-endpoint"] as const,
|
||||
|
||||
gamificationAchievements: () => ["gamification", "achievements"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
notificationsPreferences: () => ["notifications", "preferences"] as const,
|
||||
notificationsStatus: () => ["notifications", "status"] as const,
|
||||
|
||||
settingsGlobalServices: () => ["settings", "global-services"] as const,
|
||||
settingsNtfy: () => ["settings", "ntfy"] as const,
|
||||
settingsReminderThresholds: () =>
|
||||
["settings", "reminder-thresholds"] as const,
|
||||
|
||||
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
|
||||
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain,
|
||||
* insights settings, user provider preference) and the targets editor
|
||||
* all read these endpoints; centralising the keys keeps invalidation
|
||||
* symmetrical with the user-thresholds + auth surfaces.
|
||||
*/
|
||||
insightsSettings: () => ["insights", "settings"] as const,
|
||||
insightsProviderChain: () => ["insights", "provider-chain"] as const,
|
||||
insightsGlp1Timeline: (limit: number | string) =>
|
||||
["insights", "glp1-timeline", limit] as const,
|
||||
userAiProvider: () => ["user", "ai-provider"] as const,
|
||||
userProfile: () => ["user", "profile"] as const,
|
||||
/**
|
||||
* v1.7.0 — the roaming notification prefs blob behind
|
||||
* `GET/PATCH /api/auth/me/notification-prefs` (medication delivery
|
||||
* default + mood reminder hour). Distinct from
|
||||
* `notificationsPreferences()` (the per-event push toggles on the
|
||||
* `/notifications` page) so the two never collide in the cache.
|
||||
*/
|
||||
authNotificationPrefs: () => ["auth", "me", "notification-prefs"] as const,
|
||||
|
||||
apiVersion: () => ["api", "version"] as const,
|
||||
publicVersion: () => ["public", "version"] as const,
|
||||
/** v1.15.12 H1 — admin overview "update available" check against the
|
||||
* latest GitHub release tag (best-effort, day-stale). */
|
||||
versionUpdateCheck: () => ["version", "update-check"] as const,
|
||||
researchMode: () => ["research-mode"] as const,
|
||||
moodlogStatus: () => ["moodlog-status"] as const,
|
||||
integrationsStatus: () => ["integrations", "status"] as const,
|
||||
|
||||
/** v1.11.0 — owner's clinician share links (Settings → Sharing). */
|
||||
shareLinks: () => ["share-links"] as const,
|
||||
|
||||
featureFlags: () => ["feature-flags"] as const,
|
||||
coachPrefs: () => ["coach-prefs"] as const,
|
||||
coachFacts: () => ["coach-facts"] as const,
|
||||
/** v1.15.20 — Settings → AI "about me" self-description. */
|
||||
coachAboutMe: () => ["coach-about-me"] as const,
|
||||
/** v1.16.0 — pending clarifying questions (Coach composer chips). */
|
||||
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
|
||||
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
|
||||
coachNudgeStatus: () => ["coach-nudge-status"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its
|
||||
* own bare-literal `["admin", "<name>"]`. Routing through the factory
|
||||
* lets a single rename change every consumer in lockstep.
|
||||
*/
|
||||
adminAiQuality: () => ["admin", "ai-quality"] as const,
|
||||
adminAppLogs: (
|
||||
traceId: string | undefined,
|
||||
action: string | undefined,
|
||||
level: string | undefined,
|
||||
range: string | undefined,
|
||||
) => ["admin", "app-logs", traceId, action, level, range] as const,
|
||||
adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const,
|
||||
adminBackups: () => ["admin", "backups"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
|
||||
adminFeedbackRoot: () => ["admin", "feedback"] as const,
|
||||
adminHostMetrics: (window: string) =>
|
||||
["admin", "host-metrics", window] as const,
|
||||
/** v1.15.20 — registration invite list (admin Users section). */
|
||||
adminInvites: () => ["admin", "invites"] as const,
|
||||
adminAuditActions: () => ["admin", "audit-log", "actions"] as const,
|
||||
adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const,
|
||||
/**
|
||||
* v1.4.42 W3-QUERYKEY-LONGTAIL — paginated + filtered audit-log
|
||||
* read used by the login-overview admin section. The `filtered`
|
||||
* discriminator at index 2 keeps the no-arg `adminAuditLog(filter)`
|
||||
* cache slot byte-distinct so its consumers don't collide.
|
||||
*/
|
||||
adminAuditLogFiltered: (params: {
|
||||
filter: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
actor: string;
|
||||
actionFilter: string;
|
||||
target: string;
|
||||
range: string;
|
||||
}) =>
|
||||
[
|
||||
"admin",
|
||||
"audit-log",
|
||||
"filtered",
|
||||
params.filter,
|
||||
params.page,
|
||||
params.perPage,
|
||||
params.actor,
|
||||
params.actionFilter,
|
||||
params.target,
|
||||
params.range,
|
||||
] as const,
|
||||
|
||||
tokens: () => ["tokens"] as const,
|
||||
telegram: () => ["telegram"] as const,
|
||||
telegramSettings: () => ["telegram", "settings"] as const,
|
||||
withings: () => ["withings"] as const,
|
||||
/**
|
||||
* v1.4.42 W3-QUERYKEY-LONGTAIL — the per-card Withings status read.
|
||||
* Shares the `["withings"]` prefix with `withings()` so a disconnect
|
||||
* mutation invalidates both at once.
|
||||
*/
|
||||
withingsStatus: () => ["withings", "status"] as const,
|
||||
whoop: () => ["whoop"] as const,
|
||||
/**
|
||||
* Per-card WHOOP status read. Shares the `["whoop"]` prefix with `whoop()`
|
||||
* so a disconnect / credentials mutation invalidates both at once.
|
||||
*/
|
||||
whoopStatus: () => ["whoop", "status"] as const,
|
||||
// v1.12.0 — Fitbit/Pixel integration card, mirroring the WHOOP keys.
|
||||
fitbit: () => ["fitbit"] as const,
|
||||
/**
|
||||
* Per-card Fitbit status read. Shares the `["fitbit"]` prefix with `fitbit()`
|
||||
* so a disconnect / credentials mutation invalidates both at once.
|
||||
*/
|
||||
fitbitStatus: () => ["fitbit", "status"] as const,
|
||||
|
||||
// v1.4.32 — workout list + detail caches. `workouts()` is the
|
||||
// root key invalidated by the batch-ingest mutation; the recent +
|
||||
// detail sub-keys ride underneath so the dashboard tile and the
|
||||
// detail page share a cache slot with the list page.
|
||||
workouts: () => ["workouts"] as const,
|
||||
workoutsRecent: () => ["workouts", "recent"] as const,
|
||||
/**
|
||||
* v1.4.42 W3-QUERYKEY-LONGTAIL — the `useWorkouts` hook used to
|
||||
* spread `workoutsRecent()` and append an opts object inline. The
|
||||
* factory now owns the full shape so the hook never reaches for a
|
||||
* literal-array wrapper.
|
||||
*/
|
||||
workoutsRecentList: (opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
since?: string;
|
||||
sportType?: string;
|
||||
}) => ["workouts", "recent", opts] as const,
|
||||
workoutDetail: (id: string) => ["workouts", id] as const,
|
||||
|
||||
adminSettings: () => ["admin", "settings"] as const,
|
||||
adminStatus: () => ["admin", "status"] as const,
|
||||
adminUsers: () => ["admin", "users"] as const,
|
||||
adminTokens: () => ["admin", "tokens"] as const,
|
||||
adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,
|
||||
|
||||
bugreportStatus: () => ["bugreport", "status"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (Code-LOW-5) — `["user", "dashboardWidgets"]`
|
||||
* was duplicated as a literal at three call sites (dashboard,
|
||||
* insights, settings/dashboard-layout). One typo turns into a
|
||||
* silent cache miss + extra fetch; the centralised key defends
|
||||
* against the same query-key-collision class as `analytics()`.
|
||||
*/
|
||||
dashboardWidgets: () => ["user", "dashboardWidgets"] as const,
|
||||
|
||||
/**
|
||||
* v1.5.5 — per-user insights tile layout (mirrors
|
||||
* `dashboardWidgets`). The Settings UI + the iOS client + the
|
||||
* `/insights` shell all read this key; the PUT mutation invalidates
|
||||
* it on save so every consumer paints the new layout in lockstep.
|
||||
*/
|
||||
insightsLayout: () => ["user", "insightsLayout"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source priority. The
|
||||
* Settings → Sources surface reads + writes this key; saving
|
||||
* invalidates `analytics()` because the cumulative-metric aggregator
|
||||
* folds the new priority into the SLEEP_DURATION daily total
|
||||
* immediately.
|
||||
*/
|
||||
sourcePriority: () => ["auth", "source-priority"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard
|
||||
* + insights chart row. Pre-fix the key was bare `["chart-data", …]`
|
||||
* across the codebase, which excluded it from
|
||||
* `measurementDependentKeys` and left chart caches stale for up to
|
||||
* 60 s after a measurement save (audit-C2). Routing through the
|
||||
* factory pulls every variant under a single
|
||||
* `["chart-data"]` invalidation prefix so a mutation refreshes the
|
||||
* tile strip + the chart row in lockstep.
|
||||
*
|
||||
* The shape carries the heavy parameter list because the chart query
|
||||
* is bounded by metric set, value mode, BMI divisor, timezone, and
|
||||
* fetch window; the factory packs those into a single tuple to keep
|
||||
* the cache layout byte-identical with the pre-v1.4.40 layout.
|
||||
*/
|
||||
chartData: (
|
||||
types: string,
|
||||
valueMode: string,
|
||||
bmiDivisor: string | number,
|
||||
timezone: string,
|
||||
fromIso: string,
|
||||
toIso: string,
|
||||
// v1.7.0 — display-time value scale (e.g. m/s → km/h via 3.6).
|
||||
// Defaults to 1 so every pre-v1.7.0 caller packs a byte-identical
|
||||
// tuple; a non-default scale re-keys the cache so the processed
|
||||
// (scaled) series doesn't bleed across charts that share the
|
||||
// underlying raw window.
|
||||
valueScale: number = 1,
|
||||
) =>
|
||||
[
|
||||
"chart-data",
|
||||
types,
|
||||
valueMode,
|
||||
bmiDivisor,
|
||||
timezone,
|
||||
fromIso,
|
||||
toIso,
|
||||
valueScale,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.8.5 — bounded recent-timestamp read powering the
|
||||
* measurement-diversity nudge on an insights category page. Keyed by
|
||||
* the page's `MeasurementType` so each metric caches its own window.
|
||||
* Shares the `measurement-dependent` invalidation prefix below so an
|
||||
* edit/delete in the values subpage re-runs the clustering check.
|
||||
*/
|
||||
measurementDiversity: (type: string) =>
|
||||
["measurement-diversity", type] as const,
|
||||
|
||||
/**
|
||||
* v1.11.4 item J — bounded 30-day daily-aggregate read backing the
|
||||
* Insights Trends-row deterministic caption (`trend-descriptor.ts`).
|
||||
* Keyed by the comma-joined measurement type set so each card's slot
|
||||
* caches its own small window (≤ 30 rollup rows). Distinct from
|
||||
* `chartData(...)` so the caption never re-keys when the chart's
|
||||
* internal value-mode / scale / range state changes; it only needs the
|
||||
* raw 30-day series to derive direction + magnitude. Shares the
|
||||
* `["trend-series"]` invalidation prefix below so a measurement write
|
||||
* refreshes the caption in lockstep with the chart.
|
||||
*/
|
||||
insightsTrendSeries: (types: string) => ["trend-series", types] as const,
|
||||
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking surfaces. `cycle()` is the root prefix every
|
||||
* cycle write invalidates through (`cycleDependentKeys` below). The
|
||||
* calendar read is keyed by `(from, to)` so paging the month strip caches
|
||||
* each window independently; the history + profile reads each get their
|
||||
* own slot. A day-log / period write evicts the whole `["cycle"]` prefix
|
||||
* so the calendar, the wheel, the predictions panel, and the history
|
||||
* stats repaint in lockstep.
|
||||
*/
|
||||
cycle: () => ["cycle"] as const,
|
||||
cycleCalendar: (from: string, to: string) =>
|
||||
["cycle", "calendar", from, to] as const,
|
||||
cycleHistory: (limit: number) => ["cycle", "history", limit] as const,
|
||||
cycleProfile: () => ["cycle", "profile"] as const,
|
||||
/** The UNGATED enable/prefs read (`/api/auth/me/cycle-prefs`) — the settings
|
||||
* on-ramp reads this so a non-FEMALE account can opt in before the gated
|
||||
* cycle page is reachable. */
|
||||
cyclePrefs: () => ["cycle", "prefs"] as const,
|
||||
cycleInsights: () => ["cycle", "insights"] as const,
|
||||
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
|
||||
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
|
||||
* merges into the seeded chip grid. */
|
||||
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
|
||||
|
||||
/**
|
||||
* v1.15.7 — in-flight Apple Health `export.zip` import. Keyed by the
|
||||
* `jobId` returned from `POST /api/import/apple-health-export` so the
|
||||
* Export & Import settings area can poll
|
||||
* `GET /api/import/apple-health-export/[jobId]/status` with a
|
||||
* `refetchInterval` while the job is running and stop on a terminal
|
||||
* state. Each upload mints its own key, so a re-upload (or a second
|
||||
* archive) never collides with a previous job's cached status.
|
||||
*/
|
||||
importJobStatus: (jobId: string) =>
|
||||
["import", "apple-health", "status", jobId] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a measurement is created, updated or
|
||||
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
|
||||
*
|
||||
* v1.4.40 W-RSC — `["chart-data"]` prefix now lives in the bundle so a
|
||||
* fresh measurement evicts every per-chart daily-aggregate cache. The
|
||||
* prefix matches every key returned from `queryKeys.chartData(…)` via
|
||||
* TanStack's hierarchical-prefix semantics — adding a measurement now
|
||||
* refreshes the tile strip *and* the chart row in lockstep instead of
|
||||
* leaving the chart row 60 s stale (audit C2).
|
||||
*/
|
||||
export const measurementDependentKeys = [
|
||||
queryKeys.measurements(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
["chart-data"] as const,
|
||||
// v1.8.5 — re-run the diversity-nudge clustering when readings change.
|
||||
["measurement-diversity"] as const,
|
||||
// v1.11.4 item J — refresh the Trends-row deterministic caption series
|
||||
// when a reading changes, in lockstep with the chart row above it.
|
||||
["trend-series"] as const,
|
||||
// v1.11.5 — refresh the last-night hypnogram when sleep rows change.
|
||||
["sleep-night"] as const,
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a mood entry is created, updated or
|
||||
* deleted.
|
||||
*/
|
||||
export const moodDependentKeys = [
|
||||
queryKeys.moodEntries(),
|
||||
queryKeys.moodAnalytics(),
|
||||
queryKeys.moodInsights(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when medications change (CRUD or intake).
|
||||
*
|
||||
* v1.4.40 W-RSC — the dashboard's aggregate compliance chart now
|
||||
* rides the factory under `dashboardMedicationCompliance`. The prefix
|
||||
* `["dashboard-medication-compliance"]` lands in the bundle so an
|
||||
* intake POST refreshes the chart immediately rather than waiting for
|
||||
* `staleTime` (audit L4).
|
||||
*
|
||||
* v1.5.5 D-3 §10 invariant 20 (was C-E2-1 / H-cluster-G) — the
|
||||
* per-medication inline compliance chart used to mount under
|
||||
* `queryKeys.medicationComplianceChart(medicationId)` which expands to
|
||||
* `["compliance-chart-inline", id]`. The prefix `["compliance-chart-inline"]`
|
||||
* lands in the bundle so every detail-page mutation (today's-dose,
|
||||
* Pausieren, end, purge, edit) evicts the inline compliance tile in
|
||||
* one tick. The TanStack hierarchical-prefix semantics catch every
|
||||
* per-medication slot under that prefix.
|
||||
*
|
||||
* `queryKeys.medicationDetail(id)` rides under the
|
||||
* `["medications"]` prefix already so a single medication invalidation
|
||||
* also evicts its detail-page read.
|
||||
*/
|
||||
export const medicationDependentKeys = [
|
||||
queryKeys.medications(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
["dashboard-medication-compliance"] as const,
|
||||
["compliance-chart-inline"] as const,
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when cycle data changes (a day-log capture, a period
|
||||
* boundary, a day-log delete). The `["cycle"]` prefix catches the calendar
|
||||
* windows, the history stats, and the profile read in one tick so the
|
||||
* calendar/wheel and predictions panel never read stale rows after a quick
|
||||
* log. `insightsRoot()` rides along because phase-correlation cards depend
|
||||
* on the same rows.
|
||||
*/
|
||||
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
* `Promise.all(keys.map(...))` everywhere.
|
||||
*
|
||||
* Uses `allSettled` so one transient network failure doesn't abort subsequent
|
||||
* invalidations (cache would otherwise be left half-stale) and so the `void
|
||||
* invalidateKeys(...)` fire-and-forget pattern in delete handlers never
|
||||
* surfaces an unhandled rejection.
|
||||
*/
|
||||
export function invalidateKeys(
|
||||
queryClient: QueryClient,
|
||||
keys: readonly QueryKey[],
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
return Promise.allSettled(
|
||||
keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
|
||||
);
|
||||
}
|
||||
@@ -47,3 +47,19 @@ export const aboutMePutSchema = z.object({
|
||||
});
|
||||
|
||||
export type AboutMePutInput = z.infer<typeof aboutMePutSchema>;
|
||||
|
||||
/**
|
||||
* v1.16.4 — request schema for `POST /api/coach/about-me/adopt`: a
|
||||
* clarifying-question answer the user chose to fold back into the
|
||||
* stored self-context. The server picks the matching structured field
|
||||
* from the question wording, dedupes against the stored text, and
|
||||
* appends encrypted.
|
||||
*/
|
||||
export const aboutMeAdoptSchema = z.object({
|
||||
/** The clarifying question the answer belongs to (field matching). */
|
||||
question: z.string().trim().min(1).max(500),
|
||||
/** The user's answer, as typed into the Coach composer. */
|
||||
answer: z.string().trim().min(1).max(ABOUT_ME_FIELD_MAX_CHARS),
|
||||
});
|
||||
|
||||
export type AboutMeAdoptInput = z.infer<typeof aboutMeAdoptSchema>;
|
||||
@@ -649,6 +649,21 @@ export const intakeSchema = z
|
||||
.describe(
|
||||
"Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.",
|
||||
),
|
||||
/**
|
||||
* v1.16.4 — per-intake dose override. Free text mirroring
|
||||
* `Medication.dose` (max 50 chars). Persisted only on a taken
|
||||
* (non-skipped) write. Absent = the configured medication dose
|
||||
* applies; read paths fall back to it.
|
||||
*/
|
||||
doseTaken: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe(
|
||||
"Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationIntakeRequest",
|
||||
|
||||
Reference in new issue
Block a user