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:
Marc-André Bombeck committed 2026-06-11 00:10:01 +02:00
1 parent ce3b4c70c8
commit 7e7e17c68c
29 files changed
+886 -744

No files matched your search

+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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.
+181
View File
@@ -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;
+19 -1
View File
@@ -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
View File
@@ -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>
);
}
+27 -1
View File
@@ -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>
);
}
+131
View File
@@ -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;
-731
View File
@@ -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 })),
);
}
+16
View File
@@ -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>;
+15
View File
@@ -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",