Merge branch 'fix/v1473-writes' into release/v1.47.3

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:18:58 +01:00
commit 6a02cda210
73 files changed
+1414 -356

No files matched your search

+24 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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],
);
+39 -14
View File
@@ -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);
},
});
+23 -5
View File
@@ -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>,
+3 -5
View File
@@ -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);
},
+10 -15
View File
@@ -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) =>
+4 -2
View File
@@ -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 }
+2 -9
View File
@@ -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) =>
+7 -6
View File
@@ -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);
},
});
+5 -4
View File
@@ -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 });
}
+6 -7
View File
@@ -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);
}
+7 -3
View File
@@ -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 ?? [];
+17 -14
View File
@@ -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 }),
+28 -4
View File
@@ -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);
}
/**
+15 -12
View File
@@ -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();
});
});
+16 -4
View File
@@ -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 };
}
+19 -4
View File
@@ -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(),
+13 -3
View File
@@ -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 };
}
+32
View File
@@ -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");
});
});
+26 -8
View File
@@ -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);
+10 -5
View File
@@ -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);
}
+33
View File
@@ -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();
});
});
+2
View File
@@ -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,
};
+54 -5
View File
@@ -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.