feat(web): edit a supplement's composition, read it from a label photo, and show nutrient sources with the upper limit

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:48:43 +02:00
1 parent 9d32a8acb0
commit 01fef691fa
15 files changed
+1293 -42

No files matched your search

+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
+55 -2
View File
@@ -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": {
@@ -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<string, string> = { 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 (
<div className="space-y-1" data-slot="nutrients-source-split">
<div
className="bg-muted flex h-1.5 w-full overflow-hidden rounded-full"
role="img"
aria-label={legend}
>
{parts.map((s) => (
<span
key={s.key}
className="h-full"
style={{
width: `${((bySource[s.key] ?? 0) / total) * 100}%`,
backgroundColor: s.color,
}}
/>
))}
</div>
<p className="text-muted-foreground text-xs tabular-nums">{legend}</p>
</div>
);
}
/**
* 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 (
<p
className="flex items-start gap-1.5 text-xs"
data-slot="nutrients-upper-limit-note"
>
<Info
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<span>
{t(
limit.scope === "supplemental"
? "nutrients.micronutrients.aboveUpperLimitSupplemental"
: "nutrients.micronutrients.aboveUpperLimit",
{
value: formatAmount(fmt, limit.value),
unit: unitLabel,
source: limit.source,
},
)}
</span>
</p>
);
}
/**
* 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 (
<li
key={row.nutrient}
data-slot="nutrients-micronutrient-row"
className="flex items-baseline justify-between gap-3 p-2"
className="space-y-2 p-2"
>
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="flex flex-col items-end gap-0.5 text-right">
<span className="text-sm font-medium tabular-nums">
{formatAmount(fmt, row.latestAmount)} {unitLabel}
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{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,
})}
<span className="flex flex-col items-end gap-0.5 text-right">
<span className="text-sm font-medium tabular-nums">
{formatAmount(fmt, row.latestAmount)} {unitLabel}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{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,
})}
</span>
</span>
</span>
</div>
{row.latestBySource && (
<SourceSplit
bySource={row.latestBySource}
unitLabel={unitLabel}
/>
)}
<UpperLimitNote row={row} unitLabel={unitLabel} />
</li>
);
})}
+24 -1
View File
@@ -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;
}
/**
@@ -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;
}
@@ -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({
)}
</MedicationDetailSection>
)}
{showComposition && (
<CompositionSection
medicationId={id}
ingredients={ingredients}
canEdit={canManageMedications}
ownRecord={!inSharedRecord}
/>
)}
</TabsContent>
{/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/
@@ -941,6 +967,9 @@ export function MedicationDetailTabs({
{t("medications.detail.erweitert.editor.button")}
</Button>
</div>
{nutrientsOn && !showComposition && (
<CompositionRow medicationId={id} ownRecord={!inSharedRecord} />
)}
{isGlp1 && (
<div className="py-3">
<PhasesRow
@@ -0,0 +1,601 @@
"use client";
/**
* v1.43 — a supplement's composition on the medication detail page: what one
* tablet or capsule contains, and the editor that sets it.
*
* The section shows the composition as the server delivered it (labels in
* the account language, canonical amounts, the amount as entered beside a
* converted one). The editor is a ResponsiveSheet with one row per nutrient,
* built from `GET /api/nutrients/catalog`: the units offered are the code's
* input units, nothing is converted here. A label photo is offered only when
* the server says the `supplementLabel` capability is available; its
* proposal fills the rows for the person to check, and nothing is stored
* until they save.
*/
import { useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Camera, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { nutrientUnitLabel } from "@/components/insights/nutrients/unit-label";
import { FieldError } from "@/components/forms/field-error";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAiRunPhase } from "@/hooks/use-ai-run";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
import {
readSupplementLabel,
saveComposition,
useNutrientCatalog,
type MedicationIngredientDto,
type NutrientCatalogEntryDto,
type SupplementLabelProposal,
} from "./use-composition";
/** The categories the composition is offered for without being asked. */
export const COMPOSITION_CATEGORIES: ReadonlySet<string> = 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 (
<span className="tabular-nums">
{formatAmount(fmt, ingredient.amountPerUnit)}{" "}
{nutrientUnitLabel(ingredient.unit, t)}
{ingredient.enteredAmount !== null && ingredient.enteredUnit !== null && (
<span className="text-muted-foreground">
{" ("}
{formatAmount(fmt, ingredient.enteredAmount)}{" "}
{nutrientUnitLabel(ingredient.enteredUnit, t)}
{")"}
</span>
)}
</span>
);
}
function rowsFromIngredients(
ingredients: ReadonlyArray<MedicationIngredientDto>,
): 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<MedicationIngredientDto>;
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<EditorRow[]>(() =>
rowsFromIngredients(ingredients),
);
const [nextKey, setNextKey] = useState(ingredients.length);
const [source, setSource] = useState<"MANUAL" | "LABEL_AI">("MANUAL");
const [proposal, setProposal] = useState<SupplementLabelProposal | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [reading, setReading] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const runPhase = useAiRunPhase();
const chosen = new Set(rows.map((row) => row.nutrient).filter(Boolean));
function update(key: number, patch: Partial<EditorRow>) {
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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.composition.sheetTitle")}
description={t("medications.composition.sheetDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => void save()}
disabled={busy || catalog.isLoading}
data-slot="medication-composition-save"
>
{saving && (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4" data-slot="medication-composition-editor">
{labelReadable && (
<div className="space-y-2">
<input
ref={fileInput}
type="file"
// No HEIC: a phone browser then hands over a JPEG.
accept="image/jpeg,image/png,image/webp,application/pdf"
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void readLabel(file);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => fileInput.current?.click()}
data-slot="medication-composition-label-photo"
>
{reading ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Camera aria-hidden="true" className="size-4" />
)}
{reading
? t("medications.composition.labelReading")
: t("medications.composition.labelPhoto")}
</Button>
{reading && runPhase.phase === "waitingForWorker" && (
<p className="text-muted-foreground text-xs" role="status">
{t("medications.composition.labelWaiting")}
</p>
)}
</div>
)}
{proposal && (
<div
className="space-y-1 text-sm"
data-slot="medication-composition-proposal"
>
<p>{t("medications.composition.labelReviewNote")}</p>
{proposal.servingNote && (
<p className="text-muted-foreground text-xs">
{t("medications.composition.servingNote", {
note: proposal.servingNote,
})}
</p>
)}
{proposal.unmatched.length > 0 && (
<div className="text-xs">
<p className="text-muted-foreground">
{t("medications.composition.unmatchedTitle")}
</p>
<ul className="list-disc ps-4">
{proposal.unmatched.map((line, index) => (
<li key={index}>{line.printedText}</li>
))}
</ul>
</div>
)}
</div>
)}
{rows.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("medications.composition.editorEmpty")}
</p>
)}
<ul className="space-y-4" role="list">
{rows.map((row) => {
const entry = byCode.get(row.nutrient);
const name = entry?.label ?? row.nutrient;
return (
<li
key={row.key}
className="grid grid-cols-[1fr_auto] items-end gap-x-3 gap-y-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
data-slot="medication-composition-row"
>
<div className="col-span-2 space-y-2 sm:col-span-1">
<Label htmlFor={`${formId}-nutrient-${row.key}`}>
{t("medications.composition.nutrientLabel")}
</Label>
<NativeSelect
id={`${formId}-nutrient-${row.key}`}
value={row.nutrient}
onChange={(event) =>
chooseNutrient(row.key, event.target.value)
}
disabled={busy}
>
<option value="" disabled>
{t("medications.composition.choose")}
</option>
{entries.map((option: NutrientCatalogEntryDto) => (
<option
key={option.code}
value={option.code}
disabled={
option.code !== row.nutrient &&
chosen.has(option.code)
}
>
{option.label}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-2">
<Label htmlFor={`${formId}-amount-${row.key}`}>
{t("medications.composition.amountLabel")}
</Label>
<Input
id={`${formId}-amount-${row.key}`}
inputMode="decimal"
value={row.amount}
onChange={(event) =>
update(row.key, {
amount: event.target.value,
check: false,
})
}
aria-invalid={row.check || undefined}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${formId}-unit-${row.key}`}>
{t("medications.composition.unitLabel")}
</Label>
<NativeSelect
id={`${formId}-unit-${row.key}`}
value={row.unit}
onChange={(event) =>
update(row.key, { unit: event.target.value })
}
disabled={busy || !entry}
>
{(entry?.inputUnits ?? []).map((option) => (
<option key={option.unit} value={option.unit}>
{nutrientUnitLabel(option.unit, t)}
</option>
))}
</NativeSelect>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() =>
setRows((current) =>
current.filter((candidate) => candidate.key !== row.key),
)
}
disabled={busy}
aria-label={t("medications.composition.removeRow", {
name,
})}
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
{row.check && (
<p className="text-warning col-span-full text-xs">
{t("medications.composition.lowConfidence")}
</p>
)}
</li>
);
})}
</ul>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={addRow}
disabled={busy || catalog.isLoading}
data-slot="medication-composition-add-row"
>
<Plus aria-hidden="true" className="size-4" />
{t("medications.composition.addRow")}
</Button>
<FieldError
id={`${formId}-error`}
message={
error ??
(catalog.isError
? t("medications.composition.catalogError")
: undefined)
}
/>
</div>
</ResponsiveSheet>
);
}
/**
* 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<MedicationIngredientDto>;
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 (
<MedicationDetailSection
titleId="medication-composition-heading"
title={t("medications.composition.title")}
dataSlot="medication-composition"
headerExtras={
canEdit ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setOpen(true)}
data-slot="medication-composition-edit"
>
{ingredients.length > 0 ? (
<Pencil aria-hidden="true" className="size-4" />
) : (
<Plus aria-hidden="true" className="size-4" />
)}
{ingredients.length > 0
? t("medications.composition.edit")
: t("medications.composition.add")}
</Button>
) : undefined
}
>
{ingredients.length === 0 ? (
<p className="text-muted-foreground">
{t("medications.composition.empty")}
</p>
) : (
<div className="space-y-3">
<ul className="divide-border/60 divide-y" role="list">
{ingredients.map((ingredient) => (
<li
key={ingredient.nutrient}
className="flex items-baseline justify-between gap-3 py-2"
data-slot="medication-composition-ingredient"
>
<span>{ingredient.label}</span>
<IngredientAmount ingredient={ingredient} />
</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("medications.composition.perUnitNote")}
</p>
</div>
)}
{open && (
<CompositionEditor
medicationId={medicationId}
ingredients={ingredients}
open={open}
onOpenChange={setOpen}
labelReadable={ownRecord && labelCapability.available}
/>
)}
</MedicationDetailSection>
);
}
/**
* 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 (
<div
className="flex items-center justify-between gap-3 py-3"
data-slot="erweitert-composition-row"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.composition.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.composition.rowHelper")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="erweitert-composition-add"
>
<Plus aria-hidden="true" className="size-4" />
{t("medications.composition.add")}
</Button>
{open && (
<CompositionEditor
medicationId={medicationId}
ingredients={[]}
open={open}
onOpenChange={setOpen}
labelReadable={ownRecord && labelCapability.available}
/>
)}
</div>
);
}
@@ -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<void> {
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<SupplementLabelProposal> {
const form = new FormData();
form.append("file", file);
const accepted = await apiFetch<SupplementLabelProposal | AiRunAccepted>(
`/api/medications/${encodeURIComponent(medicationId)}/ingredients/extract`,
{
method: "POST",
body: form,
signal: AbortSignal.timeout(LABEL_QUEUE_REQUEST_TIMEOUT_MS),
},
);
return resolveAiRun<SupplementLabelProposal>(queryClient, accepted, progress);
}
+3
View File
@@ -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(),
];
/**
+6
View File
@@ -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,
};