diff --git a/messages/de.json b/messages/de.json index af2696232..7e7762cc3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index f2431efbd..385bed6dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 53854bcb5..6b9bbcf62 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 7ef41308c..cafde4a52 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 0914581c9..a2db0d7ea 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index ca64435fc..ac7b3cd10 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/nl.json b/messages/nl.json index cd9ea2f36..1a5fd2353 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index d0f197732..bf2a68591 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 4c63dec15..f49440a12 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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": { diff --git a/messages/ru.json b/messages/ru.json index 0ccc1e798..413260700 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -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": { diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 3fd584a0a..62f62b42f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 684b663f2..e3107e593 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 95e2363c7..514275f8b 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -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", diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 11879bff6..5cb574db0 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.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". diff --git a/src/__tests__/intake-toast-single-decision.test.ts b/src/__tests__/intake-toast-single-decision.test.ts index 0c3927a20..37d5faf6d 100644 --- a/src/__tests__/intake-toast-single-decision.test.ts +++ b/src/__tests__/intake-toast-single-decision.test.ts @@ -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(); + 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; } diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8683ff27f..27b150432 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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 }, diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 74316b483..4105720f0 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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} diff --git a/src/components/custom-metrics/custom-metric-entry-form.tsx b/src/components/custom-metrics/custom-metric-entry-form.tsx index b8236d3cf..15078ba6e 100644 --- a/src/components/custom-metrics/custom-metric-entry-form.tsx +++ b/src/components/custom-metrics/custom-metric-entry-form.tsx @@ -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( + await post( `/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); } diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index 5c59f4e1e..ee599f7b1 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -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); } diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index 5e2b60dfa..01930fea3 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -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")); }, }); diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 584b08b36..28ab5a317 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -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. diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index c67561b12..0c662b618 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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); diff --git a/src/components/documents/__tests__/confirm-facts-invalidation.test.ts b/src/components/documents/__tests__/confirm-facts-invalidation.test.ts new file mode 100644 index 000000000..43391c745 --- /dev/null +++ b/src/components/documents/__tests__/confirm-facts-invalidation.test.ts @@ -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); + }); +}); diff --git a/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx new file mode 100644 index 000000000..a698cb5be --- /dev/null +++ b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx @@ -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 }) => ( +
{children}
+ ), +})); + +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( + + undefined} + title="Link documents" + description="Pick documents" + searchPlaceholder="Search" + emptyLabel="No documents" + isPicked={() => false} + onToggle={() => undefined} + /> + , + ); +} + +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"'); + }); +}); diff --git a/src/components/documents/detail/use-document-detail.ts b/src/components/documents/detail/use-document-detail.ts index ff269a6d2..2006ef0ec 100644 --- a/src/components/documents/detail/use-document-detail.ts +++ b/src/components/documents/detail/use-document-detail.ts @@ -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, diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx index 6794e3650..a14908f5a 100644 --- a/src/components/documents/document-picker-sheet.tsx +++ b/src/components/documents/document-picker-sheet.tsx @@ -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 ( )} + {cut && !list.isError ? ( +

+ {t("documents.picker.capped", { + count: String(documents.length), + })} +

+ ) : null}
); diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 7b9732d7e..d3c224335 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -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); diff --git a/src/components/documents/trash/use-recently-deleted.ts b/src/components/documents/trash/use-recently-deleted.ts index 139fca599..215395abc 100644 --- a/src/components/documents/trash/use-recently-deleted.ts +++ b/src/components/documents/trash/use-recently-deleted.ts @@ -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], ); diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts index 2ab18b787..999092376 100644 --- a/src/components/documents/use-document-facts.ts +++ b/src/components/documents/use-document-facts.ts @@ -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), }); } diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts index 7eb16d7b2..7cba8016a 100644 --- a/src/components/documents/use-document-upload.ts +++ b/src/components/documents/use-document-upload.ts @@ -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")); } diff --git a/src/components/documents/vault/use-vault-bulk.ts b/src/components/documents/vault/use-vault-bulk.ts index 4b5cff351..f6a4cb433 100644 --- a/src/components/documents/vault/use-vault-bulk.ts +++ b/src/components/documents/vault/use-vault-bulk.ts @@ -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); }, }); diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 4c0eefcd0..8ce3a0bf1 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -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({ { - 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, diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index b92bece18..d3712ea05 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -91,7 +91,6 @@ function render( mode={options.mode ?? "vision"} pdfSupported={options.pdfSupported ?? true} consentRequired={options.consentRequired} - onCommitted={() => {}} /> , diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index c69d11aba..53eb31e58 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -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 ?? []; diff --git a/src/components/labs/biomarker-merge-sheet.tsx b/src/components/labs/biomarker-merge-sheet.tsx index 6073c4d91..1a4cc9f1d 100644 --- a/src/components/labs/biomarker-merge-sheet.tsx +++ b/src/components/labs/biomarker-merge-sheet.tsx @@ -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); }, diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index cfff0826d..9eb8e5909 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -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) => diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 83652c303..f28ccc36e 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -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("/api/labs", { + const created = await post("/api/labs", { biomarkerId, ...(isQualitative ? { valueText: qualitativeValue } diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 130e85faa..56dbcf6a0 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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) { diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 5ac645ec2..4b7247bf9 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -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) => diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 3963e48c9..989b8b3cc 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -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 : []), + ]); }, }); } diff --git a/src/components/measurements/__tests__/measurement-edit-range.test.ts b/src/components/measurements/__tests__/measurement-edit-range.test.ts new file mode 100644 index 000000000..fa62aef1d --- /dev/null +++ b/src/components/measurements/__tests__/measurement-edit-range.test.ts @@ -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(); + }); +}); diff --git a/src/components/measurements/list/measurement-edit-sheet.tsx b/src/components/measurements/list/measurement-edit-sheet.tsx index 8a8200370..f0576b6e6 100644 --- a/src/components/measurements/list/measurement-edit-sheet.tsx +++ b/src/components/measurements/list/measurement-edit-sheet.tsx @@ -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 + } /> +
diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts index e7ae637e7..8a524db5c 100644 --- a/src/components/measurements/list/use-measurement-edit.ts +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -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, + 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["t"]; + +/** The sentence for a value outside its band, in the unit the input shows. */ +function outOfRangeMessage( + display: Pick, + 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(null); const [editValue, setEditValue] = useState(""); @@ -105,10 +163,13 @@ export function useMeasurementEdit({ notes: "", }); const [editError, setEditError] = useState(null); + // A refused value is said under the value input, not in the banner. + const [editValueError, setEditValueError] = useState(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(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, diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 76e7b2f95..c3a89b4a6 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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 { diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index d275f31c3..0cdebd557 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -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); }, }); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 2fecf8747..28c14dbc3 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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("/api/mood-entries", { + const saved = await post("/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 { diff --git a/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts new file mode 100644 index 000000000..fbe6aa48c --- /dev/null +++ b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts @@ -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[0]; + +const t = (key: string) => key; + +function mutations(restoreResult: Promise) { + 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(), + ), + ); + }); +}); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index b305ed15a..af361afe1 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -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, + 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(null); const [deleting, setDeleting] = useState(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")} diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index f9654f691..54118fa5a 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -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 }); } diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index 341c01b3d..c4b979e2a 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -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(`/api/allergies/${existing.id}`, body) - : await apiPost("/api/allergies", body); + : await post("/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); } diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index c6a1ed919..9d3a09f30 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -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 ?? []; diff --git a/src/components/records/free-text-take-over.tsx b/src/components/records/free-text-take-over.tsx index e52f2f608..9574ee6ed 100644 --- a/src/components/records/free-text-take-over.tsx +++ b/src/components/records/free-text-take-over.tsx @@ -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 }), diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 9c900f10c..286cdaed1 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -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("/api/symptoms/events", input), + post("/api/symptoms/events", input), onSuccess: invalidate, }); } diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 49707c3ef..3f1d38390 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -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(); } diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index c67f2b77f..5d4258401 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -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(BASE, body), + mutationFn: (body: VaccinationWriteBody) => post(BASE, body), onSuccess: invalidate, }); diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 40a8cce42..a63972299 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -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({ { - 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, diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx index 305a63e6d..de5105900 100644 --- a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -128,9 +128,10 @@ describe("", () => { 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, { diff --git a/src/components/workouts/__tests__/manual-workout-save.test.ts b/src/components/workouts/__tests__/manual-workout-save.test.ts index e04b69829..b7a97beba 100644 --- a/src/components/workouts/__tests__/manual-workout-save.test.ts +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -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): string[] { + return client.invalidateQueries.mock.calls.map(([arg]) => + JSON.stringify((arg as { queryKey: unknown }).queryKey), + ); +} + let fetchSpy: ReturnType; 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())); }); }); diff --git a/src/components/workouts/delete-workout-button.tsx b/src/components/workouts/delete-workout-button.tsx index adfdcb303..b04d1c417 100644 --- a/src/components/workouts/delete-workout-button.tsx +++ b/src/components/workouts/delete-workout-button.tsx @@ -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 { 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); } /** diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 72e618a4e..363d200c6 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -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); diff --git a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts index 56f32ef2f..4c4f01db1 100644 --- a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts +++ b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts @@ -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(); + }); }); diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index dc947d9de..a9cccc0b1 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -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(BASE, body), + mutationFn: (body: EncounterWriteBody) => post(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(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index b42a564c8..d60766302 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -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 { 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(), diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index bab014841..3fdd4145e 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -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(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index e526e6f12..e876a0ee2 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -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 }); diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index df0597295..b828bf842 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -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}`)); + } + }); + }); }); diff --git a/src/lib/api/__tests__/use-idempotent-post.test.tsx b/src/lib/api/__tests__/use-idempotent-post.test.tsx new file mode 100644 index 000000000..cd307c480 --- /dev/null +++ b/src/lib/api/__tests__/use-idempotent-post.test.tsx @@ -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; + +function openForm(): Post { + let post: Post | null = null; + function Probe() { + post = useIdempotentPost(); + return null; + } + renderToStaticMarkup(); + 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"); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 6f2648c1d..15f2e28a2 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -17,7 +17,8 @@ * through `readError` (server envelope `error` string, falling back * to `Request failed ()`), `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, 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> { async function throwApiError(res: Response): Promise { let meta: Record | 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; } + 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 { 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( * 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); diff --git a/src/lib/api/localized-error.ts b/src/lib/api/localized-error.ts index 085429ee2..ee102ad73 100644 --- a/src/lib/api/localized-error.ts +++ b/src/lib/api/localized-error.ts @@ -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); } diff --git a/src/lib/api/use-idempotent-post.ts b/src/lib/api/use-idempotent-post.ts new file mode 100644 index 000000000..4bd1ac40a --- /dev/null +++ b/src/lib/api/use-idempotent-post.ts @@ -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(null); + return async function post( + path: string, + body?: unknown, + init?: RequestInit, + ): Promise { + key.current ??= randomId(); + const headers = new Headers(init?.headers); + headers.set("Idempotency-Key", key.current); + const result = await apiPost(path, body, { ...init, headers }); + key.current = null; + return result; + }; +} diff --git a/src/lib/query-keys/__tests__/record-write-day-keys.test.ts b/src/lib/query-keys/__tests__/record-write-day-keys.test.ts new file mode 100644 index 000000000..64f54253a --- /dev/null +++ b/src/lib/query-keys/__tests__/record-write-day-keys.test.ts @@ -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(); + }); +}); diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index 84fab90e2..7abc4b10a 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -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, }; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index f3423a5ac..57e244539 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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[]> { + 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.