From 7e7e17c68c2e128504d93800d75383a85c06aa61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 00:10:01 +0200 Subject: [PATCH] feat(medications,coach,insights): record the dose you actually took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 7 + messages/de.json | 14 +- messages/en.json | 14 +- messages/es.json | 14 +- messages/fr.json | 14 +- messages/it.json | 14 +- messages/pl.json | 14 +- .../0145_v1164_dose_taken/migration.sql | 10 + prisma/schema.prisma | 5 + src/app/api/coach/about-me/adopt/route.ts | 181 +++++ .../medications/[id]/dose-history/route.ts | 7 + src/app/api/medications/[id]/intake/route.ts | 14 + src/app/api/medications/intake/bulk/route.ts | 11 + src/app/measurements/page.tsx | 20 +- src/app/mood/page.tsx | 20 +- .../coach-panel/coach-conversation.tsx | 42 +- .../coach-panel/self-context-adopt-offer.tsx | 133 ++++ src/components/insights/sub-page-shell.tsx | 28 +- .../dose-history-ledger-compute.ts | 2 + .../medications/dose-history-ledger.tsx | 13 + .../medications/log-intake-dialog.tsx | 59 +- .../medications/use-medication-intake.ts | 10 +- .../ui/pull-to-refresh-indicator.tsx | 70 ++ src/hooks/use-pull-to-refresh.ts | 131 ++++ .../medications/scheduling/dose-history.ts | 6 + src/lib/medications/scheduling/slot-upsert.ts | 15 + src/lib/query-keys.ts | 731 ------------------ src/lib/validations/about-me.ts | 16 + src/lib/validations/medication.ts | 15 + 29 files changed, 886 insertions(+), 744 deletions(-) create mode 100644 prisma/migrations/0145_v1164_dose_taken/migration.sql create mode 100644 src/app/api/coach/about-me/adopt/route.ts create mode 100644 src/components/insights/coach-panel/self-context-adopt-offer.tsx create mode 100644 src/components/ui/pull-to-refresh-indicator.tsx create mode 100644 src/hooks/use-pull-to-refresh.ts delete mode 100644 src/lib/query-keys.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 18bb1967f..ef09fc52e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 941a9dfdb..a28c7b2f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index f8c8b45d7..00f08266f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 97de64056..97956b89a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 7293a6fb9..e739dc4c8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 6946a612c..3a37a3c38 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index a1e74fc03..2b29de7e3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ąć.", diff --git a/prisma/migrations/0145_v1164_dose_taken/migration.sql b/prisma/migrations/0145_v1164_dose_taken/migration.sql new file mode 100644 index 000000000..e8cfd54bd --- /dev/null +++ b/prisma/migrations/0145_v1164_dose_taken/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2f28806ce..31f037ec0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts new file mode 100644 index 000000000..d158bb8b4 --- /dev/null +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -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"; diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 0c583aac6..071bc28c1 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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, })); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index eaa82370f..1283727bc 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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 diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 13c5eb342..d806fdc60 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { 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 { ...(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; diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 06c59ed3f..306379e47 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -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(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 (
+

diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 41890a8cc..f630e8338 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -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 `` footer slot so the @@ -21,6 +25,19 @@ export default function MoodPage() { const [footerEl, setFooterEl] = useState(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 (
+

diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 8e11ec248..b09b065d0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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( + 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 ? ( + setPendingAdopt(null)} + /> + ) : null} setInputValue(`${question}\n`)} + onPick={(question) => { + setActiveChipQuestion(question); + setInputValue(`${question}\n`); + }} /> 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 ( +
+ {settled ? ( +

+

+ ) : ( + <> +
+ ); +} diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index f7b7d2e9e..899a41c77 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -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({
{coachLaunch ? : 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. */} +

{explainerMetric ? ( diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts index 514274974..9bc86f1fc 100644 --- a/src/components/medications/dose-history-ledger-compute.ts +++ b/src/components/medications/dose-history-ledger-compute.ts @@ -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; } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 5d09312f8..f8e423d85 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -659,6 +659,19 @@ function LedgerRowItem({ })}

)} + {/* 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 && ( +

+ {t("medications.detail.verlauf.doseTaken", { + dose: intake.doseTaken, + })} +

+ )} {dueContext && (

(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({ 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); + }} >

)} +
+ + setDoseOverride(e.target.value)} + placeholder={configuredDose} + autoComplete="off" + disabled={skipped} + /> +

+ {t("medications.logIntake.doseHint")} +

+
+