mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
9d32a8acb0
commit
01fef691fa
15 files changed
+1293
-42
No files matched your search
+55
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user