diff --git a/messages/de.json b/messages/de.json index 3c0e66920..1ef4037fb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -157,6 +157,20 @@ "event": { "notFound": "Diesen Eintrag gibt es nicht mehr." } + }, + "medications": { + "notFound": "Dieses Medikament ist nicht mehr vorhanden.", + "ingredients": { + "invalid": "Prüf die Zusammensetzung und versuch es noch einmal.", + "unitMismatch": "Diese Einheit gibt es für den Nährstoff nicht.", + "extract": { + "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", + "fileTooLarge": "Die Datei ist größer als 10 MB.", + "rateLimited": "Zu viele Etiketten gelesen. Versuch es in einer Stunde noch einmal.", + "budgetExceeded": "Dein KI-Budget für heute ist aufgebraucht.", + "failed": "Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "Der letzte Tag kann nicht vor dem ersten liegen.", "generic": "Das wurde nicht gespeichert. Bitte versuch es noch einmal." } + }, + "composition": { + "title": "Zusammensetzung", + "empty": "Trag ein, was eine Tablette oder Kapsel enthält, damit jede genommene Dosis in deine Mikronährstoffe zählt.", + "perUnitNote": "Pro Tablette oder Kapsel; jede genommene Dosis zählt in die Mikronährstoffe des Tages.", + "add": "Hinzufügen", + "edit": "Bearbeiten", + "rowHelper": "Was eine Tablette oder Kapsel enthält, für die Mikronährstoffe.", + "sheetTitle": "Was eine Einheit enthält", + "sheetDescription": "Trag die Mengen pro Tablette oder Kapsel so ein, wie sie auf dem Etikett stehen.", + "nutrientLabel": "Nährstoff", + "amountLabel": "Menge", + "unitLabel": "Einheit", + "choose": "Nährstoff wählen", + "addRow": "Nährstoff hinzufügen", + "removeRow": "{name} entfernen", + "editorEmpty": "Noch kein Nährstoff. Füge einen pro Zeile des Etiketts hinzu.", + "labelPhoto": "Vom Etikettfoto lesen", + "labelReading": "Etikett wird gelesen…", + "labelWaiting": "Der Hintergrunddienst hat das Etikett noch nicht übernommen.", + "labelReviewNote": "Prüf jede Zeile mit dem Etikett, bevor du speicherst.", + "lowConfidence": "Diese Zeile war schwer lesbar; prüf sie mit dem Etikett.", + "unmatchedTitle": "Zeilen ohne passenden Nährstoff, bei Bedarf von Hand eintragen:", + "servingNote": "Auf dem Etikett steht: {note}. Die Mengen sind pro einzelner Einheit angegeben.", + "labelError": "Das Etikett konnte nicht gelesen werden", + "saveError": "Die Zusammensetzung konnte nicht gespeichert werden", + "catalogError": "Die Nährstoffliste konnte nicht geladen werden", + "invalidRow": "Wähl in jeder Zeile einen Nährstoff und trag eine Menge über null ein." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} von {window} Tagen", "footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.", "emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert", - "loadError": "Deine Nährstoffdaten konnten nicht geladen werden" + "loadError": "Deine Nährstoffdaten konnten nicht geladen werden", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Über der tolerierbaren Höchstmenge von {value} {unit} pro Tag ({source}).", + "aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge von {value} {unit} pro Tag für Präparate ({source})." }, - "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel. Die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme." + "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel. Die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme.", + "units": { + "IU": "IE" + }, + "sources": { + "APPLE_HEALTH": "Synchronisiert", + "MANUAL": "Eingetragen", + "SUPPLEMENT": "Präparate" + } }, "daily": { "line": { diff --git a/messages/en.json b/messages/en.json index b038fc09c..b004e89ed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -157,6 +157,20 @@ "event": { "notFound": "This entry no longer exists." } + }, + "medications": { + "notFound": "This medication is no longer available.", + "ingredients": { + "invalid": "Check the composition and try again.", + "unitMismatch": "This unit is not offered for the nutrient.", + "extract": { + "fileType": "Upload a JPEG, PNG, WebP or PDF.", + "fileTooLarge": "The file is larger than 10 MB.", + "rateLimited": "Too many label reads. Try again in an hour.", + "budgetExceeded": "Your AI usage budget for today is reached.", + "failed": "The label could not be read. Try a sharper photo." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "The last day cannot be before the first.", "generic": "That did not save. Please try again." } + }, + "composition": { + "title": "Composition", + "empty": "Add what one tablet or capsule contains to count every dose taken in your micronutrients.", + "perUnitNote": "Per tablet or capsule; every dose taken adds this to the day's micronutrients.", + "add": "Add", + "edit": "Edit", + "rowHelper": "What one tablet or capsule contains, for its micronutrients.", + "sheetTitle": "What one unit contains", + "sheetDescription": "Enter the amounts per tablet or capsule as the label states them.", + "nutrientLabel": "Nutrient", + "amountLabel": "Amount", + "unitLabel": "Unit", + "choose": "Choose a nutrient", + "addRow": "Add a nutrient", + "removeRow": "Remove {name}", + "editorEmpty": "No nutrient yet. Add one per line of the label.", + "labelPhoto": "Read from a label photo", + "labelReading": "Reading the label…", + "labelWaiting": "Still waiting for the background worker to pick this up.", + "labelReviewNote": "Check each line against the label before you save.", + "lowConfidence": "This line was hard to read; check it against the label.", + "unmatchedTitle": "Lines without a matching nutrient, to enter by hand if needed:", + "servingNote": "The label states: {note}. The amounts are shown per single unit.", + "labelError": "The label could not be read", + "saveError": "The composition could not be saved", + "catalogError": "The list of nutrients could not be loaded", + "invalidRow": "Choose a nutrient and enter an amount above zero in every line." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} of {window} days", "footer": "{tracked} of {total} tracked micronutrients have data in this window.", "emptyTitle": "No micronutrient data synced yet", - "loadError": "Your nutrient data could not be loaded" + "loadError": "Your nutrient data could not be loaded", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Above the tolerable upper intake level of {value} {unit} a day ({source}).", + "aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level of {value} {unit} a day for supplements ({source})." }, - "sparseNote": "A low number here usually means sparse logging, not a deficiency. Apple Health day totals reflect what your apps logged, not total dietary intake." + "sparseNote": "A low number here usually means sparse logging, not a deficiency. Apple Health day totals reflect what your apps logged, not total dietary intake.", + "units": { + "IU": "IU" + }, + "sources": { + "APPLE_HEALTH": "Synced", + "MANUAL": "Entered", + "SUPPLEMENT": "Supplements" + } }, "daily": { "line": { diff --git a/messages/es.json b/messages/es.json index 1667ea2e5..485376900 100644 --- a/messages/es.json +++ b/messages/es.json @@ -157,6 +157,20 @@ "event": { "notFound": "Esta entrada ya no existe." } + }, + "medications": { + "notFound": "Este medicamento ya no está disponible.", + "ingredients": { + "invalid": "Revisa la composición e inténtalo de nuevo.", + "unitMismatch": "Esta unidad no está disponible para el nutriente.", + "extract": { + "fileType": "Sube un JPEG, PNG, WebP o PDF.", + "fileTooLarge": "El archivo supera los 10 MB.", + "rateLimited": "Demasiadas lecturas de etiquetas. Vuelve a intentarlo dentro de una hora.", + "budgetExceeded": "Has alcanzado tu presupuesto de IA de hoy.", + "failed": "No se pudo leer la etiqueta. Prueba con una foto más nítida." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "El último día no puede ser anterior al primero.", "generic": "No se ha guardado. Inténtalo de nuevo." } + }, + "composition": { + "title": "Composición", + "empty": "Añade lo que contiene un comprimido o una cápsula para que cada dosis tomada cuente en tus micronutrientes.", + "perUnitNote": "Por comprimido o cápsula; cada dosis tomada se suma a los micronutrientes del día.", + "add": "Añadir", + "edit": "Editar", + "rowHelper": "Lo que contiene un comprimido o una cápsula, para los micronutrientes.", + "sheetTitle": "Lo que contiene una unidad", + "sheetDescription": "Introduce las cantidades por comprimido o cápsula tal como figuran en la etiqueta.", + "nutrientLabel": "Nutriente", + "amountLabel": "Cantidad", + "unitLabel": "Unidad", + "choose": "Elige un nutriente", + "addRow": "Añadir un nutriente", + "removeRow": "Quitar {name}", + "editorEmpty": "Aún no hay nutrientes. Añade uno por cada línea de la etiqueta.", + "labelPhoto": "Leer de una foto de la etiqueta", + "labelReading": "Leyendo la etiqueta…", + "labelWaiting": "El proceso en segundo plano aún no ha empezado con la etiqueta.", + "labelReviewNote": "Comprueba cada línea con la etiqueta antes de guardar.", + "lowConfidence": "Esta línea era difícil de leer; compruébala con la etiqueta.", + "unmatchedTitle": "Líneas sin nutriente correspondiente, para introducir a mano si hace falta:", + "servingNote": "La etiqueta indica: {note}. Las cantidades se muestran por unidad.", + "labelError": "No se pudo leer la etiqueta", + "saveError": "No se pudo guardar la composición", + "catalogError": "No se pudo cargar la lista de nutrientes", + "invalidRow": "Elige un nutriente e introduce una cantidad mayor que cero en cada línea." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} de {window} días", "footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.", "emptyTitle": "Aún no hay datos de micronutrientes sincronizados", - "loadError": "No se pudieron cargar tus datos de nutrientes" + "loadError": "No se pudieron cargar tus datos de nutrientes", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Por encima del nivel máximo tolerable de {value} {unit} al día ({source}).", + "aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable de {value} {unit} al día para suplementos ({source})." }, - "sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total." + "sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total.", + "units": { + "IU": "UI" + }, + "sources": { + "APPLE_HEALTH": "Sincronizado", + "MANUAL": "Introducido", + "SUPPLEMENT": "Suplementos" + } }, "daily": { "line": { diff --git a/messages/fr.json b/messages/fr.json index b7bde5bef..d3e5ab75f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -157,6 +157,20 @@ "event": { "notFound": "Cette entrée n'existe plus." } + }, + "medications": { + "notFound": "Ce médicament n’est plus disponible.", + "ingredients": { + "invalid": "Vérifiez la composition et réessayez.", + "unitMismatch": "Cette unité n’est pas proposée pour ce nutriment.", + "extract": { + "fileType": "Importez un JPEG, PNG, WebP ou PDF.", + "fileTooLarge": "Le fichier dépasse 10 Mo.", + "rateLimited": "Trop de lectures d’étiquettes. Réessayez dans une heure.", + "budgetExceeded": "Votre budget d’IA du jour est atteint.", + "failed": "L’étiquette n’a pas pu être lue. Essayez une photo plus nette." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "Le dernier jour ne peut pas précéder le premier.", "generic": "L’enregistrement a échoué. Réessaie." } + }, + "composition": { + "title": "Composition", + "empty": "Indiquez ce que contient un comprimé ou une gélule pour que chaque prise compte dans vos micronutriments.", + "perUnitNote": "Par comprimé ou gélule ; chaque prise s’ajoute aux micronutriments du jour.", + "add": "Ajouter", + "edit": "Modifier", + "rowHelper": "Ce que contient un comprimé ou une gélule, pour les micronutriments.", + "sheetTitle": "Ce que contient une unité", + "sheetDescription": "Saisissez les quantités par comprimé ou gélule telles qu’indiquées sur l’étiquette.", + "nutrientLabel": "Nutriment", + "amountLabel": "Quantité", + "unitLabel": "Unité", + "choose": "Choisissez un nutriment", + "addRow": "Ajouter un nutriment", + "removeRow": "Retirer {name}", + "editorEmpty": "Aucun nutriment pour l’instant. Ajoutez-en un par ligne de l’étiquette.", + "labelPhoto": "Lire depuis une photo de l’étiquette", + "labelReading": "Lecture de l’étiquette…", + "labelWaiting": "Le service en arrière-plan n’a pas encore pris l’étiquette en charge.", + "labelReviewNote": "Vérifiez chaque ligne avec l’étiquette avant d’enregistrer.", + "lowConfidence": "Cette ligne était difficile à lire ; vérifiez-la avec l’étiquette.", + "unmatchedTitle": "Lignes sans nutriment correspondant, à saisir à la main si besoin :", + "servingNote": "L’étiquette indique : {note}. Les quantités sont données par unité.", + "labelError": "L’étiquette n’a pas pu être lue", + "saveError": "La composition n’a pas pu être enregistrée", + "catalogError": "La liste des nutriments n’a pas pu être chargée", + "invalidRow": "Choisissez un nutriment et saisissez une quantité supérieure à zéro sur chaque ligne." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} sur {window} jours", "footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.", "emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant", - "loadError": "Tes données de nutriments n'ont pas pu être chargées" + "loadError": "Tes données de nutriments n'ont pas pu être chargées", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité de {value} {unit} par jour ({source}).", + "aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité de {value} {unit} par jour pour les compléments ({source})." }, - "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence : les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total." + "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence : les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total.", + "units": { + "IU": "UI" + }, + "sources": { + "APPLE_HEALTH": "Synchronisé", + "MANUAL": "Saisi", + "SUPPLEMENT": "Compléments" + } }, "daily": { "line": { diff --git a/messages/it.json b/messages/it.json index 5bfa94c1e..a861564bd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -157,6 +157,20 @@ "event": { "notFound": "Questa voce non esiste più." } + }, + "medications": { + "notFound": "Questo farmaco non è più disponibile.", + "ingredients": { + "invalid": "Controlla la composizione e riprova.", + "unitMismatch": "Questa unità non è prevista per il nutriente.", + "extract": { + "fileType": "Carica un JPEG, PNG, WebP o PDF.", + "fileTooLarge": "Il file supera i 10 MB.", + "rateLimited": "Troppe letture di etichette. Riprova tra un'ora.", + "budgetExceeded": "Hai raggiunto il budget di IA di oggi.", + "failed": "Non è stato possibile leggere l'etichetta. Prova con una foto più nitida." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "L’ultimo giorno non può precedere il primo.", "generic": "Il salvataggio non è riuscito. Riprova." } + }, + "composition": { + "title": "Composizione", + "empty": "Aggiungi cosa contiene una compressa o capsula, così ogni dose assunta conta nei tuoi micronutrienti.", + "perUnitNote": "Per compressa o capsula; ogni dose assunta si aggiunge ai micronutrienti del giorno.", + "add": "Aggiungi", + "edit": "Modifica", + "rowHelper": "Cosa contiene una compressa o capsula, per i micronutrienti.", + "sheetTitle": "Cosa contiene un'unità", + "sheetDescription": "Inserisci le quantità per compressa o capsula come riportate in etichetta.", + "nutrientLabel": "Nutriente", + "amountLabel": "Quantità", + "unitLabel": "Unità", + "choose": "Scegli un nutriente", + "addRow": "Aggiungi un nutriente", + "removeRow": "Rimuovi {name}", + "editorEmpty": "Nessun nutriente ancora. Aggiungine uno per ogni riga dell'etichetta.", + "labelPhoto": "Leggi da una foto dell'etichetta", + "labelReading": "Lettura dell'etichetta…", + "labelWaiting": "Il servizio in background non ha ancora preso in carico l'etichetta.", + "labelReviewNote": "Controlla ogni riga con l'etichetta prima di salvare.", + "lowConfidence": "Questa riga era difficile da leggere; controllala con l'etichetta.", + "unmatchedTitle": "Righe senza nutriente corrispondente, da inserire a mano se serve:", + "servingNote": "L'etichetta riporta: {note}. Le quantità sono indicate per singola unità.", + "labelError": "Non è stato possibile leggere l'etichetta", + "saveError": "Non è stato possibile salvare la composizione", + "catalogError": "Non è stato possibile caricare l'elenco dei nutrienti", + "invalidRow": "Scegli un nutriente e inserisci una quantità maggiore di zero in ogni riga." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} di {window} giorni", "footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.", "emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato", - "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti" + "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Sopra il livello massimo tollerabile di {value} {unit} al giorno ({source}).", + "aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile di {value} {unit} al giorno per gli integratori ({source})." }, - "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza: i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale." + "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza: i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale.", + "units": { + "IU": "UI" + }, + "sources": { + "APPLE_HEALTH": "Sincronizzato", + "MANUAL": "Inserito", + "SUPPLEMENT": "Integratori" + } }, "daily": { "line": { diff --git a/messages/ko.json b/messages/ko.json index cd52cc2b1..7c8e2aeb6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -157,6 +157,20 @@ "event": { "notFound": "이 기록은 더 이상 없습니다." } + }, + "medications": { + "notFound": "이 약은 더 이상 없어요.", + "ingredients": { + "invalid": "성분을 확인한 뒤 다시 시도하세요.", + "unitMismatch": "이 영양소에는 이 단위를 쓸 수 없어요.", + "extract": { + "fileType": "JPEG, PNG, WebP 또는 PDF를 올려 주세요.", + "fileTooLarge": "파일이 10MB보다 커요.", + "rateLimited": "라벨을 너무 많이 읽었어요. 한 시간 뒤에 다시 시도하세요.", + "budgetExceeded": "오늘의 AI 사용 한도에 도달했어요.", + "failed": "라벨을 읽지 못했어요. 더 선명한 사진으로 다시 시도하세요." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "마지막 날은 첫날보다 앞설 수 없어요.", "generic": "저장하지 못했어요. 다시 시도해 주세요." } + }, + "composition": { + "title": "성분", + "empty": "정제나 캡슐 하나에 든 성분을 입력하면 복용한 모든 용량이 미량영양소에 반영돼요.", + "perUnitNote": "정제 또는 캡슐 1개 기준이며, 복용한 용량마다 그날의 미량영양소에 더해져요.", + "add": "추가", + "edit": "수정", + "rowHelper": "정제나 캡슐 하나에 든 성분으로, 미량영양소에 반영돼요.", + "sheetTitle": "1회 단위에 든 성분", + "sheetDescription": "라벨에 적힌 대로 정제 또는 캡슐 1개당 함량을 입력하세요.", + "nutrientLabel": "영양소", + "amountLabel": "함량", + "unitLabel": "단위", + "choose": "영양소 선택", + "addRow": "영양소 추가", + "removeRow": "{name} 삭제", + "editorEmpty": "아직 영양소가 없어요. 라벨 한 줄마다 하나씩 추가하세요.", + "labelPhoto": "라벨 사진에서 읽기", + "labelReading": "라벨을 읽는 중…", + "labelWaiting": "백그라운드 작업이 아직 라벨을 처리하지 않았어요.", + "labelReviewNote": "저장하기 전에 각 줄을 라벨과 비교해 확인하세요.", + "lowConfidence": "이 줄은 읽기 어려웠어요. 라벨과 비교해 확인하세요.", + "unmatchedTitle": "맞는 영양소가 없는 줄이에요. 필요하면 직접 입력하세요:", + "servingNote": "라벨 표기: {note}. 함량은 1개 기준으로 표시돼요.", + "labelError": "라벨을 읽지 못했어요", + "saveError": "성분을 저장하지 못했어요", + "catalogError": "영양소 목록을 불러오지 못했어요", + "invalidRow": "모든 줄에서 영양소를 고르고 0보다 큰 함량을 입력하세요." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{window}일 중 {days}일", "footer": "이 기간에 데이터가 있는 미량영양소는 추적 중인 {total}개 중 {tracked}개예요.", "emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요", - "loadError": "영양소 데이터를 불러오지 못했어요" + "loadError": "영양소 데이터를 불러오지 못했어요", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).", + "aboveUpperLimitSupplemental": "보충제로 섭취한 양이 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source})." }, - "sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요." + "sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요.", + "units": { + "IU": "IU" + }, + "sources": { + "APPLE_HEALTH": "동기화됨", + "MANUAL": "직접 입력", + "SUPPLEMENT": "보충제" + } }, "daily": { "line": { diff --git a/messages/pl.json b/messages/pl.json index 2b9a15871..0a14e73f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -157,6 +157,20 @@ "event": { "notFound": "Ten wpis już nie istnieje." } + }, + "medications": { + "notFound": "Ten lek nie jest już dostępny.", + "ingredients": { + "invalid": "Sprawdź skład i spróbuj ponownie.", + "unitMismatch": "Ta jednostka nie jest dostępna dla tego składnika.", + "extract": { + "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", + "fileTooLarge": "Plik jest większy niż 10 MB.", + "rateLimited": "Za dużo odczytów etykiet. Spróbuj ponownie za godzinę.", + "budgetExceeded": "Dzisiejszy budżet AI został wykorzystany.", + "failed": "Nie udało się odczytać etykiety. Spróbuj ostrzejszego zdjęcia." + } + } } }, "doctorReport": { @@ -2402,6 +2416,34 @@ "invalidRange": "Ostatni dzień nie może być przed pierwszym.", "generic": "Nie udało się zapisać. Spróbuj ponownie." } + }, + "composition": { + "title": "Skład", + "empty": "Dodaj, co zawiera jedna tabletka lub kapsułka, aby każda przyjęta dawka liczyła się do twoich mikroskładników.", + "perUnitNote": "Na tabletkę lub kapsułkę; każda przyjęta dawka dolicza się do mikroskładników dnia.", + "add": "Dodaj", + "edit": "Edytuj", + "rowHelper": "Co zawiera jedna tabletka lub kapsułka, dla mikroskładników.", + "sheetTitle": "Co zawiera jedna jednostka", + "sheetDescription": "Wpisz ilości na tabletkę lub kapsułkę tak, jak podano na etykiecie.", + "nutrientLabel": "Składnik", + "amountLabel": "Ilość", + "unitLabel": "Jednostka", + "choose": "Wybierz składnik", + "addRow": "Dodaj składnik", + "removeRow": "Usuń {name}", + "editorEmpty": "Brak składników. Dodaj jeden na każdą linię etykiety.", + "labelPhoto": "Odczytaj ze zdjęcia etykiety", + "labelReading": "Odczytywanie etykiety…", + "labelWaiting": "Usługa w tle jeszcze nie zajęła się etykietą.", + "labelReviewNote": "Sprawdź każdą linię z etykietą przed zapisaniem.", + "lowConfidence": "Ta linia była trudna do odczytania; sprawdź ją z etykietą.", + "unmatchedTitle": "Linie bez pasującego składnika, w razie potrzeby wpisz je ręcznie:", + "servingNote": "Na etykiecie: {note}. Ilości podano na jedną jednostkę.", + "labelError": "Nie udało się odczytać etykiety", + "saveError": "Nie udało się zapisać składu", + "catalogError": "Nie udało się wczytać listy składników", + "invalidRow": "W każdej linii wybierz składnik i wpisz ilość większą od zera." } }, "charts": { @@ -11398,9 +11440,20 @@ "rowMetaNoRef": "{days} z {window} dni", "footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.", "emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach", - "loadError": "Nie udało się wczytać danych o składnikach odżywczych" + "loadError": "Nie udało się wczytać danych o składnikach odżywczych", + "sourcePart": "{source} {value} {unit}", + "aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia {value} {unit} dziennie ({source}).", + "aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom {value} {unit} dziennie dla suplementów ({source})." }, - "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór. Dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie." + "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór. Dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie.", + "units": { + "IU": "j.m." + }, + "sources": { + "APPLE_HEALTH": "Zsynchronizowane", + "MANUAL": "Wpisane", + "SUPPLEMENT": "Suplementy" + } }, "daily": { "line": { diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 998b15c91..70bbaf973 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Leaf } from "lucide-react"; +import { Info, Leaf } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; @@ -19,14 +19,16 @@ import { resolveNutrientReference, type NutrientCode, } from "@/lib/nutrients/catalog"; -import type { NutrientIntakeOverview } from "./types"; +import type { + NutrientAmountBySource, + NutrientIntakeOverview, + NutrientOverviewRow, +} from "./types"; +import { nutrientUnitLabel } from "./unit-label"; const WINDOW_DAYS = 30; -/** Wire `ug` renders as µg; mg / ml pass through. */ -const UNIT_LABELS: Record = { mg: "mg", ug: "µg", ml: "ml" }; - -/** The 24 vitamin/mineral codes — water + caffeine ride their own cards. */ +/** The vitamin/mineral codes — water + caffeine ride their own cards. */ const MICRONUTRIENT_CODES = NUTRIENT_CODES.filter( (code) => code !== "water" && code !== "caffeine", ); @@ -38,6 +40,102 @@ function formatAmount( return fmt.number(Math.round(amount * 10) / 10, 1); } +/** The sources in the order the split reads, each with its bar colour. */ +const SOURCES = [ + { key: "APPLE_HEALTH", color: "var(--chart-1)" }, + { key: "MANUAL", color: "var(--chart-2)" }, + { key: "SUPPLEMENT", color: "var(--chart-3)" }, +] as const; + +/** + * v1.43 — the latest day split by source: a thin bar and a one-line legend. + * Shown only when the day has more than one source or a supplement share, + * because a single synced total has nothing to split. + */ +function SourceSplit({ + bySource, + unitLabel, +}: { + bySource: NutrientAmountBySource; + unitLabel: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const parts = SOURCES.filter((s) => (bySource[s.key] ?? 0) > 0); + const total = parts.reduce((sum, s) => sum + (bySource[s.key] ?? 0), 0); + if (total <= 0 || (parts.length < 2 && !bySource.SUPPLEMENT)) return null; + const legend = parts + .map((s) => + t("nutrients.micronutrients.sourcePart", { + source: t(`nutrients.sources.${s.key}`), + value: formatAmount(fmt, bySource[s.key] ?? 0), + unit: unitLabel, + }), + ) + .join(", "); + return ( +
+
+ {parts.map((s) => ( + + ))} +
+

{legend}

+
+ ); +} + +/** + * v1.43 — the calm note the server's upper-limit verdict earns: the level, + * whether it covers the supplement share only, and where it comes from. + */ +function UpperLimitNote({ + row, + unitLabel, +}: { + row: NutrientOverviewRow; + unitLabel: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const limit = row.upperLimit; + if (!limit || row.latestAboveUpperLimit !== true) return null; + return ( +

+

+ ); +} + /** * v1.29 — micronutrients card on `/insights/nutrients`. * @@ -105,34 +203,43 @@ export function MicronutrientsCard() { row.nutrient as NutrientCode, sex, ); - const unitLabel = UNIT_LABELS[row.unit] ?? row.unit; + const unitLabel = nutrientUnitLabel(row.unit, t); return (
  • - - {t(`nutrients.names.${row.nutrient}`)} - - - - {formatAmount(fmt, row.latestAmount)} {unitLabel} +
    + + {t(`nutrients.names.${row.nutrient}`)} - - {reference - ? t("nutrients.micronutrients.rowMeta", { - days: row.daysWithData, - window: WINDOW_DAYS, - value: formatAmount(fmt, reference.value), - unit: unitLabel, - }) - : t("nutrients.micronutrients.rowMetaNoRef", { - days: row.daysWithData, - window: WINDOW_DAYS, - })} + + + {formatAmount(fmt, row.latestAmount)} {unitLabel} + + + {reference + ? t("nutrients.micronutrients.rowMeta", { + days: row.daysWithData, + window: WINDOW_DAYS, + value: formatAmount(fmt, reference.value), + unit: unitLabel, + }) + : t("nutrients.micronutrients.rowMetaNoRef", { + days: row.daysWithData, + window: WINDOW_DAYS, + })} + - +
    + {row.latestBySource && ( + + )} +
  • ); })} diff --git a/src/components/insights/nutrients/types.ts b/src/components/insights/nutrients/types.ts index dcae0a1c6..ca7848aa5 100644 --- a/src/components/insights/nutrients/types.ts +++ b/src/components/insights/nutrients/types.ts @@ -17,8 +17,27 @@ export interface NutrientDailySeries { nutrient: string; unit: string; windowDays: number; - days: Array<{ day: string; amount: number }>; + days: Array<{ + day: string; + amount: number; + bySource?: NutrientAmountBySource; + aboveUpperLimit?: boolean | null; + }>; reference: ResolvedNutrientReferenceDto | null; + upperLimit?: NutrientUpperLimitDto | null; +} + +/** v1.43 — an amount split by source; a source with nothing is absent. */ +export type NutrientAmountBySource = Partial< + Record<"APPLE_HEALTH" | "MANUAL" | "SUPPLEMENT", number> +>; + +/** v1.43 — the tolerable upper intake level the server judged against. */ +export interface NutrientUpperLimitDto { + value: number; + unit: string; + scope: "total" | "supplemental"; + source: string; } export interface NutrientOverviewRow { @@ -27,6 +46,10 @@ export interface NutrientOverviewRow { latestDay: string; latestAmount: number; daysWithData: number; + /** v1.43 — absent on an older server. */ + latestBySource?: NutrientAmountBySource; + upperLimit?: NutrientUpperLimitDto | null; + latestAboveUpperLimit?: boolean | null; } /** diff --git a/src/components/insights/nutrients/unit-label.ts b/src/components/insights/nutrients/unit-label.ts new file mode 100644 index 000000000..650e9232f --- /dev/null +++ b/src/components/insights/nutrients/unit-label.ts @@ -0,0 +1,14 @@ +/** + * How a nutrient unit code from the server reads on screen. The wire spells + * microgram `ug` (ASCII on purpose) and the international unit `IU`, which + * German labels print as IE, French, Spanish and Italian ones as UI and + * Polish ones as j.m.; every other code is already its symbol. + */ +export function nutrientUnitLabel( + unit: string, + t: (key: string) => string, +): string { + if (unit === "ug") return "µg"; + if (unit === "IU") return t("nutrients.units.IU"); + return unit; +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 69f9cb55f..c4798e15b 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -33,6 +33,13 @@ import type { MedicationCourseWire } from "@/components/medications/course-fields"; import { CoursesRow } from "@/components/medications/sections/courses-section"; +import { + COMPOSITION_CATEGORIES, + CompositionRow, + CompositionSection, +} from "@/components/medications/sections/composition-section"; +import type { MedicationIngredientDto } from "@/components/medications/sections/use-composition"; +import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useCallback, @@ -184,6 +191,8 @@ export interface MedicationDetailSnapshot { canStartCourse?: boolean; recordedSchedules?: ScheduleSnapshot[]; schedules: ScheduleSnapshot[]; + /** v1.43 — the composition per unit, as the server delivered it. */ + ingredients?: MedicationIngredientDto[]; } // Slugs are ASCII / locale-independent so the URL stays stable across @@ -315,6 +324,15 @@ export function MedicationDetailTabs({ const oneShot = medication.oneShot === true; const asNeeded = medication.asNeeded === true; const isGlp1 = !oneShot && medication.treatmentClass === "GLP1"; + // v1.43 — the composition: offered up front for a vitamin or a supplement, + // shown for any medication that has one, and possible for every other one + // from the lifecycle group. Behind the opt-in nutrients module. + const { user: account } = useAuth(); + const nutrientsOn = account?.modules?.nutrients === true; + const ingredients = medication.ingredients ?? []; + const showComposition = + nutrientsOn && + (COMPOSITION_CATEGORIES.has(medication.category) || ingredients.length > 0); const isInjectable = medication.deliveryForm === "INJECTION"; // v1.28 — the "Wirkung" tab appears only when the medication resolves to a @@ -660,6 +678,14 @@ export function MedicationDetailTabs({ )} )} + {showComposition && ( + + )} {/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/ @@ -941,6 +967,9 @@ export function MedicationDetailTabs({ {t("medications.detail.erweitert.editor.button")} + {nutrientsOn && !showComposition && ( + + )} {isGlp1 && (
    = new Set([ + "VITAMIN", + "SUPPLEMENT", +]); + +/** Water is a day total, never a supplement ingredient. */ +const NOT_AN_INGREDIENT = "water"; + +interface EditorRow { + key: number; + nutrient: string; + amount: string; + unit: string; + /** From a label proposal the model was unsure of. */ + check: boolean; +} + +function formatAmount( + fmt: { number: (v: number, d?: number) => string }, + value: number, +): string { + // Up to three decimals: 0.025 mg of a trace element must not read as 0. + return fmt.number(Math.round(value * 1000) / 1000, 3); +} + +/** One composition line: "25 µg (1,000 IU)". */ +export function IngredientAmount({ + ingredient, +}: { + ingredient: MedicationIngredientDto; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + return ( + + {formatAmount(fmt, ingredient.amountPerUnit)}{" "} + {nutrientUnitLabel(ingredient.unit, t)} + {ingredient.enteredAmount !== null && ingredient.enteredUnit !== null && ( + + {" ("} + {formatAmount(fmt, ingredient.enteredAmount)}{" "} + {nutrientUnitLabel(ingredient.enteredUnit, t)} + {")"} + + )} + + ); +} + +function rowsFromIngredients( + ingredients: ReadonlyArray, +): EditorRow[] { + return ingredients.map((ingredient, index) => ({ + key: index, + nutrient: ingredient.nutrient, + amount: String(ingredient.enteredAmount ?? ingredient.amountPerUnit), + unit: ingredient.enteredUnit ?? ingredient.unit, + check: false, + })); +} + +function CompositionEditor({ + medicationId, + ingredients, + open, + onOpenChange, + labelReadable, +}: { + medicationId: string; + ingredients: ReadonlyArray; + open: boolean; + onOpenChange: (open: boolean) => void; + labelReadable: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const formId = useId(); + const catalog = useNutrientCatalog(open); + const entries = (catalog.data?.entries ?? []).filter( + (entry) => entry.code !== NOT_AN_INGREDIENT, + ); + const byCode = new Map(entries.map((entry) => [entry.code, entry])); + + const [rows, setRows] = useState(() => + rowsFromIngredients(ingredients), + ); + const [nextKey, setNextKey] = useState(ingredients.length); + const [source, setSource] = useState<"MANUAL" | "LABEL_AI">("MANUAL"); + const [proposal, setProposal] = useState( + null, + ); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [reading, setReading] = useState(false); + const fileInput = useRef(null); + const runPhase = useAiRunPhase(); + + const chosen = new Set(rows.map((row) => row.nutrient).filter(Boolean)); + + function update(key: number, patch: Partial) { + setRows((current) => + current.map((row) => (row.key === key ? { ...row, ...patch } : row)), + ); + } + + function addRow() { + setRows((current) => [ + ...current, + { key: nextKey, nutrient: "", amount: "", unit: "", check: false }, + ]); + setNextKey((key) => key + 1); + } + + function chooseNutrient(key: number, code: string) { + const entry = byCode.get(code); + update(key, { nutrient: code, unit: entry?.unit ?? "", check: false }); + } + + async function readLabel(file: File) { + setReading(true); + setError(null); + try { + const read = await readSupplementLabel(queryClient, medicationId, file, { + onQueued: runPhase.onQueued, + onProgress: runPhase.onProgress, + }); + setProposal(read); + setSource("LABEL_AI"); + setRows( + read.ingredients.map((line, index) => ({ + key: nextKey + index, + nutrient: line.nutrient, + amount: String(line.amount), + unit: line.unit, + check: line.confidence === "low", + })), + ); + setNextKey((key) => key + read.ingredients.length); + } catch (err) { + showErrorToast( + localizedApiError(err, t, "medications.composition.labelError"), + { cause: err }, + ); + } finally { + setReading(false); + runPhase.reset(); + } + } + + async function save() { + if (saving) return; + const parsed = rows.map((row) => ({ + nutrient: row.nutrient, + amount: parseDecimalEntry(row.amount), + unit: row.unit, + })); + if ( + parsed.some( + (row) => !row.nutrient || !row.unit || !(row.amount && row.amount > 0), + ) + ) { + setError(t("medications.composition.invalidRow")); + return; + } + setSaving(true); + setError(null); + try { + await saveComposition( + queryClient, + medicationId, + parsed.map((row) => ({ + nutrient: row.nutrient, + amount: row.amount as number, + unit: row.unit, + })), + source, + ); + onOpenChange(false); + } catch (err) { + setError(localizedApiError(err, t, "medications.composition.saveError")); + } finally { + setSaving(false); + } + } + + const busy = saving || reading; + + return ( + + + + + } + > +
    + {labelReadable && ( +
    + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void readLabel(file); + }} + /> + + {reading && runPhase.phase === "waitingForWorker" && ( +

    + {t("medications.composition.labelWaiting")} +

    + )} +
    + )} + + {proposal && ( +
    +

    {t("medications.composition.labelReviewNote")}

    + {proposal.servingNote && ( +

    + {t("medications.composition.servingNote", { + note: proposal.servingNote, + })} +

    + )} + {proposal.unmatched.length > 0 && ( +
    +

    + {t("medications.composition.unmatchedTitle")} +

    +
      + {proposal.unmatched.map((line, index) => ( +
    • {line.printedText}
    • + ))} +
    +
    + )} +
    + )} + + {rows.length === 0 && ( +

    + {t("medications.composition.editorEmpty")} +

    + )} + +
      + {rows.map((row) => { + const entry = byCode.get(row.nutrient); + const name = entry?.label ?? row.nutrient; + return ( +
    • +
      + + + chooseNutrient(row.key, event.target.value) + } + disabled={busy} + > + + {entries.map((option: NutrientCatalogEntryDto) => ( + + ))} + +
      +
      + + + update(row.key, { + amount: event.target.value, + check: false, + }) + } + aria-invalid={row.check || undefined} + disabled={busy} + /> +
      +
      + + + update(row.key, { unit: event.target.value }) + } + disabled={busy || !entry} + > + {(entry?.inputUnits ?? []).map((option) => ( + + ))} + +
      + + {row.check && ( +

      + {t("medications.composition.lowConfidence")} +

      + )} +
    • + ); + })} +
    + + + + +
    +
    + ); +} + +/** + * The composition section on the Übersicht tab. Rendered for a vitamin or a + * supplement, and for any medication that already has a composition. + */ +export function CompositionSection({ + medicationId, + ingredients, + canEdit, + ownRecord, +}: { + medicationId: string; + ingredients: ReadonlyArray; + canEdit: boolean; + /** Reading a label is provider work, never offered inside another record. */ + ownRecord: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const labelCapability = useAiCapability("supplementLabel"); + + return ( + setOpen(true)} + data-slot="medication-composition-edit" + > + {ingredients.length > 0 ? ( + + ); +} + +/** + * The same editor for any other medication, as a row in the Erweitert tab's + * lifecycle group: the composition is possible for every medication and + * offered up front only for vitamins and supplements. + */ +export function CompositionRow({ + medicationId, + ownRecord, +}: { + medicationId: string; + ownRecord: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const labelCapability = useAiCapability("supplementLabel"); + return ( +
    +
    +

    + {t("medications.composition.title")} +

    +

    + {t("medications.composition.rowHelper")} +

    +
    + + {open && ( + + )} +
    + ); +} diff --git a/src/components/medications/sections/use-composition.ts b/src/components/medications/sections/use-composition.ts new file mode 100644 index 000000000..11cce7d3d --- /dev/null +++ b/src/components/medications/sections/use-composition.ts @@ -0,0 +1,97 @@ +"use client"; + +/** + * v1.43 — client side of a supplement's composition: the nutrient catalogue + * the form is built from, saving the composition, and reading a label photo + * into a proposal. + * + * Every unit, factor and upper limit comes from the server's catalogue; the + * form converts nothing. The label read is a background run: the route + * answers 202 and the proposal comes from `GET /api/ai-runs/{id}`. + */ +import { useQuery, type QueryClient } from "@tanstack/react-query"; + +import { resolveAiRun, type WaitForAiRunOptions } from "@/hooks/use-ai-run"; +import { apiFetch, apiGet, apiPut } from "@/lib/api/api-fetch"; +import type { + AiRunAccepted, + SupplementLabelExtractRunResult, +} from "@/lib/documents/ai-runs/types"; +import type { MedicationIngredientDto } from "@/lib/medications/ingredients/composition"; +import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys"; + +export type { MedicationIngredientDto }; +export type SupplementLabelProposal = SupplementLabelExtractRunResult; + +export interface NutrientCatalogEntryDto { + code: string; + unit: string; + labelKey: string; + label: string; + syncable: boolean; + inputUnits: Array<{ unit: string; factorToCanonical: number }>; + upperLimit: { + value: number; + unit: string; + scope: "total" | "supplemental"; + source: string; + } | null; +} + +/** The catalogue; only read while the composition form can open. */ +export function useNutrientCatalog(enabled: boolean) { + return useQuery({ + queryKey: queryKeys.nutrientCatalog(), + queryFn: () => + apiGet<{ entries: NutrientCatalogEntryDto[] }>("/api/nutrients/catalog"), + enabled, + staleTime: 60 * 60 * 1000, + }); +} + +export interface CompositionRowInput { + nutrient: string; + amount: number; + unit: string; +} + +/** Replace the composition and refresh every read it feeds. */ +export async function saveComposition( + queryClient: QueryClient, + medicationId: string, + ingredients: CompositionRowInput[], + source: "MANUAL" | "LABEL_AI", +): Promise { + await apiPut( + `/api/medications/${encodeURIComponent(medicationId)}/ingredients`, + { + ingredients, + source, + }, + ); + await invalidateMedicationReads(queryClient); + await queryClient.invalidateQueries({ queryKey: queryKeys.nutrientsRoot() }); +} + +/** How long the queuing request may take: the upload and the enqueue, no model. */ +const LABEL_QUEUE_REQUEST_TIMEOUT_MS = 60_000; + +/** Upload a label photo and follow the background run to its proposal. */ +export async function readSupplementLabel( + queryClient: QueryClient, + medicationId: string, + file: File, + progress: WaitForAiRunOptions & { onQueued?: () => void } = {}, +): Promise { + const form = new FormData(); + form.append("file", file); + const accepted = await apiFetch( + `/api/medications/${encodeURIComponent(medicationId)}/ingredients/extract`, + { + method: "POST", + body: form, + signal: AbortSignal.timeout(LABEL_QUEUE_REQUEST_TIMEOUT_MS), + }, + ); + return resolveAiRun(queryClient, accepted, progress); +} diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 3a49c9032..5a3d4f94c 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -250,6 +250,9 @@ export const medicationDependentKeys = [ // write to this one evicts both roots. queryKeys.dayRoot(), queryKeys.timelineRoot(), + // v1.43 — a taken supplement dose adds to the day's micronutrients + // (`source = SUPPLEMENT`), so a dose write evicts the nutrient reads. + queryKeys.nutrientsRoot(), ]; /** diff --git a/src/lib/query-keys/nutrients.ts b/src/lib/query-keys/nutrients.ts index c7c6fd05c..b0fffd7cf 100644 --- a/src/lib/query-keys/nutrients.ts +++ b/src/lib/query-keys/nutrients.ts @@ -16,4 +16,10 @@ export const nutrientKeys = { */ nutrientDaily: (nutrient: string, days: number) => ["nutrients", "daily", nutrient, days] as const, + /** + * v1.43 — the nutrient catalogue from `GET /api/nutrients/catalog`: codes, + * labels, input units and upper limits. The composition form is built from + * it. + */ + nutrientCatalog: () => ["nutrients", "catalog"] as const, };