mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'fix/v1473-writes' into release/v1.47.3
This commit is contained in:
commit
6a02cda210
73 files changed
+1414
-356
No files matched your search
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
|
||||
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
|
||||
}
|
||||
},
|
||||
"missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.",
|
||||
"rate_limited": "Gerade gibt es zu viele Anfragen, daher wurde nichts geändert. Warte kurz und versuche es erneut.",
|
||||
"forbidden": "Dafür fehlt dir die Berechtigung, daher wurde nichts geändert.",
|
||||
"invalid": "Einige Angaben wurden nicht angenommen, daher wurde nichts geändert. Prüfe sie und versuche es erneut."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Stresslevel",
|
||||
"typeTrainingReadiness": "Trainingsbereitschaft",
|
||||
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Eigenen Impfstoff entfernen?",
|
||||
"sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.",
|
||||
"catalogGroup": "Katalog"
|
||||
}
|
||||
},
|
||||
"deleted": "Impfung gelöscht",
|
||||
"restoreFailed": "Impfung konnte nicht wiederhergestellt werden."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Krankheit",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Das Dokument konnte nicht gelöscht werden.",
|
||||
"purgeConfirmTitle": "Dieses Dokument endgültig löschen?",
|
||||
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Speichern",
|
||||
"savedToast": "Eigene Metrik gespeichert",
|
||||
"updatedToast": "Eigene Metrik aktualisiert",
|
||||
"saveError": "Eigene Metrik konnte nicht gespeichert werden"
|
||||
"saveError": "Eigene Metrik konnte nicht gespeichert werden",
|
||||
"nameTaken": "Es gibt bereits eine eigene Metrik mit diesem Namen."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Wert",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
|
||||
"capped": "Die letzten zwei Jahre",
|
||||
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
|
||||
}
|
||||
},
|
||||
"deleted": "Termin gelöscht",
|
||||
"restoreFailed": "Termin konnte nicht wiederhergestellt werden."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Vorsorge, Termine, Eingriffe oder Körperstellen anzeigen",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Eintrag löschen?",
|
||||
"deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.",
|
||||
"deleteFailed": "Eintrag konnte nicht gelöscht werden.",
|
||||
"noSpecialtyGroup": "Ohne Fachrichtung"
|
||||
"noSpecialtyGroup": "Ohne Fachrichtung",
|
||||
"deleted": "Eintrag gelöscht",
|
||||
"restoreFailed": "Eintrag konnte nicht wiederhergestellt werden."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Confirm your current password to change this.",
|
||||
"failed": "That did not verify. Nothing was changed."
|
||||
}
|
||||
},
|
||||
"missing": "Your session has ended, so nothing was changed. Sign in again to continue."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "The document reader is busy with other work. Try again in a minute."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Your session has ended, so nothing was changed. Sign in again to continue.",
|
||||
"rate_limited": "There are too many requests just now, so nothing was changed. Wait a moment and try again.",
|
||||
"forbidden": "You don't have permission for this, so nothing was changed.",
|
||||
"invalid": "Some of the entries were not accepted, so nothing was changed. Check them and try again."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Stress level",
|
||||
"typeTrainingReadiness": "Training readiness",
|
||||
"typeAcuteTrainingLoad": "Acute training load",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Enter a value between {min} and {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Remove own vaccine?",
|
||||
"sectionDescription": "Vaccines you added yourself because the catalogue does not list them.",
|
||||
"catalogGroup": "Catalogue"
|
||||
}
|
||||
},
|
||||
"deleted": "Vaccination deleted",
|
||||
"restoreFailed": "The vaccination could not be restored."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Illness",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Couldn't delete the document.",
|
||||
"purgeConfirmTitle": "Delete this document for good?",
|
||||
"purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Showing the newest {count}. Search to find an older document."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Save",
|
||||
"savedToast": "Custom metric saved",
|
||||
"updatedToast": "Custom metric updated",
|
||||
"saveError": "Could not save the custom metric"
|
||||
"saveError": "Could not save the custom metric",
|
||||
"nameTaken": "A custom metric with this name already exists."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Value",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
|
||||
"capped": "The last two years",
|
||||
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
|
||||
}
|
||||
},
|
||||
"deleted": "Visit deleted",
|
||||
"restoreFailed": "The visit could not be restored."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Show checkups, visits, procedures or body sites",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Delete this entry?",
|
||||
"deleteDescription": "Your existing visits stay, and they keep this name.",
|
||||
"deleteFailed": "The entry could not be deleted.",
|
||||
"noSpecialtyGroup": "No specialty"
|
||||
"noSpecialtyGroup": "No specialty",
|
||||
"deleted": "Entry deleted",
|
||||
"restoreFailed": "The entry could not be restored."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Confirma tu contraseña actual para cambiar esto.",
|
||||
"failed": "La verificación ha fallado. No se ha cambiado nada."
|
||||
}
|
||||
},
|
||||
"missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.",
|
||||
"rate_limited": "Hay demasiadas solicitudes ahora mismo, así que no se ha cambiado nada. Espera un momento y vuelve a intentarlo.",
|
||||
"forbidden": "No tienes permiso para esto, así que no se ha cambiado nada.",
|
||||
"invalid": "Algunos datos no se aceptaron, así que no se ha cambiado nada. Revísalos y vuelve a intentarlo."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Nivel de estrés",
|
||||
"typeTrainingReadiness": "Preparación para entrenar",
|
||||
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "¿Quitar la vacuna propia?",
|
||||
"sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.",
|
||||
"catalogGroup": "Catálogo"
|
||||
}
|
||||
},
|
||||
"deleted": "Vacuna eliminada",
|
||||
"restoreFailed": "No se pudo restaurar la vacuna."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Enfermedad",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "No se pudo eliminar el documento.",
|
||||
"purgeConfirmTitle": "¿Eliminar este documento definitivamente?",
|
||||
"purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Guardar",
|
||||
"savedToast": "Métrica personalizada guardada",
|
||||
"updatedToast": "Métrica personalizada actualizada",
|
||||
"saveError": "No se pudo guardar la métrica personalizada"
|
||||
"saveError": "No se pudo guardar la métrica personalizada",
|
||||
"nameTaken": "Ya existe una métrica personalizada con este nombre."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Valor",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
|
||||
"capped": "Los últimos dos años",
|
||||
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
|
||||
}
|
||||
},
|
||||
"deleted": "Cita eliminada",
|
||||
"restoreFailed": "No se pudo restaurar la cita."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostrar revisiones, citas, procedimientos o zonas del cuerpo",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "¿Eliminar esta entrada?",
|
||||
"deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.",
|
||||
"deleteFailed": "No se pudo eliminar la entrada.",
|
||||
"noSpecialtyGroup": "Sin especialidad"
|
||||
"noSpecialtyGroup": "Sin especialidad",
|
||||
"deleted": "Entrada eliminada",
|
||||
"restoreFailed": "No se pudo restaurar la entrada."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Confirmez votre mot de passe actuel pour modifier ceci.",
|
||||
"failed": "La vérification a échoué. Rien n'a été modifié."
|
||||
}
|
||||
},
|
||||
"missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.",
|
||||
"rate_limited": "Il y a trop de requêtes pour le moment, rien n'a donc été modifié. Patientez un instant puis réessayez.",
|
||||
"forbidden": "Vous n'avez pas l'autorisation pour cette action, rien n'a donc été modifié.",
|
||||
"invalid": "Certaines saisies n'ont pas été acceptées, rien n'a donc été modifié. Vérifiez-les puis réessayez."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Niveau de stress",
|
||||
"typeTrainingReadiness": "Disponibilité à l'entraînement",
|
||||
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Retirer ce vaccin personnel ?",
|
||||
"sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.",
|
||||
"catalogGroup": "Catalogue"
|
||||
}
|
||||
},
|
||||
"deleted": "Vaccination supprimée",
|
||||
"restoreFailed": "La vaccination n'a pas pu être restaurée."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Maladie",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Impossible de supprimer le document.",
|
||||
"purgeConfirmTitle": "Supprimer ce document définitivement ?",
|
||||
"purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Enregistrer",
|
||||
"savedToast": "Mesure personnalisée enregistrée",
|
||||
"updatedToast": "Mesure personnalisée mise à jour",
|
||||
"saveError": "Impossible d'enregistrer la mesure personnalisée"
|
||||
"saveError": "Impossible d'enregistrer la mesure personnalisée",
|
||||
"nameTaken": "Une mesure personnalisée portant ce nom existe déjà."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Valeur",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
|
||||
"capped": "Les deux dernières années",
|
||||
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
|
||||
}
|
||||
},
|
||||
"deleted": "Rendez-vous supprimé",
|
||||
"restoreFailed": "Le rendez-vous n'a pas pu être restauré."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Afficher les dépistages, les rendez-vous, les interventions ou les parties du corps",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Supprimer cette entrée ?",
|
||||
"deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.",
|
||||
"deleteFailed": "L'entrée n'a pas pu être supprimée.",
|
||||
"noSpecialtyGroup": "Sans spécialité"
|
||||
"noSpecialtyGroup": "Sans spécialité",
|
||||
"deleted": "Entrée supprimée",
|
||||
"restoreFailed": "L'entrée n'a pas pu être restaurée."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Conferma la password attuale per modificarlo.",
|
||||
"failed": "La verifica non è riuscita. Non è stato modificato nulla."
|
||||
}
|
||||
},
|
||||
"missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.",
|
||||
"rate_limited": "Ci sono troppe richieste in questo momento, quindi non è stato modificato nulla. Attendi un attimo e riprova.",
|
||||
"forbidden": "Non hai l'autorizzazione per farlo, quindi non è stato modificato nulla.",
|
||||
"invalid": "Alcuni dati non sono stati accettati, quindi non è stato modificato nulla. Controllali e riprova."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Livello di stress",
|
||||
"typeTrainingReadiness": "Prontezza all'allenamento",
|
||||
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Rimuovere il vaccino personale?",
|
||||
"sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.",
|
||||
"catalogGroup": "Catalogo"
|
||||
}
|
||||
},
|
||||
"deleted": "Vaccinazione eliminata",
|
||||
"restoreFailed": "Impossibile ripristinare la vaccinazione."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Malattia",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Impossibile eliminare il documento.",
|
||||
"purgeConfirmTitle": "Eliminare definitivamente questo documento?",
|
||||
"purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Salva",
|
||||
"savedToast": "Metrica personalizzata salvata",
|
||||
"updatedToast": "Metrica personalizzata aggiornata",
|
||||
"saveError": "Impossibile salvare la metrica personalizzata"
|
||||
"saveError": "Impossibile salvare la metrica personalizzata",
|
||||
"nameTaken": "Esiste già una metrica personalizzata con questo nome."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Valore",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
|
||||
"capped": "Gli ultimi due anni",
|
||||
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
|
||||
}
|
||||
},
|
||||
"deleted": "Appuntamento eliminato",
|
||||
"restoreFailed": "Non è stato possibile ripristinare l'appuntamento."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostra controlli, appuntamenti, interventi o parti del corpo",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Eliminare questa voce?",
|
||||
"deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.",
|
||||
"deleteFailed": "Non è stato possibile eliminare la voce.",
|
||||
"noSpecialtyGroup": "Senza specializzazione"
|
||||
"noSpecialtyGroup": "Senza specializzazione",
|
||||
"deleted": "Voce eliminata",
|
||||
"restoreFailed": "Non è stato possibile ripristinare la voce."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "변경하려면 현재 비밀번호를 확인하세요.",
|
||||
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
|
||||
}
|
||||
},
|
||||
"missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.",
|
||||
"rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.",
|
||||
"forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.",
|
||||
"invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "스트레스 수준",
|
||||
"typeTrainingReadiness": "트레이닝 준비도",
|
||||
"typeAcuteTrainingLoad": "급성 훈련 부하",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "HealthLog가 기록할 수 있는 것",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "직접 추가한 백신을 삭제할까요?",
|
||||
"sectionDescription": "목록에 없어서 직접 추가한 백신이에요.",
|
||||
"catalogGroup": "목록"
|
||||
}
|
||||
},
|
||||
"deleted": "예방접종 기록을 삭제했어요",
|
||||
"restoreFailed": "예방접종 기록을 복원하지 못했어요."
|
||||
},
|
||||
"illness": {
|
||||
"title": "아픈 날",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "문서를 삭제하지 못했어요.",
|
||||
"purgeConfirmTitle": "이 문서를 완전히 삭제할까요?",
|
||||
"purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "저장",
|
||||
"savedToast": "사용자 지표를 저장했어요",
|
||||
"updatedToast": "사용자 지표를 수정했어요",
|
||||
"saveError": "사용자 지표를 저장하지 못했어요"
|
||||
"saveError": "사용자 지표를 저장하지 못했어요",
|
||||
"nameTaken": "이 이름의 사용자 지표가 이미 있어요."
|
||||
},
|
||||
"entry": {
|
||||
"value": "값",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
|
||||
"capped": "최근 2년",
|
||||
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
|
||||
}
|
||||
},
|
||||
"deleted": "진료를 삭제했어요",
|
||||
"restoreFailed": "진료를 복원하지 못했어요."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "건강검진, 진료, 시술 또는 신체 부위 보기",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "이 항목을 삭제할까요?",
|
||||
"deleteDescription": "기존 진료 기록은 그대로 남고, 이 이름도 유지돼요.",
|
||||
"deleteFailed": "항목을 삭제하지 못했어요.",
|
||||
"noSpecialtyGroup": "진료과 없음"
|
||||
"noSpecialtyGroup": "진료과 없음",
|
||||
"deleted": "항목을 삭제했어요",
|
||||
"restoreFailed": "항목을 복원하지 못했어요."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Bevestig je huidige wachtwoord om dit te wijzigen.",
|
||||
"failed": "De verificatie is mislukt. Er is niets gewijzigd."
|
||||
}
|
||||
},
|
||||
"missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.",
|
||||
"rate_limited": "Er zijn net te veel verzoeken, dus er is niets gewijzigd. Wacht even en probeer het opnieuw.",
|
||||
"forbidden": "Je hebt hier geen toestemming voor, dus er is niets gewijzigd.",
|
||||
"invalid": "Sommige gegevens zijn niet geaccepteerd, dus er is niets gewijzigd. Controleer ze en probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Stressniveau",
|
||||
"typeTrainingReadiness": "Trainingsgereedheid",
|
||||
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Wat HealthLog kan bijhouden",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Eigen vaccin verwijderen?",
|
||||
"sectionDescription": "Vaccins die je zelf hebt toegevoegd omdat ze niet in de catalogus staan.",
|
||||
"catalogGroup": "Catalogus"
|
||||
}
|
||||
},
|
||||
"deleted": "Vaccinatie verwijderd",
|
||||
"restoreFailed": "De vaccinatie kon niet worden teruggezet."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Ziekte",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Het document kon niet worden verwijderd.",
|
||||
"purgeConfirmTitle": "Dit document definitief verwijderen?",
|
||||
"purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Opslaan",
|
||||
"savedToast": "Eigen meting opgeslagen",
|
||||
"updatedToast": "Eigen meting bijgewerkt",
|
||||
"saveError": "De eigen meting kon niet worden opgeslagen"
|
||||
"saveError": "De eigen meting kon niet worden opgeslagen",
|
||||
"nameTaken": "Er bestaat al een eigen meting met deze naam."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Waarde",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
|
||||
"capped": "De afgelopen twee jaar",
|
||||
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
|
||||
}
|
||||
},
|
||||
"deleted": "Bezoek verwijderd",
|
||||
"restoreFailed": "Het bezoek kon niet worden teruggezet."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Controles, bezoeken, ingrepen of lichaamsdelen tonen",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Deze vermelding verwijderen?",
|
||||
"deleteDescription": "Je bestaande bezoeken blijven bestaan en houden deze naam.",
|
||||
"deleteFailed": "De vermelding kon niet worden verwijderd.",
|
||||
"noSpecialtyGroup": "Geen specialisme"
|
||||
"noSpecialtyGroup": "Geen specialisme",
|
||||
"deleted": "Vermelding verwijderd",
|
||||
"restoreFailed": "De vermelding kon niet worden teruggezet."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Potwierdź obecne hasło, aby to zmienić.",
|
||||
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
|
||||
}
|
||||
},
|
||||
"missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.",
|
||||
"rate_limited": "W tej chwili jest zbyt wiele zapytań, więc nic nie zostało zmienione. Zaczekaj chwilę i spróbuj ponownie.",
|
||||
"forbidden": "Nie masz do tego uprawnień, więc nic nie zostało zmienione.",
|
||||
"invalid": "Niektóre dane nie zostały przyjęte, więc nic nie zostało zmienione. Sprawdź je i spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Poziom stresu",
|
||||
"typeTrainingReadiness": "Gotowość do treningu",
|
||||
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Usunąć własną szczepionkę?",
|
||||
"sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.",
|
||||
"catalogGroup": "Katalog"
|
||||
}
|
||||
},
|
||||
"deleted": "Szczepienie usunięte",
|
||||
"restoreFailed": "Nie udało się przywrócić szczepienia."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Choroba",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Nie udało się usunąć dokumentu.",
|
||||
"purgeConfirmTitle": "Usunąć ten dokument na stałe?",
|
||||
"purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Zapisz",
|
||||
"savedToast": "Zapisano metrykę własną",
|
||||
"updatedToast": "Zaktualizowano metrykę własną",
|
||||
"saveError": "Nie udało się zapisać metryki własnej"
|
||||
"saveError": "Nie udało się zapisać metryki własnej",
|
||||
"nameTaken": "Metryka własna o tej nazwie już istnieje."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Wartość",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
|
||||
"capped": "Ostatnie dwa lata",
|
||||
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
|
||||
}
|
||||
},
|
||||
"deleted": "Wizyta usunięta",
|
||||
"restoreFailed": "Nie udało się przywrócić wizyty."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty, zabiegi lub miejsca na ciele",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Usunąć ten wpis?",
|
||||
"deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.",
|
||||
"deleteFailed": "Nie udało się usunąć wpisu.",
|
||||
"noSpecialtyGroup": "Bez specjalizacji"
|
||||
"noSpecialtyGroup": "Bez specjalizacji",
|
||||
"deleted": "Wpis usunięty",
|
||||
"restoreFailed": "Nie udało się przywrócić wpisu."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Confirme sua senha atual para alterar isto.",
|
||||
"failed": "A verificação não foi concluída. Nada foi alterado."
|
||||
}
|
||||
},
|
||||
"missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.",
|
||||
"rate_limited": "Há solicitações demais neste momento, então nada foi alterado. Aguarde um pouco e tente de novo.",
|
||||
"forbidden": "Você não tem permissão para isso, então nada foi alterado.",
|
||||
"invalid": "Alguns dados não foram aceitos, então nada foi alterado. Confira e tente de novo."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Nível de estresse",
|
||||
"typeTrainingReadiness": "Prontidão para treinar",
|
||||
"typeAcuteTrainingLoad": "Carga de treino aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Informe um valor entre {min} e {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "O que o HealthLog pode acompanhar",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Remover vacina própria?",
|
||||
"sectionDescription": "Vacinas que você adicionou porque o catálogo não as lista.",
|
||||
"catalogGroup": "Catálogo"
|
||||
}
|
||||
},
|
||||
"deleted": "Vacina excluída",
|
||||
"restoreFailed": "Não foi possível restaurar a vacina."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Doenças",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Não foi possível excluir o documento.",
|
||||
"purgeConfirmTitle": "Excluir este documento para sempre?",
|
||||
"purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Salvar",
|
||||
"savedToast": "Métrica personalizada salva",
|
||||
"updatedToast": "Métrica personalizada atualizada",
|
||||
"saveError": "Não foi possível salvar a métrica personalizada"
|
||||
"saveError": "Não foi possível salvar a métrica personalizada",
|
||||
"nameTaken": "Já existe uma métrica personalizada com este nome."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Valor",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
|
||||
"capped": "Os últimos dois anos",
|
||||
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
|
||||
}
|
||||
},
|
||||
"deleted": "Consulta excluída",
|
||||
"restoreFailed": "Não foi possível restaurar a consulta."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostrar check-ups, consultas, procedimentos ou locais do corpo",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Excluir esta entrada?",
|
||||
"deleteDescription": "Suas consultas existentes permanecem e mantêm este nome.",
|
||||
"deleteFailed": "Não foi possível excluir a entrada.",
|
||||
"noSpecialtyGroup": "Sem especialidade"
|
||||
"noSpecialtyGroup": "Sem especialidade",
|
||||
"deleted": "Entrada excluída",
|
||||
"restoreFailed": "Não foi possível restaurar a entrada."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
+24
-6
@@ -143,7 +143,8 @@
|
||||
"reproof": {
|
||||
"required": "Подтвердите текущий пароль, чтобы изменить это.",
|
||||
"failed": "Не подтверждено. Ничего не изменено."
|
||||
}
|
||||
},
|
||||
"missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить."
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
@@ -178,6 +179,12 @@
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Средство чтения документов занято другой работой. Попробуйте через минуту."
|
||||
},
|
||||
"http": {
|
||||
"unauthenticated": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.",
|
||||
"rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.",
|
||||
"forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.",
|
||||
"invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -979,7 +986,8 @@
|
||||
"typeStressLevel": "Уровень стресса",
|
||||
"typeTrainingReadiness": "Готовность к тренировке",
|
||||
"typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
|
||||
"sourceGarmin": "Garmin"
|
||||
"sourceGarmin": "Garmin",
|
||||
"valueOutOfRange": "Введите значение от {min} до {max} {unit}."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Что умеет отслеживать HealthLog",
|
||||
@@ -10951,7 +10959,9 @@
|
||||
"deleteTitle": "Убрать свою вакцину?",
|
||||
"sectionDescription": "Вакцины, которые вы добавили сами, потому что их нет в каталоге.",
|
||||
"catalogGroup": "Каталог"
|
||||
}
|
||||
},
|
||||
"deleted": "Вакцинация удалена",
|
||||
"restoreFailed": "Не удалось восстановить вакцинацию."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Заболевание",
|
||||
@@ -12460,6 +12470,9 @@
|
||||
"purgeFailed": "Не удалось удалить документ.",
|
||||
"purgeConfirmTitle": "Удалить этот документ навсегда?",
|
||||
"purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить."
|
||||
},
|
||||
"picker": {
|
||||
"capped": "Показаны {count} самых новых. Более старый документ найдите через поиск."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12507,7 +12520,8 @@
|
||||
"save": "Сохранить",
|
||||
"savedToast": "Свой показатель сохранён",
|
||||
"updatedToast": "Свой показатель обновлён",
|
||||
"saveError": "Не удалось сохранить свой показатель"
|
||||
"saveError": "Не удалось сохранить свой показатель",
|
||||
"nameTaken": "Свой показатель с таким названием уже существует."
|
||||
},
|
||||
"entry": {
|
||||
"value": "Значение",
|
||||
@@ -13462,7 +13476,9 @@
|
||||
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.",
|
||||
"capped": "Последние два года",
|
||||
"cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет."
|
||||
}
|
||||
},
|
||||
"deleted": "Визит удалён",
|
||||
"restoreFailed": "Визит не удалось восстановить."
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Показать осмотры, визиты, процедуры или области тела",
|
||||
@@ -13495,7 +13511,9 @@
|
||||
"deleteTitle": "Удалить эту запись?",
|
||||
"deleteDescription": "Ваши существующие визиты остаются, и они сохраняют это имя.",
|
||||
"deleteFailed": "Запись не удалось удалить.",
|
||||
"noSpecialtyGroup": "Без специальности"
|
||||
"noSpecialtyGroup": "Без специальности",
|
||||
"deleted": "Запись удалена",
|
||||
"restoreFailed": "Запись не удалось восстановить."
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
|
||||
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
|
||||
"app/insights/page-client.tsx :: InsightsPageClient": 375,
|
||||
"app/labs/page.tsx :: LabsPage": 228,
|
||||
"app/labs/page.tsx :: LabsPage": 216,
|
||||
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
|
||||
"app/notifications/page.tsx :: NotificationsPage": 327,
|
||||
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
|
||||
@@ -134,7 +134,7 @@
|
||||
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239,
|
||||
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
|
||||
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
|
||||
"components/labs/lab-form.tsx :: LabForm": 473,
|
||||
"components/labs/lab-form.tsx :: LabForm": 474,
|
||||
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
|
||||
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
|
||||
@@ -172,7 +172,7 @@
|
||||
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202,
|
||||
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
|
||||
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
|
||||
"components/mood/mood-form.tsx :: MoodForm": 513,
|
||||
"components/mood/mood-form.tsx :: MoodForm": 514,
|
||||
"components/mood/mood-list.tsx :: MoodList": 1109,
|
||||
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
|
||||
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
|
||||
@@ -183,7 +183,6 @@
|
||||
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
|
||||
"components/records/allergy-form.tsx :: AllergyForm": 225,
|
||||
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
|
||||
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
|
||||
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
|
||||
"components/settings/about-section.tsx :: AboutSection": 236,
|
||||
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
|
||||
|
||||
@@ -1069,7 +1069,7 @@
|
||||
"components/labs/lab-biomarker-chart.tsx": 3,
|
||||
"components/labs/lab-biomarker-detail.tsx": 13,
|
||||
"components/labs/lab-form.tsx": 6,
|
||||
"components/labs/lab-history-list.tsx": 4,
|
||||
"components/labs/lab-history-list.tsx": 3,
|
||||
"components/labs/lab-list.tsx": 5,
|
||||
"components/labs/lab-reference-range-bar.tsx": 1,
|
||||
"components/labs/lab-trend-card.tsx": 1,
|
||||
|
||||
@@ -83,7 +83,7 @@ const ALLOWLIST: readonly Allowed[] = [
|
||||
file: "components/documents/document-picker-sheet.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.",
|
||||
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
|
||||
},
|
||||
{
|
||||
file: "hooks/use-mood-day.ts",
|
||||
|
||||
@@ -49,15 +49,15 @@ const FACTORY = "lib/query-keys/index.ts";
|
||||
* match — the wrapper carries the refetch internally, so they are compliant by
|
||||
* construction and correctly invisible here.
|
||||
*
|
||||
* The cycle / encounter / vaccination / illness families joined the matcher
|
||||
* alongside the original three: the encounter and illness bundles now carry a
|
||||
* daily-read key directly, the vaccination bundle's reminder root feeds the
|
||||
* Today rail, and cycle rides along so a future daily-read key added to its
|
||||
* The cycle / encounter / vaccination / illness / workout families joined the
|
||||
* matcher alongside the original three: the encounter, illness and workout
|
||||
* bundles carry a daily-read key directly, the vaccination bundle's reminder
|
||||
* root feeds the Today rail, and cycle rides along so a future daily-read key added to its
|
||||
* bundle cannot ship an unpaired site silently (its current sites are
|
||||
* allowlisted below with the reason).
|
||||
*/
|
||||
const INVALIDATION =
|
||||
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g;
|
||||
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys|workoutDependentKeys)\b/g;
|
||||
// Match the CALL form only (open paren), never the bare import specifier —
|
||||
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
|
||||
// count and let a file drop one paired call while still "passing".
|
||||
|
||||
@@ -111,12 +111,31 @@ export function intakeFacts(source: string, fileName: string): IntakeFacts {
|
||||
callsHelper: false,
|
||||
};
|
||||
|
||||
// A create form posts through the function `useIdempotentPost()` returns
|
||||
// (one key per opened form), bound to a local name.
|
||||
const posters = new Set<string>();
|
||||
const findPosters = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer &&
|
||||
ts.isCallExpression(node.initializer) &&
|
||||
ts.isIdentifier(node.initializer.expression) &&
|
||||
(aliases.get(node.initializer.expression.text) ??
|
||||
node.initializer.expression.text) === "useIdempotentPost"
|
||||
) {
|
||||
posters.add(node.name.text);
|
||||
}
|
||||
node.forEachChild(findPosters);
|
||||
};
|
||||
parsed.forEachChild(findPosters);
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression;
|
||||
if (ts.isIdentifier(callee)) {
|
||||
const name = aliases.get(callee.text) ?? callee.text;
|
||||
if (name === "apiPost") {
|
||||
if (name === "apiPost" || posters.has(callee.text)) {
|
||||
const url = urlText(node);
|
||||
if (url !== null && url.endsWith("/intake")) facts.postsIntake = true;
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/documents/sources/document-source-picker.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 },
|
||||
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
|
||||
"src/components/illness/episode-menu.tsx": {
|
||||
CheckCircle2: 2,
|
||||
@@ -344,8 +345,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 },
|
||||
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
|
||||
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
|
||||
"src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 },
|
||||
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
|
||||
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
|
||||
"src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 },
|
||||
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
|
||||
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
|
||||
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
|
||||
|
||||
+3
-15
@@ -37,7 +37,7 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { LabResultListResponse } from "@/components/labs/types";
|
||||
import {
|
||||
@@ -231,18 +231,14 @@ export default function LabsPage() {
|
||||
footerSlot={addFooterEl}
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
}}
|
||||
// v1.30.1 — "Save & add another" keeps the sheet open; the
|
||||
// form clears the reading itself, this callback just refreshes
|
||||
// the list underneath so the just-saved row is there once the
|
||||
// user is done and closes the sheet.
|
||||
onSavedKeepOpen={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
}}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
/>
|
||||
@@ -255,14 +251,6 @@ export default function LabsPage() {
|
||||
mode={ocrCapability.data.mode}
|
||||
pdfSupported={ocrCapability.data.pdfSupported}
|
||||
consentRequired={labsOcr.reason === "consent_required"}
|
||||
onCommitted={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -55,6 +56,7 @@ export function CustomMetricEntryForm({
|
||||
const { t } = useTranslations();
|
||||
const formId = useId();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(() => nowLocal());
|
||||
@@ -85,7 +87,7 @@ export function CustomMetricEntryForm({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost<CustomMetricEntryDto>(
|
||||
await post<CustomMetricEntryDto>(
|
||||
`/api/custom-metrics/${customMetricId}/entries`,
|
||||
body,
|
||||
);
|
||||
@@ -99,11 +101,7 @@ export function CustomMetricEntryForm({
|
||||
});
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("customMetrics.entry.saveError");
|
||||
setError(message);
|
||||
setError(localizedApiError(err, t, "customMetrics.entry.saveError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { CustomMetricDto } from "./types";
|
||||
@@ -122,11 +123,11 @@ export function CustomMetricForm({
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("customMetrics.form.saveError");
|
||||
setError(message);
|
||||
setError(
|
||||
err instanceof ApiError && err.status === 409
|
||||
? t("customMetrics.form.nameTaken")
|
||||
: localizedApiError(err, t, "customMetrics.form.saveError"),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -140,11 +141,7 @@ export function CustomMetricHistoryList({
|
||||
closeEdit();
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("customMetrics.entry.editError"),
|
||||
);
|
||||
setEditError(localizedApiError(err, t, "customMetrics.entry.editError"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -393,21 +393,22 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
|
||||
);
|
||||
|
||||
it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => {
|
||||
expect(componentSrc).toContain(
|
||||
"await invalidateMedicationReads(queryClient);",
|
||||
);
|
||||
expect(componentSrc).not.toContain(
|
||||
"invalidateKeys(queryClient, medicationDependentKeys)",
|
||||
);
|
||||
// The submit handler pairs the blessed helper with the inline
|
||||
// compliance-chart fan-out AFTER the intake POST landed.
|
||||
// AFTER the intake POST landed, and after the sheet was told it is done:
|
||||
// the save does not wait for the refetches before it closes.
|
||||
expect(componentSrc).toMatch(
|
||||
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/,
|
||||
/async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
|
||||
);
|
||||
// The bundle carries every inline compliance chart, so no second sweep.
|
||||
expect(medicationDependentKeys).toContainEqual(
|
||||
queryKeys.complianceChartInline(),
|
||||
);
|
||||
});
|
||||
|
||||
it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => {
|
||||
// The helper the submit path awaits — asserted directly, same shape as
|
||||
// The helper the submit path calls — asserted directly, same shape as
|
||||
// the take-all-due suite: the bundle fans out (N keys), then the two
|
||||
// daily reads are invalidated with `refetchType: "inactive"`, which is
|
||||
// what actually refetches them while unmounted.
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
} from "@/lib/medications/default-medication";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
runUndoIntake,
|
||||
@@ -118,6 +120,7 @@ export function MedicationIntakeQuickAdd({
|
||||
}: MedicationIntakeQuickAddProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
|
||||
// source the cards use); Berlin stays the last-resort fallback so
|
||||
// logged-out mounts behave unchanged.
|
||||
@@ -225,7 +228,7 @@ export function MedicationIntakeQuickAdd({
|
||||
// v1.30.1 — the route returns the created event
|
||||
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
|
||||
// already reads; the id drives the new Undo action below.
|
||||
const created = await apiPost<{ id?: string } | undefined>(
|
||||
const created = await post<{ id?: string } | undefined>(
|
||||
`/api/medications/${medicationId}/intake`,
|
||||
{
|
||||
takenAt: timestamp,
|
||||
@@ -235,13 +238,6 @@ export function MedicationIntakeQuickAdd({
|
||||
);
|
||||
const eventId = created?.id;
|
||||
|
||||
await invalidateMedicationReads(queryClient);
|
||||
// Fan-out to every inline compliance chart key (one per medication)
|
||||
// so the detail-page tile refreshes when the user re-enters it.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.complianceChartInline(),
|
||||
});
|
||||
|
||||
// v1.36.x — the shared decision, not a fourth copy of it. This surface
|
||||
// was left out of the collapse that took the other three: it offered a
|
||||
// delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`,
|
||||
@@ -272,11 +268,16 @@ export function MedicationIntakeQuickAdd({
|
||||
toast.success(t("common.saved"));
|
||||
}
|
||||
onSuccess?.();
|
||||
// The dose is stored; the sheet does not wait for the refetches. The
|
||||
// bundle covers every inline compliance chart as well.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("dashboard.medicationIntakeQuickAdd.saveError"),
|
||||
localizedApiError(
|
||||
err,
|
||||
t,
|
||||
"dashboard.medicationIntakeQuickAdd.saveError",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Confirming a document's facts creates medications, conditions and lab
|
||||
* values. The confirm used to evict only the roots of those lists, so the
|
||||
* dashboard, the Today digest, adherence, nutrients, the day and the timeline
|
||||
* kept showing the record without them.
|
||||
*/
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { invalidateConfirmedFactReads } from "../use-document-facts";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const client = new QueryClient();
|
||||
|
||||
afterEach(() => client.clear());
|
||||
|
||||
describe("invalidateConfirmedFactReads", () => {
|
||||
it.each([
|
||||
["the dashboard snapshot", queryKeys.dashboardSnapshot()],
|
||||
["the Today digest", queryKeys.dailyDigest()],
|
||||
["the nutrient reads", queryKeys.nutrientsRoot()],
|
||||
["an open day", queryKeys.day("2026-11-10")],
|
||||
["the timeline", queryKeys.timelineRoot()],
|
||||
["the lab results", queryKeys.labResults()],
|
||||
["the condition journal", queryKeys.illness()],
|
||||
["the document", queryKeys.inboundDocument("doc-1")],
|
||||
])("marks %s stale", async (_label, key) => {
|
||||
client.setQueryData(key, { seeded: true });
|
||||
|
||||
await invalidateConfirmedFactReads(client, "doc-1");
|
||||
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The document picker reads one page of the vault, the newest fifty. With a
|
||||
* longer vault it listed those and said nothing, so an older report seemed
|
||||
* not to exist unless one already knew to search for it.
|
||||
*
|
||||
* ResponsiveSheet portals its body, which static markup does not render, so it
|
||||
* is mocked down to a wrapper that always shows its children.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-sheet">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
// A cut page is a full one.
|
||||
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
|
||||
id: `d${i}`,
|
||||
kind: "LAB_RESULT",
|
||||
title: `Blood panel ${i}`,
|
||||
filename: null,
|
||||
documentDate: "2026-10-01",
|
||||
createdAt: "2026-10-01T08:00:00.000Z",
|
||||
})),
|
||||
nextCursor: page.nextCursor,
|
||||
},
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentPickerSheet } from "../document-picker-sheet";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentPickerSheet
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
title="Link documents"
|
||||
description="Pick documents"
|
||||
searchPlaceholder="Search"
|
||||
emptyLabel="No documents"
|
||||
isPicked={() => false}
|
||||
onToggle={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DocumentPickerSheet", () => {
|
||||
it("says the list is cut, and how to reach the rest", () => {
|
||||
page.nextCursor = "d49";
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="document-picker-capped"');
|
||||
expect(html).toContain("Showing the newest 50");
|
||||
});
|
||||
|
||||
it("says nothing when every document is listed", () => {
|
||||
page.nextCursor = null;
|
||||
expect(render()).not.toContain('data-slot="document-picker-capped"');
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
documentDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
DocumentSourceSystemValue,
|
||||
InboundDocumentDetailDto,
|
||||
@@ -265,7 +269,7 @@ function useDocumentWrites({
|
||||
// A link written from this side changes what the visit's and the
|
||||
// dose's own views list, so their reads refresh with the vault's.
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.documents(),
|
||||
...documentDependentKeys,
|
||||
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
|
||||
...(input.vaccinationIds !== undefined
|
||||
? [queryKeys.vaccinations()]
|
||||
@@ -282,7 +286,7 @@ function useDocumentWrites({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost(`/api/documents/inbound/${id}/restore`, {}),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
void invalidateKeys(queryClient, documentDependentKeys);
|
||||
},
|
||||
onError: (error) => {
|
||||
const reason =
|
||||
@@ -303,7 +307,7 @@ function useDocumentWrites({
|
||||
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
onOpenChange(false);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
void invalidateKeys(queryClient, documentDependentKeys);
|
||||
toast.success(t("documents.toast.deleted"), {
|
||||
description: t("documents.toast.deletedHint", {
|
||||
days: DOCUMENT_PURGE_GRACE_DAYS,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
/**
|
||||
* The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on
|
||||
* phones) with a debounced search over the person's own vault — the same
|
||||
* recall path as the vault page, one page (50) of matches, the search input
|
||||
* as the way to an older document — and one toggle row per document.
|
||||
* recall path as the vault page, one page (50) of matches with a line that
|
||||
* says when more exist, the search input as the way to an older document —
|
||||
* and one toggle row per document.
|
||||
*
|
||||
* Two pickers used to carry this whole body as copies: linking documents to
|
||||
* an illness episode (each toggle writes at once) and choosing documents for
|
||||
@@ -97,6 +98,8 @@ export function DocumentPickerSheet({
|
||||
});
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
// More match than one page: say so, and that the search reaches the rest.
|
||||
const cut = Boolean(list.data?.nextCursor);
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
@@ -188,6 +191,16 @@ export function DocumentPickerSheet({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{cut && !list.isError ? (
|
||||
<p
|
||||
data-slot="document-picker-capped"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.picker.capped", {
|
||||
count: String(documents.length),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,11 @@ import {
|
||||
type DocumentSourceTagDto,
|
||||
} from "@/lib/documents/sources/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
documentDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
@@ -317,7 +321,7 @@ export function DocumentSourcePicker({
|
||||
// The vault, its pickers and the record's own lists read documents
|
||||
// through the `["documents"]` prefix; the picker's states through its own.
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.documents(),
|
||||
...documentDependentKeys,
|
||||
queryKeys.documentSources(),
|
||||
]);
|
||||
if (handed.length > 0) onImported?.(handed);
|
||||
|
||||
@@ -10,7 +10,11 @@ import { ApiError, apiGetList, apiPost } from "@/lib/api/api-fetch";
|
||||
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
documentDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
DeletedDocumentListDto,
|
||||
DocumentBulkResultDto,
|
||||
@@ -21,7 +25,8 @@ import type {
|
||||
* document back (one at a time, or all through the bulk route); "Delete now"
|
||||
* removes one for good ahead of the grace window. Every write invalidates the
|
||||
* `["documents"]` prefix, so the vault, its usage figures and this list repaint
|
||||
* together.
|
||||
* together, and the day and timeline roots, which place a document on its
|
||||
* date.
|
||||
*/
|
||||
export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
@@ -36,7 +41,7 @@ export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
|
||||
});
|
||||
|
||||
const refresh = useCallback(
|
||||
() => void invalidateKeys(queryClient, [queryKeys.documents()]),
|
||||
() => void invalidateKeys(queryClient, documentDependentKeys),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* approve/reject decisions the person made; a fact without a decision stays
|
||||
* pending on the server.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { resolveAiRun, useAiRunPhase } from "@/hooks/use-ai-run";
|
||||
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -21,7 +25,15 @@ import type {
|
||||
AiRunAccepted,
|
||||
DocumentExtractRunResult,
|
||||
} from "@/lib/documents/ai-runs/types";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
documentDependentKeys,
|
||||
illnessDependentKeys,
|
||||
invalidateKeys,
|
||||
labDependentKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundFactEdit,
|
||||
@@ -101,11 +113,32 @@ export function useEditFact() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every read a confirm can touch: the medications, conditions and lab values
|
||||
* it creates, with what derives from them, and the document itself (status
|
||||
* and fact states). A new medication or condition also reaches the dashboard
|
||||
* and the Today digest, which are unmounted while the person reviews.
|
||||
*/
|
||||
export function invalidateConfirmedFactReads(
|
||||
queryClient: QueryClient,
|
||||
documentId: string,
|
||||
) {
|
||||
return Promise.all([
|
||||
invalidateKeys(queryClient, [
|
||||
...medicationDependentKeys,
|
||||
...illnessDependentKeys,
|
||||
...labDependentKeys,
|
||||
...documentDependentKeys,
|
||||
queryKeys.inboundDocument(documentId),
|
||||
]),
|
||||
refetchInactiveDailyReads(queryClient),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the review decisions. Approved facts land in their structured
|
||||
* stores (labs / conditions / medications) through the server's existing
|
||||
* field-by-field commit; rejected facts are discarded. Invalidates every
|
||||
* read the commit can touch, plus the document itself (status + fact states).
|
||||
* field-by-field commit; rejected facts are discarded.
|
||||
*/
|
||||
export function useConfirmFacts() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -127,15 +160,7 @@ export function useConfirmFacts() {
|
||||
`/api/documents/inbound/${documentId}/confirm`,
|
||||
{ decisions },
|
||||
),
|
||||
onSuccess: (_data, { documentId }) => {
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.inboundDocument(documentId),
|
||||
queryKeys.documents(),
|
||||
queryKeys.labResults(),
|
||||
queryKeys.biomarkers(),
|
||||
queryKeys.illness(),
|
||||
queryKeys.medications(),
|
||||
]);
|
||||
},
|
||||
onSuccess: (_data, { documentId }) =>
|
||||
void invalidateConfirmedFactReads(queryClient, documentId),
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { DOCUMENT_UPLOAD_PER_USER_CONCURRENCY } from "@/lib/documents/inbound-constants";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
import { uploadWithBusyRetry } from "./upload-busy-retry";
|
||||
import {
|
||||
parseUploadResponse,
|
||||
@@ -261,7 +261,7 @@ export function useDocumentUpload(
|
||||
}
|
||||
const decision = settleDecision(result);
|
||||
removeItem(localId);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
void invalidateKeys(queryClient, documentDependentKeys);
|
||||
if (decision.toastDuplicate) {
|
||||
toast.info(t("documents.toast.duplicate"));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DocumentBulkAction,
|
||||
DocumentBulkResultDto,
|
||||
@@ -64,7 +64,7 @@ export function useVaultBulk({
|
||||
return results;
|
||||
},
|
||||
onSettled: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
void invalidateKeys(queryClient, documentDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
|
||||
import {
|
||||
@@ -84,7 +86,7 @@ export function EncounterSheet({
|
||||
onSaved?: (saved: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update, remove } = useEncounterMutations();
|
||||
const { create, update, remove, restore } = useEncounterMutations();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
// Seeded once, at mount. The caller remounts this component on every open by
|
||||
// varying its `key`, so a second edit never shows the first one's values and
|
||||
@@ -128,8 +130,7 @@ export function EncounterSheet({
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
const message = localizedApiError(err, t, "encounters.saveFailed");
|
||||
setError(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
@@ -210,8 +211,25 @@ export function EncounterSheet({
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
const id = encounter.id;
|
||||
remove.mutate(id, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
// The delete is soft. The Undo runs after this sheet
|
||||
// closed, so it awaits the restore itself rather than
|
||||
// passing callbacks a closed sheet never receives.
|
||||
toast.success(t("encounters.deleted"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void restore.mutateAsync(id).catch((failure) =>
|
||||
showErrorToast(t("encounters.restoreFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("encounters.deleteFailed"), {
|
||||
cause: failure,
|
||||
|
||||
@@ -91,7 +91,6 @@ function render(
|
||||
mode={options.mode ?? "vision"}
|
||||
pdfSupported={options.pdfSupported ?? true}
|
||||
consentRequired={options.consentRequired}
|
||||
onCommitted={() => {}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
@@ -68,8 +68,7 @@ export function BiomarkerManager() {
|
||||
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("labs.biomarker.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
|
||||
@@ -103,8 +102,7 @@ export function BiomarkerManager() {
|
||||
function afterSave() {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
}
|
||||
|
||||
const markers = data?.biomarkers ?? [];
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateKeys, labDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
@@ -79,8 +79,7 @@ export function BiomarkerMergeSheet({
|
||||
name: result.target.name,
|
||||
}),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
type MessageBundle,
|
||||
} from "@/lib/i18n/load-locale";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -259,13 +259,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
// lapsed; bust them alongside the result list so the assessment repaints as
|
||||
// soon as the reading lands.
|
||||
function invalidateAfterReadingWrite() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
});
|
||||
void invalidateKeys(queryClient, [
|
||||
...labDependentKeys,
|
||||
queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
|
||||
]);
|
||||
}
|
||||
|
||||
function afterAdd() {
|
||||
@@ -282,12 +279,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
|
||||
function afterEditMarker() {
|
||||
setEditOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
// Resolved name / unit / range on every reading derives from the marker.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
void invalidateKeys(queryClient, [
|
||||
...labDependentKeys,
|
||||
queryKeys.biomarkerDetail(biomarkerId),
|
||||
]);
|
||||
}
|
||||
|
||||
// v1.18.9 (#41/#3) — delete the biomarker directly from its detail page so
|
||||
@@ -301,8 +297,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
mutationFn: () => apiDelete(`/api/biomarkers/${biomarkerId}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("labs.biomarker.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
router.push("/labs");
|
||||
},
|
||||
onError: (failure) =>
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
isUnreadableRange,
|
||||
@@ -144,6 +145,7 @@ export function LabForm({
|
||||
const labNumber = useLabNumber();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
const formId = useId();
|
||||
|
||||
const { data: catalog, isLoading: catalogLoading } = useQuery({
|
||||
@@ -265,7 +267,7 @@ export function LabForm({
|
||||
const printed = isQualitative
|
||||
? null
|
||||
: parseReferenceRange(sourceRange, selected?.unit);
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
const created = await post<LabResultDto>("/api/labs", {
|
||||
biomarkerId,
|
||||
...(isQualitative
|
||||
? { valueText: qualitativeValue }
|
||||
|
||||
@@ -35,7 +35,7 @@ import { LabConvertedNote } from "./lab-converted-note";
|
||||
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
|
||||
import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
|
||||
@@ -147,14 +147,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const [hasNote, setHasNote] = useState(false);
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
// v1.32.19 — a lab reading edit / delete / restore shifts the two most
|
||||
// recent panels the Insights "what changed since your last panel" card
|
||||
// reads, so bust it too instead of leaving it on its pre-write deltas
|
||||
// until the 60 s stale window lapses.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
});
|
||||
void invalidateKeys(queryClient, labDependentKeys);
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
|
||||
@@ -142,7 +142,6 @@ export function OcrReviewDialog({
|
||||
mode,
|
||||
pdfSupported,
|
||||
consentRequired = false,
|
||||
onCommitted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -155,7 +154,6 @@ export function OcrReviewDialog({
|
||||
* granted `/api/auth/me` refreshes and the picker appears.
|
||||
*/
|
||||
consentRequired?: boolean;
|
||||
onCommitted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const pickerId = useId();
|
||||
@@ -335,7 +333,6 @@ export function OcrReviewDialog({
|
||||
})
|
||||
: t("labs.ocr.savedEmptyToast"),
|
||||
);
|
||||
onCommitted();
|
||||
handleClose(false);
|
||||
},
|
||||
onError: (failure) =>
|
||||
|
||||
@@ -29,7 +29,9 @@ import type { AiRunAccepted } from "@/lib/documents/ai-runs/types";
|
||||
import { ocrImageToText } from "@/lib/labs/local-ocr";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
documentDependentKeys,
|
||||
invalidateKeys,
|
||||
labDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
@@ -259,12 +261,11 @@ export function useOcrCommit() {
|
||||
});
|
||||
},
|
||||
onSuccess: (_result, { file }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
// A scan filed into the vault adds a document — refresh its lists.
|
||||
if (file) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.documents() });
|
||||
}
|
||||
// A scan filed into the vault adds a document as well.
|
||||
void invalidateKeys(queryClient, [
|
||||
...labDependentKeys,
|
||||
...(file ? documentDependentKeys : []),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The edit sheet refuses an implausible value at the value field, before the
|
||||
* request, in the unit the input shows. The server checks the same band and
|
||||
* used to be the only one that did: its 422 reached the sheet as the English
|
||||
* banner "Validation failed", in every locale, with no field marked.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { UnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { editValueRange } from "../list/use-measurement-edit";
|
||||
import { rowDisplayFor } from "../list/use-measurement-row-display";
|
||||
|
||||
const KG_PER_LB = 0.45359237;
|
||||
|
||||
function display(
|
||||
preference: "metric" | "imperial",
|
||||
glucose: "mg/dL" | "mmol/L",
|
||||
) {
|
||||
const imperial = preference === "imperial";
|
||||
const unitDisplay = {
|
||||
preference,
|
||||
isTransformed: (type: string) => imperial && type === "WEIGHT",
|
||||
toDisplay: (type: string, v: number) =>
|
||||
imperial && type === "WEIGHT" ? v / KG_PER_LB : v,
|
||||
fromDisplay: (type: string, v: number) =>
|
||||
imperial && type === "WEIGHT" ? v * KG_PER_LB : v,
|
||||
unitFor: (type: string) =>
|
||||
imperial && type === "WEIGHT" ? "lb" : getUnitForType(type),
|
||||
} as unknown as UnitDisplay;
|
||||
return rowDisplayFor(glucose, unitDisplay);
|
||||
}
|
||||
|
||||
const row = (type: string, unit: string) => ({
|
||||
id: "m1",
|
||||
type,
|
||||
value: 72,
|
||||
unit,
|
||||
source: "MANUAL",
|
||||
measuredAt: "2026-11-10T08:00:00.000Z",
|
||||
notes: null,
|
||||
});
|
||||
|
||||
describe("editValueRange", () => {
|
||||
it("names the band in the stored unit for a metric reader", () => {
|
||||
expect(
|
||||
editValueRange(display("metric", "mg/dL"), row("WEIGHT", "kg")),
|
||||
).toEqual({ min: 1, max: 500, unit: "kg" });
|
||||
});
|
||||
|
||||
it("names it in pounds for an imperial reader, who types pounds", () => {
|
||||
const range = editValueRange(
|
||||
display("imperial", "mg/dL"),
|
||||
row("WEIGHT", "kg"),
|
||||
);
|
||||
expect(range?.unit).toBe("lb");
|
||||
expect(range?.max).toBeCloseTo(1102.3, 1);
|
||||
});
|
||||
|
||||
it("names it in mmol/L for a glucose edit in mmol/L", () => {
|
||||
const range = editValueRange(
|
||||
display("metric", "mmol/L"),
|
||||
row("BLOOD_GLUCOSE", "mg/dL"),
|
||||
);
|
||||
expect(range?.unit).toBe("mmol/L");
|
||||
expect(range!.max).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("has nothing to say for a type without a band", () => {
|
||||
expect(
|
||||
editValueRange(display("metric", "mg/dL"), row("NO_SUCH_TYPE", "x")),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Loader2, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -57,6 +58,8 @@ export function MeasurementEditSheet({
|
||||
setEditValue,
|
||||
editError,
|
||||
editErrorDescriptor,
|
||||
editValueError,
|
||||
editValueErrorId,
|
||||
editMeasuredAt,
|
||||
setEditMeasuredAt,
|
||||
editNotes,
|
||||
@@ -103,9 +106,12 @@ export function MeasurementEditSheet({
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!editError || undefined}
|
||||
aria-describedby={editErrorDescriptor}
|
||||
aria-invalid={!!(editValueError || editError) || undefined}
|
||||
aria-describedby={
|
||||
editValueError ? editValueErrorId : editErrorDescriptor
|
||||
}
|
||||
/>
|
||||
<FieldError id={editValueErrorId} message={editValueError} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -5,9 +5,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal";
|
||||
import { ApiError, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { toCanonicalMgdl } from "@/lib/glucose";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import {
|
||||
VALUE_RANGES,
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/measurements/value-ranges";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -78,6 +83,58 @@ export function storedValueFor(
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored value's plausible band for the row's type, in the unit the edit
|
||||
* input shows, or null for a type without one. The same table the server
|
||||
* checks the edit against.
|
||||
*/
|
||||
export function editValueRange(
|
||||
{ rowDisplay }: Pick<MeasurementRowDisplay, "rowDisplay">,
|
||||
measurement: Measurement,
|
||||
): { min: number; max: number; unit: string } | null {
|
||||
const range = VALUE_RANGES[measurement.type];
|
||||
if (!range) return null;
|
||||
const low = rowDisplay({ ...measurement, value: range.min });
|
||||
const high = rowDisplay({ ...measurement, value: range.max });
|
||||
const round = (n: number) => Math.round(n * 10) / 10;
|
||||
return { min: round(low.value), max: round(high.value), unit: low.unit };
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
/** The sentence for a value outside its band, in the unit the input shows. */
|
||||
function outOfRangeMessage(
|
||||
display: Pick<MeasurementRowDisplay, "rowDisplay">,
|
||||
measurement: Measurement,
|
||||
t: Translate,
|
||||
formatNumber: (value: number) => string,
|
||||
): string {
|
||||
const range = editValueRange(display, measurement);
|
||||
return range
|
||||
? t("measurements.valueOutOfRange", {
|
||||
min: formatNumber(range.min),
|
||||
max: formatNumber(range.max),
|
||||
unit: range.unit,
|
||||
})
|
||||
: t("measurements.invalidValue");
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner for a refused save. A 409 names its reason in `meta.errorCode`:
|
||||
* the edit collides with another reading's (type, time, source, stage), or
|
||||
* the reading belongs to a server-owned source (connector, import, computed).
|
||||
*/
|
||||
function saveRefusal(err: unknown, t: Translate): string {
|
||||
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
|
||||
if (code === "measurement.duplicate_timestamp") {
|
||||
return t("measurements.duplicateTimestamp");
|
||||
}
|
||||
if (code === "measurement.update.server_owned_source") {
|
||||
return t("measurements.serverOwnedSource");
|
||||
}
|
||||
return localizedApiError(err, t, "measurements.saveError");
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit sheet's state: the reading being edited, the draft seeded in the
|
||||
* person's display unit, the save (converted back to the stored unit) and
|
||||
@@ -94,6 +151,7 @@ export function useMeasurementEdit({
|
||||
deleteMutation: MeasurementListData["deleteMutation"];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState<Measurement | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
@@ -105,10 +163,13 @@ export function useMeasurementEdit({
|
||||
notes: "",
|
||||
});
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
// A refused value is said under the value input, not in the banner.
|
||||
const [editValueError, setEditValueError] = useState<string | null>(null);
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// The error banner is linked to the inputs through `aria-describedby`, so a
|
||||
// screen reader announces why a submit was refused.
|
||||
const editErrorId = useId();
|
||||
const editValueErrorId = useId();
|
||||
// The sheet's footer is a slot the form portals its buttons into.
|
||||
const editFormId = useId();
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -127,39 +188,30 @@ export function useMeasurementEdit({
|
||||
}) => {
|
||||
await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
onSuccess: () => {
|
||||
setEditSeed({
|
||||
value: editValue,
|
||||
measuredAt: editMeasuredAt,
|
||||
notes: editNotes,
|
||||
});
|
||||
dismissEdit();
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
// A 409 names its reason in `meta.errorCode`: the edit collides with
|
||||
// another reading's (type, time, source, stage), or the reading belongs
|
||||
// to a server-owned source (connector, import, computed).
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "measurement.duplicate_timestamp"
|
||||
) {
|
||||
setEditError(t("measurements.duplicateTimestamp"));
|
||||
return;
|
||||
if (err instanceof ApiError && err.issuePaths.includes("value")) {
|
||||
setEditValueError(valueRefusal());
|
||||
} else {
|
||||
setEditError(saveRefusal(err, t));
|
||||
}
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "measurement.update.server_owned_source"
|
||||
) {
|
||||
setEditError(t("measurements.serverOwnedSource"));
|
||||
return;
|
||||
}
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : t("measurements.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function valueRefusal(): string {
|
||||
return editing
|
||||
? outOfRangeMessage(display, editing, t, fmt.number)
|
||||
: t("measurements.invalidValue");
|
||||
}
|
||||
const editDismissal = useSeededFormDismissal({
|
||||
seed: editSeed,
|
||||
value: {
|
||||
@@ -188,11 +240,13 @@ export function useMeasurementEdit({
|
||||
setEditNotes(seed.notes);
|
||||
setEditSeed(seed);
|
||||
setEditError(null);
|
||||
setEditValueError(null);
|
||||
}
|
||||
|
||||
function dismissEdit() {
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
setEditValueError(null);
|
||||
setEditDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
@@ -216,9 +270,11 @@ export function useMeasurementEdit({
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
|
||||
setEditError(null);
|
||||
setEditValueError(null);
|
||||
const parsedValue = Number(editValue);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
setEditError(t("measurements.invalidValue"));
|
||||
setEditValueError(t("measurements.invalidValue"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,8 +285,16 @@ export function useMeasurementEdit({
|
||||
}
|
||||
|
||||
const canonicalValue = storedValueFor(display, editing, parsedValue);
|
||||
// The server refuses a changed value outside the type's band; say so at
|
||||
// the field before sending, in the unit the input shows.
|
||||
if (
|
||||
editValue !== editSeed.value &&
|
||||
validateMeasurementRange(editing.type, canonicalValue) !== null
|
||||
) {
|
||||
setEditValueError(valueRefusal());
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
value: canonicalValue,
|
||||
@@ -250,6 +314,8 @@ export function useMeasurementEdit({
|
||||
editError,
|
||||
editErrorId,
|
||||
editErrorDescriptor: editError ? editErrorId : undefined,
|
||||
editValueError,
|
||||
editValueErrorId,
|
||||
editFormId,
|
||||
editFooterEl,
|
||||
setEditFooterEl,
|
||||
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
measurementDependentKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
import {
|
||||
getLastUsedMeasurementType,
|
||||
@@ -327,6 +327,7 @@ export function MeasurementForm({
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const { user } = useAuth();
|
||||
// Glucose is its own display axis, separate from metric/imperial: it is
|
||||
@@ -448,7 +449,7 @@ export function MeasurementForm({
|
||||
});
|
||||
}
|
||||
|
||||
await apiPost("/api/measurements", batch);
|
||||
await post("/api/measurements", batch);
|
||||
} else if (isKetoneMode) {
|
||||
// One fingerstick, posted as one transaction (`ketone-fingerstick.ts`).
|
||||
const batch = ketoneFingerstickBatch({
|
||||
@@ -463,7 +464,7 @@ export function MeasurementForm({
|
||||
setError(t("measurements.saveError"));
|
||||
return;
|
||||
}
|
||||
await apiPost("/api/measurements", batch);
|
||||
await post("/api/measurements", batch);
|
||||
} else {
|
||||
// Single measurement. Canonical storage stays SI: an imperial user
|
||||
// types in lb / in / °F and we invert to the canonical unit at the
|
||||
@@ -508,7 +509,7 @@ export function MeasurementForm({
|
||||
: inverted;
|
||||
}
|
||||
canonicalValue = entryValueToCanonical(type, canonicalValue);
|
||||
await apiPost("/api/measurements", {
|
||||
await post("/api/measurements", {
|
||||
type,
|
||||
value: canonicalValue,
|
||||
measuredAt: timestamp,
|
||||
@@ -528,8 +529,6 @@ export function MeasurementForm({
|
||||
setPulse("");
|
||||
setPairedGlucose("");
|
||||
setNotes("");
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
// v1.36.x — a delegate's receipt names the record. "Saved" alone is
|
||||
// the one confirmation somebody acting for another person does not
|
||||
// need, and the reading has just left their own history for good.
|
||||
@@ -544,6 +543,10 @@ export function MeasurementForm({
|
||||
: undefined,
|
||||
);
|
||||
onSuccess?.();
|
||||
// The reading is stored; the sheet does not wait for every read that
|
||||
// shows it to come back.
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
} catch (err) {
|
||||
setError(localizedApiError(err, t, "measurements.saveError"));
|
||||
} finally {
|
||||
|
||||
@@ -32,7 +32,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGetList, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -96,17 +100,16 @@ export function MentalWellbeing() {
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setPhase("result");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
});
|
||||
// A completed screening can satisfy a due checkup reminder, so the
|
||||
// checkups list and the Today rail must repaint. Honest constraint:
|
||||
// the server satisfy runs async via pg-boss, so this invalidation
|
||||
// narrows the stale window but cannot close it — a refetch racing
|
||||
// the job may still see the pre-satisfy row until the next tick.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.measurementReminders(),
|
||||
});
|
||||
// A screening is listed on its day, and it can satisfy a due checkup
|
||||
// reminder, so the checkups list and the Today rail must repaint.
|
||||
// Honest constraint: the server satisfy runs async via pg-boss, so
|
||||
// this invalidation narrows the stale window but cannot close it — a
|
||||
// refetch racing the job may still see the pre-satisfy row.
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.mentalHealthAssessments(),
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.dayRoot(),
|
||||
]);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,8 +42,8 @@ import {
|
||||
moodDependentKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
@@ -125,6 +125,7 @@ export function MoodForm({
|
||||
}: MoodFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
|
||||
const [mood, setMood] = useState("");
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
@@ -236,7 +237,7 @@ export function MoodForm({
|
||||
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const saved = await apiPost<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
const saved = await post<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
@@ -257,13 +258,13 @@ export function MoodForm({
|
||||
if (tagKeys.length > 0) recordUse(tagKeys);
|
||||
resetForm();
|
||||
setMoreTagsOpen(false);
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("common.saved"));
|
||||
// The entry landed, but a tag picked here may have been archived or
|
||||
// deleted elsewhere since the picker loaded; the server says so.
|
||||
if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
|
||||
onSuccess?.();
|
||||
void invalidateKeys(queryClient, moodDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
} catch (err) {
|
||||
setError(localizedApiError(err, t, "mood.saveError"));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Deleting an address-book entry is a soft delete with a restore route, and
|
||||
* nothing on the web offered it: a mistaken delete could not be taken back.
|
||||
* The delete now confirms with an Undo that calls the restore.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const toastSuccess = vi.hoisted(() => vi.fn());
|
||||
const errorToast = vi.hoisted(() => vi.fn());
|
||||
vi.mock("sonner", () => ({ toast: { success: toastSuccess } }));
|
||||
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast: errorToast }));
|
||||
|
||||
import { deleteWithUndo } from "../practitioner-list";
|
||||
|
||||
type Mutations = Parameters<typeof deleteWithUndo>[0];
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function mutations(restoreResult: Promise<unknown>) {
|
||||
const remove = {
|
||||
mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) =>
|
||||
options.onSuccess?.(),
|
||||
),
|
||||
};
|
||||
const restore = { mutateAsync: vi.fn(() => restoreResult) };
|
||||
return { remove, restore, all: { remove, restore } as unknown as Mutations };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastSuccess.mockReset();
|
||||
errorToast.mockReset();
|
||||
});
|
||||
|
||||
describe("deleteWithUndo", () => {
|
||||
it("confirms the delete with an Undo that restores the entry", async () => {
|
||||
const m = mutations(Promise.resolve({ id: "p1" }));
|
||||
|
||||
deleteWithUndo(m.all, "p1", t);
|
||||
|
||||
expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything());
|
||||
const [message, options] = toastSuccess.mock.calls[0]!;
|
||||
expect(message).toBe("practitioners.deleted");
|
||||
expect(options.action.label).toBe("common.undo");
|
||||
options.action.onClick();
|
||||
expect(m.restore.mutateAsync).toHaveBeenCalledWith("p1");
|
||||
await Promise.resolve();
|
||||
expect(errorToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when the restore fails", async () => {
|
||||
const m = mutations(Promise.reject(new Error("offline")));
|
||||
|
||||
deleteWithUndo(m.all, "p1", t);
|
||||
toastSuccess.mock.calls[0]![1].action.onClick();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(errorToast).toHaveBeenCalledWith(
|
||||
"practitioners.restoreFailed",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
@@ -43,6 +44,33 @@ import { VISITS_PATH } from "@/lib/encounters/visit-links";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
/**
|
||||
* Delete an entry, with an Undo: the delete is soft, and the restore brings
|
||||
* the entry back.
|
||||
*/
|
||||
export function deleteWithUndo(
|
||||
{ remove, restore }: ReturnType<typeof usePractitionerMutations>,
|
||||
id: string,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
remove.mutate(id, {
|
||||
onSuccess: () =>
|
||||
toast.success(t("practitioners.deleted"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void restore.mutateAsync(id).catch((failure) =>
|
||||
showErrorToast(t("practitioners.restoreFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("practitioners.deleteFailed"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
@@ -52,7 +80,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
const { remove } = usePractitionerMutations();
|
||||
const mutations = usePractitionerMutations();
|
||||
|
||||
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Practitioner | null>(null);
|
||||
@@ -257,12 +285,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
if (!deleting) return;
|
||||
const id = deleting.id;
|
||||
setDeleting(null);
|
||||
remove.mutate(id, {
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("practitioners.deleteFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
deleteWithUndo(mutations, id, t);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
@@ -104,8 +105,7 @@ export function PractitionerSheet({
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
||||
const message = localizedApiError(err, t, "practitioners.saveFailed");
|
||||
setError(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
allergyCategoryEnum,
|
||||
allergySeverityEnum,
|
||||
@@ -66,6 +68,7 @@ export function AllergyForm({
|
||||
footerSlot,
|
||||
}: AllergyFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const post = useIdempotentPost();
|
||||
const formId = useId();
|
||||
|
||||
const [substance, setSubstance] = useState(existing?.substance ?? "");
|
||||
@@ -105,7 +108,7 @@ export function AllergyForm({
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
|
||||
: await apiPost<AllergyDTO>("/api/allergies", body);
|
||||
: await post<AllergyDTO>("/api/allergies", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("records.allergies.form.updatedToast")
|
||||
@@ -113,11 +116,7 @@ export function AllergyForm({
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("records.allergies.form.saveError");
|
||||
setError(message);
|
||||
setError(localizedApiError(err, t, "records.allergies.form.saveError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGetList } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
allergyDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import { AllergyForm } from "./allergy-form";
|
||||
import type { AllergyDTO } from "@/lib/records/dto";
|
||||
@@ -59,7 +63,7 @@ export function AllergyManager({
|
||||
mutationFn: (id: string) => apiDelete(`/api/allergies/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.allergies.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
|
||||
void invalidateKeys(queryClient, allergyDependentKeys);
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("records.allergies.deleteError"), { cause: failure }),
|
||||
@@ -78,7 +82,7 @@ export function AllergyManager({
|
||||
function afterSave() {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
|
||||
void invalidateKeys(queryClient, allergyDependentKeys);
|
||||
}
|
||||
|
||||
const rows = page?.data ?? [];
|
||||
|
||||
@@ -31,7 +31,13 @@ import { withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
allergyDependentKeys,
|
||||
illnessDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
interface AboutMeData {
|
||||
@@ -66,13 +72,12 @@ export function FreeTextTakeOver({
|
||||
const afterCreate = () => {
|
||||
setSaved(true);
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.savedToast"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
field === "conditions" ? queryKeys.illness() : queryKeys.allergies(),
|
||||
});
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.lifeCircumstances(),
|
||||
...(field === "conditions" ? illnessDependentKeys : allergyDependentKeys),
|
||||
]);
|
||||
// A chronic condition frames the dashboard and the Today digest.
|
||||
if (field === "conditions") void refetchInactiveDailyReads(queryClient);
|
||||
};
|
||||
|
||||
const createCondition = useMutation({
|
||||
@@ -103,12 +108,10 @@ export function FreeTextTakeOver({
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
|
||||
close();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.coachAboutMe(),
|
||||
queryKeys.lifeCircumstances(),
|
||||
]);
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.takeOver.clearError"), { cause: error }),
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
* Reads unwrap the envelope through `apiGet`; every key comes from the
|
||||
* factory (`queryKeys.symptom*`, under the `["illness"]` root). Every write
|
||||
* evicts the whole illness tree, because the episode detail lists the
|
||||
* occurrences filed against it and the definition summaries count them.
|
||||
* occurrences filed against it and the definition summaries count them, and
|
||||
* the day and timeline roots, which show each occurrence.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
SymptomDefinitionDTO,
|
||||
@@ -118,9 +125,24 @@ export function useSymptomEvents(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a symptom write reaches: the illness tree its keys live under, and the
|
||||
* day and the timeline, which list every occurrence. Nothing on the dashboard
|
||||
* or the Today digest reads symptoms.
|
||||
*/
|
||||
export function invalidateSymptomReads(qc: QueryClient) {
|
||||
return invalidateKeys(qc, [
|
||||
queryKeys.illness(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Not returned, so a save reports as soon as it lands rather than after
|
||||
// every read it touches has refetched.
|
||||
function useInvalidateSymptoms() {
|
||||
const qc = useQueryClient();
|
||||
return () => invalidateKeys(qc, [queryKeys.illness()]);
|
||||
return () => void invalidateSymptomReads(qc);
|
||||
}
|
||||
|
||||
export function useCreateSymptomDefinition() {
|
||||
@@ -163,11 +185,13 @@ export function useDeleteSymptomDefinition() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Under one key for as long as the entry form that mounts it is open. */
|
||||
export function useLogSymptomEvent() {
|
||||
const invalidate = useInvalidateSymptoms();
|
||||
const post = useIdempotentPost();
|
||||
return useMutation({
|
||||
mutationFn: (input: SymptomEventCreateInput) =>
|
||||
apiPost<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
post<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
customLookupOf,
|
||||
@@ -105,10 +106,9 @@ export function BoosterMintPrompt({
|
||||
});
|
||||
toastWrittenOutcome("success", t("vaccinations.booster.planned"));
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("vaccinations.booster.failed"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "vaccinations.booster.failed"), {
|
||||
cause: err,
|
||||
});
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -125,16 +126,19 @@ export function useVaccination(id: string | null) {
|
||||
*/
|
||||
export function useVaccinationMutations() {
|
||||
const qc = useQueryClient();
|
||||
// Not returned: a mutation would wait for every refetch before it settles,
|
||||
// and the sheet would stay open with its spinner until then.
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
void Promise.all([
|
||||
invalidateKeys(qc, vaccinationDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
// One key for as long as the sheet that mounts this is open.
|
||||
const post = useIdempotentPost();
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.vaccinationCreate(),
|
||||
mutationFn: (body: VaccinationWriteBody) =>
|
||||
apiPost<Vaccination>(BASE, body),
|
||||
mutationFn: (body: VaccinationWriteBody) => post<Vaccination>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { RelatedRecordsBlock } from "@/components/links/related-records";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
@@ -68,7 +70,7 @@ export function VaccinationSheet({
|
||||
onCreated?: (created: Vaccination) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update, remove } = useVaccinationMutations();
|
||||
const { create, update, remove, restore } = useVaccinationMutations();
|
||||
// The list row carries no document links; the detail read does. Until it
|
||||
// answers, the draft's links stay "untouched" and Save leaves them alone.
|
||||
const detail = useVaccination(vaccination?.id ?? null);
|
||||
@@ -99,8 +101,7 @@ export function VaccinationSheet({
|
||||
onCreated?.(created);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("vaccinations.saveFailed");
|
||||
const message = localizedApiError(err, t, "vaccinations.saveFailed");
|
||||
setError(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
@@ -198,8 +199,28 @@ export function VaccinationSheet({
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
remove.mutate(vaccination.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
const id = vaccination.id;
|
||||
remove.mutate(id, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
// The delete is soft. The Undo runs after this sheet
|
||||
// closed, so it awaits the restore itself rather than
|
||||
// passing callbacks a closed sheet never receives.
|
||||
toast.success(t("vaccinations.deleted"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void restore.mutateAsync(id).catch((failure) =>
|
||||
showErrorToast(
|
||||
t("vaccinations.restoreFailed"),
|
||||
{
|
||||
cause: failure,
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("vaccinations.deleteFailed"), {
|
||||
cause: failure,
|
||||
|
||||
@@ -128,9 +128,10 @@ describe("<ManualWorkoutForm>", () => {
|
||||
await pickSport("Running");
|
||||
await screen.getByLabelText("Minutes").fill("30");
|
||||
await save.click();
|
||||
// The form's own sentence, never the server's English prose.
|
||||
await expect
|
||||
.element(screen.getByRole("alert"))
|
||||
.toHaveTextContent("Service unavailable");
|
||||
.toHaveTextContent("The workout could not be saved");
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
|
||||
api.on("POST", BATCH, {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* helpers on their own:
|
||||
*
|
||||
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
|
||||
* the form's external id, and refreshes the workout reads;
|
||||
* the form's external id, and refreshes the workout reads, the day and
|
||||
* the timeline;
|
||||
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
|
||||
* 3. delete is offered for a hand-entered workout in one's own record only,
|
||||
* and calls `DELETE /api/workouts/{id}`.
|
||||
@@ -44,9 +45,16 @@ function fakeClient() {
|
||||
return {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
removeQueries: vi.fn(),
|
||||
getQueryCache: () => ({ findAll: () => [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function invalidatedKeys(client: ReturnType<typeof fakeClient>): string[] {
|
||||
return client.invalidateQueries.mock.calls.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
}
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -80,7 +88,7 @@ describe("saveManualWorkout — the wire", () => {
|
||||
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
|
||||
});
|
||||
|
||||
it("refreshes every workout read, and the daily reads", async () => {
|
||||
it("refreshes every workout read, the daily reads, the day and the timeline", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "inserted" }] }),
|
||||
);
|
||||
@@ -88,12 +96,29 @@ describe("saveManualWorkout — the wire", () => {
|
||||
|
||||
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
|
||||
|
||||
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
const keys = invalidatedKeys(client);
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
|
||||
// "Capture for this day" saves into an open day view.
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
|
||||
});
|
||||
|
||||
it("returns once the entry is stored, without waiting for the refetches", async () => {
|
||||
// The sheet closes on this answer; on a slow connection the reads it
|
||||
// refreshes can take seconds, and the person would watch a spinner.
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "inserted" }] }),
|
||||
);
|
||||
const client = {
|
||||
...fakeClient(),
|
||||
invalidateQueries: vi.fn(() => new Promise(() => {})),
|
||||
};
|
||||
|
||||
await expect(
|
||||
saveManualWorkout(ENTRY, client as unknown as QueryClient),
|
||||
).resolves.toBe("inserted");
|
||||
});
|
||||
|
||||
it("treats a duplicate (the same form submitted twice) as saved", async () => {
|
||||
@@ -177,8 +202,9 @@ describe("delete — a hand-entered workout in one's own record only", () => {
|
||||
expect(client.removeQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workoutDetail("w-1"),
|
||||
});
|
||||
expect(client.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workouts(),
|
||||
});
|
||||
const keys = invalidatedKeys(client);
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,12 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -38,8 +43,8 @@ export function canDeleteWorkout(
|
||||
|
||||
/**
|
||||
* Delete, then drop the deleted detail from the cache and refresh the rest of
|
||||
* the workout reads (list, dashboard tile) and the two daily reads the route
|
||||
* evicted server-side. The detail is removed rather than invalidated, so
|
||||
* the workout reads (list, dashboard tile, the day and the timeline) and the
|
||||
* two daily reads the route evicted server-side. The detail is removed rather than invalidated, so
|
||||
* nothing refetches a row that no longer exists.
|
||||
*/
|
||||
export async function deleteManualWorkout(
|
||||
@@ -48,8 +53,8 @@ export async function deleteManualWorkout(
|
||||
): Promise<void> {
|
||||
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
|
||||
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
void invalidateKeys(queryClient, workoutDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { getQuantityTransform } from "@/lib/measurements/display-transform";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
refetchInactiveDailyReads,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
buildManualWorkoutEntry,
|
||||
@@ -90,7 +95,8 @@ interface BatchResult {
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated";
|
||||
|
||||
/**
|
||||
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
|
||||
* Post the entry and start refreshing what shows workouts; the caller reports
|
||||
* the save without waiting for those reads. A `duplicate` is a success:
|
||||
* the same form already landed this session (a second tap, a retry), so the
|
||||
* row exists exactly once. A `skipped` entry was refused and throws.
|
||||
*/
|
||||
@@ -105,12 +111,11 @@ export async function saveManualWorkout(
|
||||
if (status !== "inserted" && status !== "duplicate" && status !== "updated") {
|
||||
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
|
||||
}
|
||||
// The list, the dashboard tile and every detail read sit under this
|
||||
// prefix. The form also opens from the capture picker on any page, where
|
||||
// the Today hero (which names a workout that just landed) and the dashboard
|
||||
// snapshot are unmounted, so those two are refetched outright.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
// The form also opens from the capture picker on any page, where the
|
||||
// Today hero and the dashboard snapshot are unmounted, so those two are
|
||||
// refetched outright.
|
||||
void invalidateKeys(queryClient, workoutDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -228,9 +233,7 @@ export function ManualWorkoutForm({
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("insights.workouts.manual.saveError"),
|
||||
localizedApiError(err, t, "insights.workouts.manual.saveError"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.32.19 — a Vorsorge reminder marked done (or created / edited / deleted)
|
||||
@@ -16,6 +17,7 @@ import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
function fakeQueryClient(): QueryClient {
|
||||
return {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryCache: () => ({ findAll: () => [] }),
|
||||
} as unknown as QueryClient;
|
||||
}
|
||||
|
||||
@@ -47,4 +49,25 @@ describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract
|
||||
expect(inactiveKeys).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
||||
expect(inactiveKeys).toContain(JSON.stringify(["daily", "digest"]));
|
||||
});
|
||||
|
||||
it("refetches the Today rail while it is on screen", async () => {
|
||||
// The dashboard: the check-up tile and the Today rail are both mounted,
|
||||
// where the inactive pass alone fetches nothing.
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
let reads = 0;
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
queryFn: async () => ++reads,
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
await vi.waitFor(() => expect(reads).toBe(1));
|
||||
|
||||
await invalidateReminderReads(client);
|
||||
|
||||
expect(reads).toBe(2);
|
||||
unsubscribe();
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
@@ -166,17 +167,20 @@ export function useEncounterMutations() {
|
||||
*
|
||||
* So the fan-out is the visit roots PLUS the reminder reads, through the same
|
||||
* helper the Vorsorge surfaces use, rather than a second sweep that could
|
||||
* drift from it.
|
||||
* drift from it. Not returned: a mutation would wait for every refetch
|
||||
* before it settles, and the sheet would stay open until then.
|
||||
*/
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
void Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
// One key for as long as the sheet that mounts this is open.
|
||||
const post = useIdempotentPost();
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.encounterCreate(),
|
||||
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
|
||||
mutationFn: (body: EncounterWriteBody) => post<Encounter>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -194,5 +198,13 @@ export function useEncounterMutations() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
// The Undo of a delete. A restored visit re-anchors its reminder only
|
||||
// while its date is still ahead.
|
||||
const restore = useMutation({
|
||||
mutationKey: queryKeys.encounterRestore(),
|
||||
mutationFn: (id: string) => apiPost<Encounter>(`${BASE}/${id}/restore`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, restore };
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.32.19 — the read fan-out every Vorsorge reminder mutation runs on
|
||||
@@ -31,11 +35,21 @@ import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
* (`refetchOnMount: false`) — the reminder lingered on the Today rail until
|
||||
* the 120 s poll. Force the inactive daily reads to refetch alongside the
|
||||
* reminders invalidation so the rail clears at once, the same freshness
|
||||
* contract the medication intake surfaces use.
|
||||
* contract the medication intake surfaces use. The inactive pass fetches
|
||||
* nothing for a read that is on screen, though: marked done from the
|
||||
* dashboard tile, the check-up stayed due on the Today rail above it. So
|
||||
* the two daily reads are invalidated the ordinary way as well, which
|
||||
* refetches them where they are mounted. A check-up marked done is also
|
||||
* listed on its day.
|
||||
*/
|
||||
export function invalidateReminderReads(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }),
|
||||
invalidateKeys(qc, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.dailyDigest(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dayRoot(),
|
||||
]),
|
||||
refetchInactiveDailyReads(qc),
|
||||
]);
|
||||
}
|
||||
@@ -103,7 +117,8 @@ export function useMeasurementReminders(enabled = true) {
|
||||
|
||||
export function useMeasurementReminderMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => invalidateReminderReads(qc);
|
||||
// Not returned: a mutation would wait for every refetch before it settles.
|
||||
const invalidate = () => void invalidateReminderReads(qc);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderCreate(),
|
||||
|
||||
@@ -62,9 +62,10 @@ export function usePractitionerMutations() {
|
||||
const qc = useQueryClient();
|
||||
// Renaming a practice changes the label a rail appointment renders under,
|
||||
// and the daily reads are unmounted from the address book — pair the
|
||||
// bundle invalidation with the forced inactive refetch.
|
||||
// bundle invalidation with the forced inactive refetch. Not returned, so the
|
||||
// sheet closes without waiting for the refetches.
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
void Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
refetchInactiveDailyReads(qc),
|
||||
]);
|
||||
@@ -90,5 +91,14 @@ export function usePractitionerMutations() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
// The Undo of a delete. It brings the entry back, not the links the
|
||||
// delete cleared on the visits that named it.
|
||||
const restore = useMutation({
|
||||
mutationKey: queryKeys.practitionerRestore(),
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<Practitioner>(`${BASE}/${id}/restore`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, restore };
|
||||
}
|
||||
@@ -190,6 +190,38 @@ describe("apiFetchEnvelope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiError.issuePaths", () => {
|
||||
it("names the fields a 422's issue list refused", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
data: null,
|
||||
error: "Validation failed",
|
||||
details: {
|
||||
issues: [
|
||||
{ path: "value", code: "custom", message: "out of range" },
|
||||
],
|
||||
},
|
||||
},
|
||||
422,
|
||||
),
|
||||
);
|
||||
const error = await apiPut("/api/measurements/m1", { value: 720 }).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).issuePaths).toEqual(["value"]);
|
||||
});
|
||||
|
||||
it("is empty when the refusal named no field", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ data: null, error: "Nope" }, 409),
|
||||
);
|
||||
const error = await apiGet("/api/x").catch((e: unknown) => e);
|
||||
expect((error as ApiError).issuePaths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiFetchRaw", () => {
|
||||
it("returns the raw Response without ok-check or unwrap", async () => {
|
||||
const res = new Response("plain", { status: 500 });
|
||||
|
||||
@@ -113,4 +113,51 @@ describe("localizedApiError", () => {
|
||||
),
|
||||
).toBe(translator(locale as Catalog)("measurements.saveError"));
|
||||
});
|
||||
|
||||
describe("a refusal the client can act on", () => {
|
||||
const enT = translator(en as Catalog);
|
||||
|
||||
it.each([
|
||||
["an ended session", 401, "auth.missing", "Sign in again"],
|
||||
["a rate limit", 429, "http.rate_limited", "too many requests"],
|
||||
["a missing right", 403, "http.forbidden", "permission"],
|
||||
["a rejected entry", 422, "http.invalid", "Check them"],
|
||||
])("names %s instead of the save fallback", (_, status, code, text) => {
|
||||
const error = new ApiError("raw English server prose", status, {
|
||||
errorCode: code,
|
||||
});
|
||||
expect(localizedApiError(error, enT, "measurements.saveError")).toMatch(
|
||||
new RegExp(text, "i"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a refusal without a code by its status", () => {
|
||||
// A reverse proxy's 429 carries no envelope at all.
|
||||
const error = new ApiError("Request failed (429)", 429);
|
||||
expect(localizedApiError(error, enT, "measurements.saveError")).toBe(
|
||||
enT("apiErrors.http.rate_limited"),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", de],
|
||||
["es", es],
|
||||
["fr", fr],
|
||||
["it", itCatalog],
|
||||
["pl", pl],
|
||||
])("has its own sentence in %s", (_, locale) => {
|
||||
const localT = translator(locale as Catalog);
|
||||
for (const code of [
|
||||
"auth.missing",
|
||||
"http.unauthenticated",
|
||||
"http.rate_limited",
|
||||
"http.forbidden",
|
||||
"http.invalid",
|
||||
]) {
|
||||
const sentence = localT(`apiErrors.${code}`);
|
||||
expect(sentence).not.toBe(`apiErrors.${code}`);
|
||||
expect(sentence).not.toBe(enT(`apiErrors.${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A second tap on Save after the first answer was lost must not store the
|
||||
* entry twice. The per-call key the transport mints changes with every
|
||||
* call, so a create form sends one key until a save lands.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useIdempotentPost } from "../use-idempotent-post";
|
||||
|
||||
type Post = ReturnType<typeof useIdempotentPost>;
|
||||
|
||||
function openForm(): Post {
|
||||
let post: Post | null = null;
|
||||
function Probe() {
|
||||
post = useIdempotentPost();
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(<Probe />);
|
||||
return post!;
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function keyOf(call: number): string | null {
|
||||
const init = fetchMock.mock.calls[call]![1] as RequestInit;
|
||||
return new Headers(init.headers).get("Idempotency-Key");
|
||||
}
|
||||
|
||||
const stored = () =>
|
||||
new Response(JSON.stringify({ data: { id: "e1" }, error: null }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
beforeEach(() => vi.stubGlobal("fetch", fetchMock));
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useIdempotentPost", () => {
|
||||
it("sends the first key again when the first answer never arrived", async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new DOMException("timed out", "TimeoutError"))
|
||||
.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
|
||||
expect(keyOf(0)).toBeTruthy();
|
||||
expect(keyOf(1)).toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("takes a new key for the next entry once a save landed", async () => {
|
||||
fetchMock.mockResolvedValueOnce(stored()).mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
|
||||
expect(keyOf(1)).not.toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("keeps a header the caller set", async () => {
|
||||
fetchMock.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await post("/api/x", {}, { headers: { Prefer: "respond-async" } });
|
||||
|
||||
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
||||
expect(new Headers(init.headers).get("Prefer")).toBe("respond-async");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@
|
||||
* through `readError` (server envelope `error` string, falling back
|
||||
* to `Request failed (<status>)`), `status` carrying the HTTP code,
|
||||
* `meta` carrying the error envelope's `meta` object when present
|
||||
* (e.g. `meta.errorCode`).
|
||||
* (e.g. `meta.errorCode`), `issuePaths` the fields a 422's
|
||||
* `details.issues` names.
|
||||
* - Network failures reject with the native `TypeError` from `fetch`,
|
||||
* exactly as before — only HTTP-level failures become `ApiError`.
|
||||
* - 401 / 403 / 429 are NOT special-cased: the wrapper throws the same
|
||||
@@ -70,17 +71,24 @@ export class ApiError extends Error {
|
||||
* server). Its first characters are the error id a person reads out.
|
||||
*/
|
||||
readonly requestId: string | null;
|
||||
/**
|
||||
* The fields a refusal's `details.issues` names (`"value"`, `"measuredAt"`),
|
||||
* so a form can mark the one the server refused. Empty when it named none.
|
||||
*/
|
||||
readonly issuePaths: readonly string[];
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
meta?: Record<string, unknown>,
|
||||
requestId?: string | null,
|
||||
issuePaths: readonly string[] = [],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.meta = meta;
|
||||
this.issuePaths = issuePaths;
|
||||
const fromMeta =
|
||||
typeof meta?.requestId === "string" && meta.requestId.length > 0
|
||||
? meta.requestId
|
||||
@@ -97,11 +105,21 @@ interface Envelope<T, M = Record<string, unknown>> {
|
||||
|
||||
async function throwApiError(res: Response): Promise<never> {
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
let issuePaths: string[] = [];
|
||||
try {
|
||||
const json = (await res.clone().json()) as { meta?: unknown };
|
||||
const json = (await res.clone().json()) as {
|
||||
meta?: unknown;
|
||||
details?: { issues?: unknown };
|
||||
};
|
||||
if (json && typeof json.meta === "object" && json.meta !== null) {
|
||||
meta = json.meta as Record<string, unknown>;
|
||||
}
|
||||
const issues = json?.details?.issues;
|
||||
if (Array.isArray(issues)) {
|
||||
issuePaths = issues.flatMap((issue: { path?: unknown }) =>
|
||||
typeof issue?.path === "string" ? [issue.path] : [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body — readError supplies the fallback message */
|
||||
}
|
||||
@@ -110,6 +128,7 @@ async function throwApiError(res: Response): Promise<never> {
|
||||
res.status,
|
||||
meta,
|
||||
res.headers.get("x-request-id"),
|
||||
issuePaths,
|
||||
);
|
||||
// A server-side failure is worth an id in the session's support info,
|
||||
// whether or not the call site ends up showing it.
|
||||
@@ -243,12 +262,11 @@ export function apiGet<T = unknown>(
|
||||
* Attach a per-call `Idempotency-Key` unless the caller already set one.
|
||||
*
|
||||
* Every `apiHandler` route accepts the header, and the write routes
|
||||
* wrapped in `withIdempotency` dedupe on it — but almost no client call
|
||||
* site sent one, so a transport-level replay of a create (a retried
|
||||
* request, a re-sent form submission) could land a duplicate dose or
|
||||
* vaccination and shift every derived series. Minting the key here
|
||||
* protects every `apiPost` / `apiPatch` surface at once; a caller with a
|
||||
* real natural key still wins by setting the header itself.
|
||||
* wrapped in `withIdempotency` dedupe on it. A key minted per call covers a
|
||||
* transport-level replay of that one request. A second tap on Save is a
|
||||
* new call and gets a new key, so a create form sends one key for as long
|
||||
* as it is open (`useIdempotentPost`); a caller that sets the header
|
||||
* itself always wins.
|
||||
*/
|
||||
function withDefaultIdempotencyKey(init: RequestInit): RequestInit {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { ApiError } from "./api-fetch";
|
||||
import { defaultErrorCodeForStatus } from "./default-error-codes";
|
||||
import { translateCode } from "@/lib/i18n/translate-code";
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
/**
|
||||
* Resolve an API envelope's stable error code through the locale catalog.
|
||||
* Missing/unknown codes, network failures, and non-API errors deliberately
|
||||
* collapse to the caller's safe domain fallback; server prose is never shown.
|
||||
* A refusal without a code of its own (a proxy's 429, an answer without the
|
||||
* envelope) is read by its status, the way the server labels one, so an
|
||||
* ended session or a rate limit is named as such. Unknown codes, network
|
||||
* failures, and non-API errors deliberately collapse to the caller's safe
|
||||
* domain fallback; server prose is never shown.
|
||||
*/
|
||||
export function localizedApiError(
|
||||
error: unknown,
|
||||
t: Translate,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
const errorCode =
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
const errorCode = !(error instanceof ApiError)
|
||||
? null
|
||||
: typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: null;
|
||||
: defaultErrorCodeForStatus(error.status);
|
||||
return translateCode(t, "apiErrors", errorCode, fallbackKey);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
|
||||
/**
|
||||
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
|
||||
*
|
||||
* The transport mints a fresh key per call, which covers a replayed request
|
||||
* but not a second tap on Save: when the first answer never arrived (the 15 s
|
||||
* timeout on a weak signal) the entry is already stored, and the retry is a
|
||||
* new call with a new key, so the server stored it twice. Every submit
|
||||
* through this function sends the same key until one succeeds, so the retry
|
||||
* replays the first answer instead; a retry with a changed body is a
|
||||
* different request and runs as one. The next entry gets a new key.
|
||||
*/
|
||||
export function useIdempotentPost() {
|
||||
const key = useRef<string | null>(null);
|
||||
return async function post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
key.current ??= randomId();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Idempotency-Key", key.current);
|
||||
const result = await apiPost<T>(path, body, { ...init, headers });
|
||||
key.current = null;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The day view and the timeline read every record table, and the day query
|
||||
* stays fresh for five minutes because every record write evicts its root.
|
||||
* A write whose invalidation leaves the two roots out keeps an open day and
|
||||
* the timeline on their pre-write answer: "Capture for this day" saved a
|
||||
* symptom or a workout and the day showed the old list.
|
||||
*/
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { invalidateSymptomReads } from "@/components/symptoms/use-symptoms";
|
||||
import {
|
||||
allergyDependentKeys,
|
||||
cycleDependentKeys,
|
||||
documentDependentKeys,
|
||||
encounterDependentKeys,
|
||||
illnessDependentKeys,
|
||||
labDependentKeys,
|
||||
measurementDependentKeys,
|
||||
medicationDependentKeys,
|
||||
moodDependentKeys,
|
||||
queryKeys,
|
||||
vaccinationDependentKeys,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
const RECORD_BUNDLES = {
|
||||
allergy: allergyDependentKeys,
|
||||
cycle: cycleDependentKeys,
|
||||
document: documentDependentKeys,
|
||||
encounter: encounterDependentKeys,
|
||||
illness: illnessDependentKeys,
|
||||
lab: labDependentKeys,
|
||||
measurement: measurementDependentKeys,
|
||||
medication: medicationDependentKeys,
|
||||
mood: moodDependentKeys,
|
||||
vaccination: vaccinationDependentKeys,
|
||||
workout: workoutDependentKeys,
|
||||
};
|
||||
|
||||
describe("record writes reach the day view and the timeline", () => {
|
||||
it.each(Object.entries(RECORD_BUNDLES))(
|
||||
"a %s write evicts the day and timeline roots",
|
||||
(_name, bundle) => {
|
||||
expect(bundle).toContainEqual(queryKeys.dayRoot());
|
||||
expect(bundle).toContainEqual(queryKeys.timelineRoot());
|
||||
},
|
||||
);
|
||||
|
||||
it("a symptom write marks an open day and the timeline stale", async () => {
|
||||
const client = new QueryClient();
|
||||
const day = queryKeys.day("2026-11-10");
|
||||
const lanes = queryKeys.timeline("week", null, null, "");
|
||||
client.setQueryData(day, { sections: [] });
|
||||
client.setQueryData(lanes, { lanes: [] });
|
||||
|
||||
await invalidateSymptomReads(client);
|
||||
|
||||
expect(client.getQueryState(day)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(lanes)?.isInvalidated).toBe(true);
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,9 @@ export const encounterKeys = {
|
||||
encounterCreate: () => ["encounters", "create"] as const,
|
||||
encounterUpdate: () => ["encounters", "update"] as const,
|
||||
encounterDelete: () => ["encounters", "delete"] as const,
|
||||
encounterRestore: () => ["encounters", "restore"] as const,
|
||||
practitionerCreate: () => ["practitioners", "create"] as const,
|
||||
practitionerUpdate: () => ["practitioners", "update"] as const,
|
||||
practitionerDelete: () => ["practitioners", "delete"] as const,
|
||||
practitionerRestore: () => ["practitioners", "restore"] as const,
|
||||
};
|
||||
@@ -359,6 +359,52 @@ export const vaccinationDependentKeys = [
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a workout is logged, edited or deleted. The Today
|
||||
* digest names a workout that just arrived and the write evicts the server's
|
||||
* daily caches, so the daily reads ride along; pair call sites with
|
||||
* `refetchInactiveDailyReads`, because both are unmounted wherever the form
|
||||
* opens outside the dashboard.
|
||||
*/
|
||||
export const workoutDependentKeys = [
|
||||
queryKeys.workouts(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dailyDigest(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a lab reading is created, edited, deleted or restored,
|
||||
* or a marker is renamed, hidden, merged or deleted. The marker list carries
|
||||
* each marker's latest reading, and the "what changed since your last panel"
|
||||
* card compares the two newest panels.
|
||||
*/
|
||||
export const labDependentKeys = [
|
||||
queryKeys.labResults(),
|
||||
queryKeys.biomarkers(),
|
||||
queryKeys.insightsLabsChanges(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/** Keys invalidated when an allergy is created, edited or deleted. */
|
||||
export const allergyDependentKeys = [
|
||||
queryKeys.allergies(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a document is uploaded, imported, edited, deleted or
|
||||
* restored. A document's date places it on its day and on the timeline.
|
||||
*/
|
||||
export const documentDependentKeys = [
|
||||
queryKeys.documents(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when an input of the `ai` block on `GET /api/auth/me`
|
||||
* changes: the account's own provider (key, local model, compatible endpoint,
|
||||
@@ -385,7 +431,8 @@ export const aiInputDependentKeys = [
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
* `Promise.all(keys.map(...))` everywhere.
|
||||
* `Promise.all(keys.map(...))` everywhere. A key listed twice (two bundles
|
||||
* spread into one list share the day and timeline roots) is invalidated once.
|
||||
*
|
||||
* Uses `allSettled` so one transient network failure doesn't abort subsequent
|
||||
* invalidations (cache would otherwise be left half-stale) and so the `void
|
||||
@@ -404,8 +451,11 @@ export function invalidateKeys(
|
||||
queryClient: QueryClient,
|
||||
keys: readonly QueryKey[],
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
const unique = new Map(keys.map((key) => [JSON.stringify(key), key]));
|
||||
return Promise.allSettled(
|
||||
keys.map((queryKey) => invalidateKey(queryClient, queryKey)),
|
||||
[...unique.values()].map((queryKey) =>
|
||||
invalidateKey(queryClient, queryKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -441,9 +491,8 @@ async function invalidateKey(
|
||||
* remount re-fetches under `refetchOnMount: false`.
|
||||
*
|
||||
* Originates from the v1.16.11 dashboard-snapshot fix and the v1.29.1
|
||||
* medication-intake fix (`invalidateMedicationReads` in
|
||||
* `components/medications/use-medication-intake.ts`, which predates this
|
||||
* shared helper and keeps its own inline copy). Call this alongside
|
||||
* medication-intake fix (`invalidateMedicationReads` below wraps the pair
|
||||
* for medication writes). Call this alongside
|
||||
* `invalidateKeys(queryClient, xDependentKeys)` from any write whose server
|
||||
* route already hard-evicts the snapshot bucket — the forced refetch then
|
||||
* returns post-write data immediately, no server change needed.
|
||||
|
||||
Reference in new issue
Block a user