mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their stubs. A nightly job (03:20) and the connect backfill read the timeline, visits and residency endpoints through safeFetch and store one row of daily values per local day: distances, places visited, minutes away from the home place and the sealed country with its travel-day verdict. A private instance needs its exact origin in DAWARICH_PRIVATE_ORIGINS. The connection joins the shared ledger and the connection health model, watched for errors only. The day carries a movement block, the model projection drops the travel country, the two movement channels join the correlation matrix under the environment module, travel days become trip suggestions, and the days travel in the environment backup section.
This commit is contained in:
1 parent
1e7fc08f57
commit
b838ed4403
95 files changed
+4466
-158
No files matched your search
@@ -70,6 +70,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# for a self-hosted Nightscout that is not reachable from the public internet.
|
||||
# NIGHTSCOUT_PRIVATE_ORIGINS=""
|
||||
|
||||
# Dawarich instances on your own network that the movement-context connection
|
||||
# may reach, comma-separated exact origins (e.g. "http://dawarich.lan:3000").
|
||||
# Each entry is one scheme://host[:port], never a suffix or a range. A public
|
||||
# Dawarich instance needs nothing here.
|
||||
# DAWARICH_PRIVATE_ORIGINS=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification targets on a private network -- optional
|
||||
|
||||
@@ -230,6 +230,11 @@ x-healthlog-env: &healthlog_env
|
||||
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
|
||||
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
|
||||
NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}"
|
||||
# Dawarich instances on your own network that the movement-context
|
||||
# connection may reach, comma-separated exact origins (e.g.
|
||||
# "http://dawarich.lan:3000"). Without it a LAN/private Dawarich URL is
|
||||
# refused as an SSRF target; a public instance needs nothing here.
|
||||
DAWARICH_PRIVATE_ORIGINS: "${DAWARICH_PRIVATE_ORIGINS:-}"
|
||||
# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own
|
||||
# network, comma-separated exact origins (e.g. "https://gotify.example.com,
|
||||
# http://ntfy.lan:8080"). Without it a target that resolves to a private
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Ortszeitraum entfernen",
|
||||
"removeConfirmTitle": "Diese Reise entfernen?",
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet.",
|
||||
"range": "{start} bis {end}"
|
||||
"range": "{start} bis {end}",
|
||||
"suggestionsIntro": "Dawarich hat Tage im Ausland gefunden, die noch zu keinem Zeitraum gehören.",
|
||||
"suggestionUse": "Übernehmen",
|
||||
"suggestionDismiss": "Vorschlag verwerfen"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Fehlerserie: {count} von {threshold}",
|
||||
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Bewegung",
|
||||
"dawarichDescription": "Verbinde deine Dawarich-Instanz, um Tagesstrecke, Zeit außer Haus und Reisetage zu übernehmen.",
|
||||
"dawarichUrl": "Dawarich-Adresse",
|
||||
"dawarichApiKey": "API-Schlüssel",
|
||||
"dawarichApiKeyHelp": "Du findest ihn in Dawarich unter Konto. HealthLog liest damit nur Tageswerte, nie Koordinaten oder Ortsnamen.",
|
||||
"dawarichConnect": "Verbinden",
|
||||
"dawarichUpdate": "Änderungen speichern",
|
||||
"dawarichConnected": "Dawarich verbunden. Die letzten 30 Tage werden gerade geladen.",
|
||||
"dawarichSync": "Jetzt abrufen",
|
||||
"dawarichSyncQueued": "Die letzten sieben Tage werden gleich abgerufen.",
|
||||
"dawarichSyncAlreadyQueued": "Ein Abruf läuft bereits.",
|
||||
"dawarichDisconnect": "Trennen",
|
||||
"dawarichDisconnectTitle": "Dawarich trennen?",
|
||||
"dawarichDisconnectDescription": "Künftige Abrufe stoppen. Bereits gespeicherte Tage bleiben erhalten.",
|
||||
"dawarichDisconnectDelete": "Trennen und Tage löschen",
|
||||
"dawarichDisconnectDeleteTitle": "Trennen und alle Tage löschen?",
|
||||
"dawarichDisconnectDeleteDescription": "Die Verbindung und alle gespeicherten Bewegungstage werden entfernt. Das lässt sich nicht rückgängig machen.",
|
||||
"dawarichHomePlaceSet": "Zuhause ist festgelegt.",
|
||||
"dawarichHomePlaceMissing": "Lege dein Zuhause fest, damit die Zeit außer Haus gemessen werden kann.",
|
||||
"dawarichHomeCountryIs": "Heimatland: {country}.",
|
||||
"dawarichHomeCountryMissing": "Noch kein Heimatland, daher keine Reisetage.",
|
||||
"dawarichDaysStored": "Gespeicherte Tage: {count}",
|
||||
"dawarichHomePlace": "Zuhause",
|
||||
"dawarichHomePlaceChoose": "Ort aus Dawarich wählen",
|
||||
"dawarichHomePlaceChange": "Zuhause wählen",
|
||||
"dawarichHomeCountry": "Heimatland",
|
||||
"dawarichHomeCountryHelp": "Zweistelliger Ländercode, etwa DE. Leer gelassen gilt das Land, in dem du im letzten Jahr am häufigsten warst.",
|
||||
"dawarichHomeSave": "Speichern",
|
||||
"dawarichHomeSaved": "Gespeichert.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Diese Adresse funktioniert nicht. Nutze die Adresse der Instanz ohne Zugangsdaten.",
|
||||
"privateOrigin": "Diese Instanz liegt in einem privaten Netz. Bitte den Betreiber des Servers, sie freizugeben.",
|
||||
"credentials": "Dawarich hat den API-Schlüssel nicht angenommen.",
|
||||
"unreachable": "Dawarich ist nicht erreichbar. Prüfe die Adresse und ob die Instanz läuft.",
|
||||
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es dann erneut.",
|
||||
"homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.",
|
||||
"notConnected": "Dawarich ist nicht verbunden.",
|
||||
"generic": "Das hat nicht geklappt. Versuche es erneut."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "Beifuß",
|
||||
"olive": "Olive",
|
||||
"ragweed": "Ambrosia"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Zeit außer Haus",
|
||||
"distance": "Zurückgelegte Strecke"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "Laufend an diesem Tag",
|
||||
"values": "Werte",
|
||||
"happened": "Was war"
|
||||
"happened": "Was war",
|
||||
"movement": "Unterwegs"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Höchster Tageswert seit {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Heute",
|
||||
"hasEntries": "mit Einträgen",
|
||||
"dayNSince": "Tag {n}, seit {date}",
|
||||
"sinceDate": "Seit {date}"
|
||||
"sinceDate": "Seit {date}",
|
||||
"movement": {
|
||||
"total": "Strecke",
|
||||
"driving": "Mit dem Auto",
|
||||
"train": "Mit der Bahn",
|
||||
"places": "Besuchte Orte",
|
||||
"away": "Außer Haus",
|
||||
"travel": "Reisetag",
|
||||
"travelYes": "Ja",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Zeitleiste",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Remove location period",
|
||||
"removeConfirmTitle": "Remove this trip?",
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes.",
|
||||
"range": "{start} to {end}"
|
||||
"range": "{start} to {end}",
|
||||
"suggestionsIntro": "Dawarich found days abroad that no period covers yet.",
|
||||
"suggestionUse": "Use",
|
||||
"suggestionDismiss": "Dismiss suggestion"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Failure streak: {count} of {threshold}",
|
||||
"copyFailed": "Could not copy. Select the text and copy it by hand."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movement",
|
||||
"dawarichDescription": "Connect your Dawarich instance to bring in daily distance, time away from home and travel days.",
|
||||
"dawarichUrl": "Dawarich address",
|
||||
"dawarichApiKey": "API key",
|
||||
"dawarichApiKeyHelp": "You find it in Dawarich under Account. HealthLog uses it to read daily values only, never coordinates or place names.",
|
||||
"dawarichConnect": "Connect",
|
||||
"dawarichUpdate": "Save changes",
|
||||
"dawarichConnected": "Dawarich connected. The last 30 days are loading now.",
|
||||
"dawarichSync": "Fetch now",
|
||||
"dawarichSyncQueued": "The last seven days will be fetched shortly.",
|
||||
"dawarichSyncAlreadyQueued": "A fetch is already on its way.",
|
||||
"dawarichDisconnect": "Disconnect",
|
||||
"dawarichDisconnectTitle": "Disconnect Dawarich?",
|
||||
"dawarichDisconnectDescription": "Future fetches stop. Days already stored stay.",
|
||||
"dawarichDisconnectDelete": "Disconnect and delete days",
|
||||
"dawarichDisconnectDeleteTitle": "Disconnect and delete every day?",
|
||||
"dawarichDisconnectDeleteDescription": "The connection and every stored movement day are removed. This cannot be undone.",
|
||||
"dawarichHomePlaceSet": "Home is set.",
|
||||
"dawarichHomePlaceMissing": "Set your home so time away from home can be measured.",
|
||||
"dawarichHomeCountryIs": "Home country: {country}.",
|
||||
"dawarichHomeCountryMissing": "No home country yet, so no travel days.",
|
||||
"dawarichDaysStored": "Days stored: {count}",
|
||||
"dawarichHomePlace": "Home",
|
||||
"dawarichHomePlaceChoose": "Choose a place from Dawarich",
|
||||
"dawarichHomePlaceChange": "Choose home",
|
||||
"dawarichHomeCountry": "Home country",
|
||||
"dawarichHomeCountryHelp": "Two-letter country code, such as DE. Left empty, the country you spent most days in over the last year counts.",
|
||||
"dawarichHomeSave": "Save",
|
||||
"dawarichHomeSaved": "Saved.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "That address does not work. Use the instance address without credentials.",
|
||||
"privateOrigin": "This instance is on a private network. Ask the server operator to approve it.",
|
||||
"credentials": "Dawarich did not accept the API key.",
|
||||
"unreachable": "Dawarich cannot be reached. Check the address and that the instance is running.",
|
||||
"rateLimited": "Too many attempts. Wait a moment, then try again.",
|
||||
"homePlaceUnknown": "The Dawarich instance no longer knows this place.",
|
||||
"notConnected": "Dawarich is not connected.",
|
||||
"generic": "That did not work. Try again."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "mugwort",
|
||||
"olive": "olive",
|
||||
"ragweed": "ragweed"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Time away from home",
|
||||
"distance": "Distance travelled"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "Running that day",
|
||||
"values": "Values",
|
||||
"happened": "What happened"
|
||||
"happened": "What happened",
|
||||
"movement": "On the move"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Highest daily value since {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Today",
|
||||
"hasEntries": "has entries",
|
||||
"dayNSince": "Day {n}, since {date}",
|
||||
"sinceDate": "Since {date}"
|
||||
"sinceDate": "Since {date}",
|
||||
"movement": {
|
||||
"total": "Distance",
|
||||
"driving": "By car",
|
||||
"train": "By train",
|
||||
"places": "Places visited",
|
||||
"away": "Away from home",
|
||||
"travel": "Travel day",
|
||||
"travelYes": "Yes",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Eliminar periodo de ubicación",
|
||||
"removeConfirmTitle": "¿Quitar este viaje?",
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje.",
|
||||
"range": "Del {start} al {end}"
|
||||
"range": "Del {start} al {end}",
|
||||
"suggestionsIntro": "Dawarich encontró días en el extranjero que aún no cubre ningún periodo.",
|
||||
"suggestionUse": "Usar",
|
||||
"suggestionDismiss": "Descartar sugerencia"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Racha de fallos: {count} de {threshold}",
|
||||
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movimiento",
|
||||
"dawarichDescription": "Conecta tu instancia de Dawarich para traer la distancia diaria, el tiempo fuera de casa y los días de viaje.",
|
||||
"dawarichUrl": "Dirección de Dawarich",
|
||||
"dawarichApiKey": "Clave de API",
|
||||
"dawarichApiKeyHelp": "Lo encuentras en Dawarich en Cuenta. HealthLog solo lee valores diarios con ella, nunca coordenadas ni nombres de lugares.",
|
||||
"dawarichConnect": "Conectar",
|
||||
"dawarichUpdate": "Guardar cambios",
|
||||
"dawarichConnected": "Dawarich conectado. Se están cargando los últimos 30 días.",
|
||||
"dawarichSync": "Obtener ahora",
|
||||
"dawarichSyncQueued": "Los últimos siete días se obtendrán en breve.",
|
||||
"dawarichSyncAlreadyQueued": "Ya hay una obtención en curso.",
|
||||
"dawarichDisconnect": "Desconectar",
|
||||
"dawarichDisconnectTitle": "¿Desconectar Dawarich?",
|
||||
"dawarichDisconnectDescription": "Las próximas obtenciones se detienen. Los días ya guardados se conservan.",
|
||||
"dawarichDisconnectDelete": "Desconectar y borrar los días",
|
||||
"dawarichDisconnectDeleteTitle": "¿Desconectar y borrar todos los días?",
|
||||
"dawarichDisconnectDeleteDescription": "Se eliminan la conexión y todos los días de movimiento guardados. No se puede deshacer.",
|
||||
"dawarichHomePlaceSet": "El hogar está definido.",
|
||||
"dawarichHomePlaceMissing": "Define tu hogar para poder medir el tiempo fuera de casa.",
|
||||
"dawarichHomeCountryIs": "País de residencia: {country}.",
|
||||
"dawarichHomeCountryMissing": "Aún no hay país de residencia, así que no hay días de viaje.",
|
||||
"dawarichDaysStored": "Días guardados: {count}",
|
||||
"dawarichHomePlace": "Hogar",
|
||||
"dawarichHomePlaceChoose": "Elige un lugar de Dawarich",
|
||||
"dawarichHomePlaceChange": "Elegir hogar",
|
||||
"dawarichHomeCountry": "País de residencia",
|
||||
"dawarichHomeCountryHelp": "Código de país de dos letras, como DE. Si lo dejas vacío, cuenta el país donde pasaste más días el último año.",
|
||||
"dawarichHomeSave": "Guardar",
|
||||
"dawarichHomeSaved": "Guardado.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Esa dirección no funciona. Usa la dirección de la instancia sin credenciales.",
|
||||
"privateOrigin": "Esta instancia está en una red privada. Pide al operador del servidor que la autorice.",
|
||||
"credentials": "Dawarich no aceptó la clave de API.",
|
||||
"unreachable": "No se puede acceder a Dawarich. Comprueba la dirección y que la instancia esté en marcha.",
|
||||
"rateLimited": "Demasiados intentos. Espera un momento y vuelve a intentarlo.",
|
||||
"homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.",
|
||||
"notConnected": "Dawarich no está conectado.",
|
||||
"generic": "No ha funcionado. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "artemisa",
|
||||
"olive": "olivo",
|
||||
"ragweed": "ambrosía"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Tiempo fuera de casa",
|
||||
"distance": "Distancia recorrida"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Puntuaciones",
|
||||
"running": "En curso ese día",
|
||||
"values": "Valores",
|
||||
"happened": "Qué pasó"
|
||||
"happened": "Qué pasó",
|
||||
"movement": "En movimiento"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valor diario más alto desde {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Hoy",
|
||||
"hasEntries": "con entradas",
|
||||
"dayNSince": "Día {n}, desde el {date}",
|
||||
"sinceDate": "Desde el {date}"
|
||||
"sinceDate": "Desde el {date}",
|
||||
"movement": {
|
||||
"total": "Distancia",
|
||||
"driving": "En coche",
|
||||
"train": "En tren",
|
||||
"places": "Lugares visitados",
|
||||
"away": "Fuera de casa",
|
||||
"travel": "Día de viaje",
|
||||
"travelYes": "Sí",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Supprimer la période de localisation",
|
||||
"removeConfirmTitle": "Retirer ce voyage ?",
|
||||
"removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît.",
|
||||
"range": "Du {start} au {end}"
|
||||
"range": "Du {start} au {end}",
|
||||
"suggestionsIntro": "Dawarich a trouvé des jours à l’étranger qu’aucune période ne couvre encore.",
|
||||
"suggestionUse": "Utiliser",
|
||||
"suggestionDismiss": "Ignorer la suggestion"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Série d’échecs : {count} sur {threshold}",
|
||||
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Déplacements",
|
||||
"dawarichDescription": "Connecte ton instance Dawarich pour récupérer la distance du jour, le temps hors de chez toi et les jours de voyage.",
|
||||
"dawarichUrl": "Adresse Dawarich",
|
||||
"dawarichApiKey": "Clé d’API",
|
||||
"dawarichApiKeyHelp": "Tu la trouves dans Dawarich sous Compte. HealthLog ne lit que des valeurs journalières, jamais de coordonnées ni de noms de lieux.",
|
||||
"dawarichConnect": "Connecter",
|
||||
"dawarichUpdate": "Enregistrer les modifications",
|
||||
"dawarichConnected": "Dawarich connecté. Les 30 derniers jours sont en cours de chargement.",
|
||||
"dawarichSync": "Récupérer maintenant",
|
||||
"dawarichSyncQueued": "Les sept derniers jours seront récupérés sous peu.",
|
||||
"dawarichSyncAlreadyQueued": "Une récupération est déjà en cours.",
|
||||
"dawarichDisconnect": "Déconnecter",
|
||||
"dawarichDisconnectTitle": "Déconnecter Dawarich ?",
|
||||
"dawarichDisconnectDescription": "Les prochaines récupérations s’arrêtent. Les jours déjà enregistrés restent.",
|
||||
"dawarichDisconnectDelete": "Déconnecter et supprimer les jours",
|
||||
"dawarichDisconnectDeleteTitle": "Déconnecter et supprimer tous les jours ?",
|
||||
"dawarichDisconnectDeleteDescription": "La connexion et tous les jours de déplacement enregistrés sont supprimés. C’est irréversible.",
|
||||
"dawarichHomePlaceSet": "Le domicile est défini.",
|
||||
"dawarichHomePlaceMissing": "Définis ton domicile pour que le temps hors de chez toi puisse être mesuré.",
|
||||
"dawarichHomeCountryIs": "Pays de résidence : {country}.",
|
||||
"dawarichHomeCountryMissing": "Pas encore de pays de résidence, donc pas de jours de voyage.",
|
||||
"dawarichDaysStored": "Jours enregistrés : {count}",
|
||||
"dawarichHomePlace": "Domicile",
|
||||
"dawarichHomePlaceChoose": "Choisis un lieu dans Dawarich",
|
||||
"dawarichHomePlaceChange": "Choisir le domicile",
|
||||
"dawarichHomeCountry": "Pays de résidence",
|
||||
"dawarichHomeCountryHelp": "Code pays à deux lettres, par exemple DE. Laissé vide, c’est le pays où tu as passé le plus de jours l’an dernier qui compte.",
|
||||
"dawarichHomeSave": "Enregistrer",
|
||||
"dawarichHomeSaved": "Enregistré.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Cette adresse ne fonctionne pas. Utilise l’adresse de l’instance sans identifiants.",
|
||||
"privateOrigin": "Cette instance est sur un réseau privé. Demande à l’opérateur du serveur de l’autoriser.",
|
||||
"credentials": "Dawarich n’a pas accepté la clé d’API.",
|
||||
"unreachable": "Dawarich est injoignable. Vérifie l’adresse et que l’instance fonctionne.",
|
||||
"rateLimited": "Trop de tentatives. Patiente un instant, puis réessaie.",
|
||||
"homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.",
|
||||
"notConnected": "Dawarich n’est pas connecté.",
|
||||
"generic": "Ça n’a pas marché. Réessaie."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "armoise",
|
||||
"olive": "olivier",
|
||||
"ragweed": "ambroisie"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Temps hors de chez soi",
|
||||
"distance": "Distance parcourue"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "En cours ce jour-là",
|
||||
"values": "Valeurs",
|
||||
"happened": "Ce qui s’est passé"
|
||||
"happened": "Ce qui s’est passé",
|
||||
"movement": "En déplacement"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valeur journalière la plus haute depuis {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Aujourd’hui",
|
||||
"hasEntries": "avec des entrées",
|
||||
"dayNSince": "Jour {n}, depuis le {date}",
|
||||
"sinceDate": "Depuis le {date}"
|
||||
"sinceDate": "Depuis le {date}",
|
||||
"movement": {
|
||||
"total": "Distance",
|
||||
"driving": "En voiture",
|
||||
"train": "En train",
|
||||
"places": "Lieux visités",
|
||||
"away": "Hors de chez toi",
|
||||
"travel": "Jour de voyage",
|
||||
"travelYes": "Oui",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Rimuovi periodo di posizione",
|
||||
"removeConfirmTitle": "Rimuovere questo viaggio?",
|
||||
"removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio.",
|
||||
"range": "Dal {start} al {end}"
|
||||
"range": "Dal {start} al {end}",
|
||||
"suggestionsIntro": "Dawarich ha trovato giorni all’estero non ancora coperti da un periodo.",
|
||||
"suggestionUse": "Usa",
|
||||
"suggestionDismiss": "Ignora il suggerimento"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Serie di errori: {count} su {threshold}",
|
||||
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movimento",
|
||||
"dawarichDescription": "Collega la tua istanza Dawarich per importare la distanza giornaliera, il tempo fuori casa e i giorni di viaggio.",
|
||||
"dawarichUrl": "Indirizzo Dawarich",
|
||||
"dawarichApiKey": "Chiave API",
|
||||
"dawarichApiKeyHelp": "La trovi in Dawarich alla voce Account. HealthLog legge solo valori giornalieri, mai coordinate o nomi di luoghi.",
|
||||
"dawarichConnect": "Collega",
|
||||
"dawarichUpdate": "Salva modifiche",
|
||||
"dawarichConnected": "Dawarich collegato. Gli ultimi 30 giorni si stanno caricando.",
|
||||
"dawarichSync": "Recupera ora",
|
||||
"dawarichSyncQueued": "Gli ultimi sette giorni verranno recuperati a breve.",
|
||||
"dawarichSyncAlreadyQueued": "Un recupero è già in corso.",
|
||||
"dawarichDisconnect": "Disconnetti",
|
||||
"dawarichDisconnectTitle": "Disconnettere Dawarich?",
|
||||
"dawarichDisconnectDescription": "I recuperi futuri si fermano. I giorni già salvati restano.",
|
||||
"dawarichDisconnectDelete": "Disconnetti ed elimina i giorni",
|
||||
"dawarichDisconnectDeleteTitle": "Disconnettere ed eliminare tutti i giorni?",
|
||||
"dawarichDisconnectDeleteDescription": "Vengono rimossi il collegamento e tutti i giorni di movimento salvati. Non si può annullare.",
|
||||
"dawarichHomePlaceSet": "La casa è impostata.",
|
||||
"dawarichHomePlaceMissing": "Imposta la tua casa per poter misurare il tempo fuori casa.",
|
||||
"dawarichHomeCountryIs": "Paese di residenza: {country}.",
|
||||
"dawarichHomeCountryMissing": "Nessun paese di residenza per ora, quindi niente giorni di viaggio.",
|
||||
"dawarichDaysStored": "Giorni salvati: {count}",
|
||||
"dawarichHomePlace": "Casa",
|
||||
"dawarichHomePlaceChoose": "Scegli un luogo da Dawarich",
|
||||
"dawarichHomePlaceChange": "Scegli la casa",
|
||||
"dawarichHomeCountry": "Paese di residenza",
|
||||
"dawarichHomeCountryHelp": "Codice paese di due lettere, ad esempio DE. Se lo lasci vuoto, vale il paese in cui hai passato più giorni nell’ultimo anno.",
|
||||
"dawarichHomeSave": "Salva",
|
||||
"dawarichHomeSaved": "Salvato.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Questo indirizzo non funziona. Usa l’indirizzo dell’istanza senza credenziali.",
|
||||
"privateOrigin": "Questa istanza è in una rete privata. Chiedi all’operatore del server di autorizzarla.",
|
||||
"credentials": "Dawarich non ha accettato la chiave API.",
|
||||
"unreachable": "Dawarich non è raggiungibile. Controlla l’indirizzo e che l’istanza sia attiva.",
|
||||
"rateLimited": "Troppi tentativi. Attendi un momento e riprova.",
|
||||
"homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.",
|
||||
"notConnected": "Dawarich non è collegato.",
|
||||
"generic": "Non ha funzionato. Riprova."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "artemisia",
|
||||
"olive": "olivo",
|
||||
"ragweed": "ambrosia"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Tempo fuori casa",
|
||||
"distance": "Distanza percorsa"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Punteggi",
|
||||
"running": "In corso quel giorno",
|
||||
"values": "Valori",
|
||||
"happened": "Cosa è successo"
|
||||
"happened": "Cosa è successo",
|
||||
"movement": "In movimento"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valore giornaliero più alto dal {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Oggi",
|
||||
"hasEntries": "con voci",
|
||||
"dayNSince": "Giorno {n}, dal {date}",
|
||||
"sinceDate": "Dal {date}"
|
||||
"sinceDate": "Dal {date}",
|
||||
"movement": {
|
||||
"total": "Distanza",
|
||||
"driving": "In auto",
|
||||
"train": "In treno",
|
||||
"places": "Luoghi visitati",
|
||||
"away": "Fuori casa",
|
||||
"travel": "Giorno di viaggio",
|
||||
"travelYes": "Sì",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "위치 기간 삭제",
|
||||
"removeConfirmTitle": "이 여행을 삭제할까요?",
|
||||
"removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요.",
|
||||
"range": "{start}~{end}"
|
||||
"range": "{start}~{end}",
|
||||
"suggestionsIntro": "Dawarich가 아직 어떤 기간에도 속하지 않은 해외 체류일을 찾았습니다.",
|
||||
"suggestionUse": "사용",
|
||||
"suggestionDismiss": "제안 닫기"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "지난 날짜 채우기",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
|
||||
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "이동",
|
||||
"dawarichDescription": "Dawarich 인스턴스를 연결해 하루 이동 거리, 집 밖에 있던 시간, 여행일을 가져옵니다.",
|
||||
"dawarichUrl": "Dawarich 주소",
|
||||
"dawarichApiKey": "API 키",
|
||||
"dawarichApiKeyHelp": "Dawarich의 계정 메뉴에서 찾을 수 있습니다. HealthLog는 이 키로 하루 단위 값만 읽고 좌표나 장소 이름은 읽지 않습니다.",
|
||||
"dawarichConnect": "연결",
|
||||
"dawarichUpdate": "변경 사항 저장",
|
||||
"dawarichConnected": "Dawarich가 연결되었습니다. 최근 30일을 불러오는 중입니다.",
|
||||
"dawarichSync": "지금 가져오기",
|
||||
"dawarichSyncQueued": "최근 7일을 곧 가져옵니다.",
|
||||
"dawarichSyncAlreadyQueued": "이미 가져오는 중입니다.",
|
||||
"dawarichDisconnect": "연결 해제",
|
||||
"dawarichDisconnectTitle": "Dawarich 연결을 해제할까요?",
|
||||
"dawarichDisconnectDescription": "앞으로의 가져오기가 중단됩니다. 이미 저장된 날은 남습니다.",
|
||||
"dawarichDisconnectDelete": "연결 해제 후 기록 삭제",
|
||||
"dawarichDisconnectDeleteTitle": "연결을 해제하고 모든 날을 삭제할까요?",
|
||||
"dawarichDisconnectDeleteDescription": "연결과 저장된 모든 이동 기록이 삭제됩니다. 되돌릴 수 없습니다.",
|
||||
"dawarichHomePlaceSet": "집이 설정되어 있습니다.",
|
||||
"dawarichHomePlaceMissing": "집 밖에 있던 시간을 재려면 집을 설정하세요.",
|
||||
"dawarichHomeCountryIs": "거주 국가: {country}.",
|
||||
"dawarichHomeCountryMissing": "거주 국가가 없어 여행일도 없습니다.",
|
||||
"dawarichDaysStored": "저장된 날: {count}",
|
||||
"dawarichHomePlace": "집",
|
||||
"dawarichHomePlaceChoose": "Dawarich에서 장소 선택",
|
||||
"dawarichHomePlaceChange": "집 선택",
|
||||
"dawarichHomeCountry": "거주 국가",
|
||||
"dawarichHomeCountryHelp": "DE처럼 두 글자 국가 코드입니다. 비워 두면 지난 1년 동안 가장 많은 날을 보낸 나라가 기준이 됩니다.",
|
||||
"dawarichHomeSave": "저장",
|
||||
"dawarichHomeSaved": "저장했습니다.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "이 주소는 사용할 수 없습니다. 인증 정보 없이 인스턴스 주소를 입력하세요.",
|
||||
"privateOrigin": "이 인스턴스는 사설 네트워크에 있습니다. 서버 운영자에게 허용을 요청하세요.",
|
||||
"credentials": "Dawarich가 API 키를 받아들이지 않았습니다.",
|
||||
"unreachable": "Dawarich에 연결할 수 없습니다. 주소와 인스턴스 실행 여부를 확인하세요.",
|
||||
"rateLimited": "시도가 너무 많습니다. 잠시 후 다시 시도하세요.",
|
||||
"homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.",
|
||||
"notConnected": "Dawarich가 연결되어 있지 않습니다.",
|
||||
"generic": "실패했습니다. 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "쑥",
|
||||
"olive": "올리브",
|
||||
"ragweed": "돼지풀"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "집 밖에 있던 시간",
|
||||
"distance": "이동 거리"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "점수",
|
||||
"running": "그날 진행 중",
|
||||
"values": "수치",
|
||||
"happened": "있었던 일"
|
||||
"happened": "있었던 일",
|
||||
"movement": "이동"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "{since} 이후 가장 높은 하루 값",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "오늘",
|
||||
"hasEntries": "기록 있음",
|
||||
"dayNSince": "{n}일째, {date}부터",
|
||||
"sinceDate": "{date}부터"
|
||||
"sinceDate": "{date}부터",
|
||||
"movement": {
|
||||
"total": "이동 거리",
|
||||
"driving": "자동차",
|
||||
"train": "기차",
|
||||
"places": "방문한 장소",
|
||||
"away": "집 밖",
|
||||
"travel": "여행일",
|
||||
"travelYes": "예",
|
||||
"kilometres": "{value}km",
|
||||
"miles": "{value}mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
|
||||
+62
-3
@@ -5894,7 +5894,10 @@
|
||||
"remove": "Usuń okres lokalizacji",
|
||||
"removeConfirmTitle": "Usunąć tę podróż?",
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży.",
|
||||
"range": "Od {start} do {end}"
|
||||
"range": "Od {start} do {end}",
|
||||
"suggestionsIntro": "Dawarich znalazł dni za granicą, których nie obejmuje jeszcze żaden okres.",
|
||||
"suggestionUse": "Użyj",
|
||||
"suggestionDismiss": "Odrzuć sugestię"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
@@ -7374,6 +7377,46 @@
|
||||
"failureStreak": "Seria błędów: {count} z {threshold}",
|
||||
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Ruch",
|
||||
"dawarichDescription": "Połącz swoją instancję Dawarich, aby pobierać dzienny dystans, czas poza domem i dni podróży.",
|
||||
"dawarichUrl": "Adres Dawarich",
|
||||
"dawarichApiKey": "Klucz API",
|
||||
"dawarichApiKeyHelp": "Znajdziesz go w Dawarich w sekcji Konto. HealthLog odczytuje nim tylko wartości dzienne, nigdy współrzędne ani nazwy miejsc.",
|
||||
"dawarichConnect": "Połącz",
|
||||
"dawarichUpdate": "Zapisz zmiany",
|
||||
"dawarichConnected": "Dawarich połączony. Trwa wczytywanie ostatnich 30 dni.",
|
||||
"dawarichSync": "Pobierz teraz",
|
||||
"dawarichSyncQueued": "Ostatnie siedem dni zostanie zaraz pobrane.",
|
||||
"dawarichSyncAlreadyQueued": "Pobieranie już trwa.",
|
||||
"dawarichDisconnect": "Rozłącz",
|
||||
"dawarichDisconnectTitle": "Rozłączyć Dawarich?",
|
||||
"dawarichDisconnectDescription": "Kolejne pobierania zostaną zatrzymane. Zapisane dni pozostaną.",
|
||||
"dawarichDisconnectDelete": "Rozłącz i usuń dni",
|
||||
"dawarichDisconnectDeleteTitle": "Rozłączyć i usunąć wszystkie dni?",
|
||||
"dawarichDisconnectDeleteDescription": "Połączenie i wszystkie zapisane dni ruchu zostaną usunięte. Tego nie można cofnąć.",
|
||||
"dawarichHomePlaceSet": "Dom jest ustawiony.",
|
||||
"dawarichHomePlaceMissing": "Ustaw dom, aby można było mierzyć czas poza domem.",
|
||||
"dawarichHomeCountryIs": "Kraj zamieszkania: {country}.",
|
||||
"dawarichHomeCountryMissing": "Brak kraju zamieszkania, więc brak dni podróży.",
|
||||
"dawarichDaysStored": "Zapisane dni: {count}",
|
||||
"dawarichHomePlace": "Dom",
|
||||
"dawarichHomePlaceChoose": "Wybierz miejsce z Dawarich",
|
||||
"dawarichHomePlaceChange": "Wybierz dom",
|
||||
"dawarichHomeCountry": "Kraj zamieszkania",
|
||||
"dawarichHomeCountryHelp": "Dwuliterowy kod kraju, na przykład DE. Jeśli zostawisz puste, liczy się kraj, w którym spędziłeś najwięcej dni w ostatnim roku.",
|
||||
"dawarichHomeSave": "Zapisz",
|
||||
"dawarichHomeSaved": "Zapisano.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Ten adres nie działa. Użyj adresu instancji bez danych logowania.",
|
||||
"privateOrigin": "Ta instancja jest w sieci prywatnej. Poproś operatora serwera o jej dopuszczenie.",
|
||||
"credentials": "Dawarich nie zaakceptował klucza API.",
|
||||
"unreachable": "Dawarich jest nieosiągalny. Sprawdź adres i czy instancja działa.",
|
||||
"rateLimited": "Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.",
|
||||
"notConnected": "Dawarich nie jest połączony.",
|
||||
"generic": "Nie udało się. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -11057,6 +11100,10 @@
|
||||
"mugwort": "bylica",
|
||||
"olive": "oliwka",
|
||||
"ragweed": "ambrozja"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Czas poza domem",
|
||||
"distance": "Przebyty dystans"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -12596,7 +12643,8 @@
|
||||
"scores": "Wyniki",
|
||||
"running": "W toku tego dnia",
|
||||
"values": "Wartości",
|
||||
"happened": "Co się działo"
|
||||
"happened": "Co się działo",
|
||||
"movement": "W drodze"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Najwyższa wartość dzienna od {since}",
|
||||
@@ -12652,7 +12700,18 @@
|
||||
"today": "Dzisiaj",
|
||||
"hasEntries": "z wpisami",
|
||||
"dayNSince": "Dzień {n}, od {date}",
|
||||
"sinceDate": "Od {date}"
|
||||
"sinceDate": "Od {date}",
|
||||
"movement": {
|
||||
"total": "Dystans",
|
||||
"driving": "Samochodem",
|
||||
"train": "Pociągiem",
|
||||
"places": "Odwiedzone miejsca",
|
||||
"away": "Poza domem",
|
||||
"travel": "Dzień podróży",
|
||||
"travelYes": "Tak",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś czasu",
|
||||
|
||||
+16
-15
@@ -236,15 +236,16 @@ model User {
|
||||
// v1.43 — Dawarich movement context (per-user, encrypted at app level, part
|
||||
// of the `environment` module). Base URL and API key sealed like the
|
||||
// Nightscout pair; `dawarichPrefsEncrypted` seals
|
||||
// `{ homePlaceId, homeCountry, dismissedSuggestionIds }`. Same SSRF floor:
|
||||
// a private host needs `DAWARICH_PRIVATE_ORIGINS` or this switch.
|
||||
/// @pending-consumer(#v143-B7) Sealed Dawarich base URL.
|
||||
// `{ homePlaceId, homeCountry, derivedHomeCountry, dismissedSuggestionIds }`.
|
||||
// Same SSRF floor as Nightscout: a private host needs its exact origin in
|
||||
// `DAWARICH_PRIVATE_ORIGINS`; the switch below never grants it.
|
||||
/// Sealed Dawarich base URL.
|
||||
dawarichUrlEncrypted String? @map("dawarich_url_encrypted")
|
||||
/// @pending-consumer(#v143-B7) Sealed Dawarich API key.
|
||||
/// Sealed Dawarich API key.
|
||||
dawarichApiKeyEncrypted String? @map("dawarich_api_key_encrypted")
|
||||
/// @pending-consumer(#v143-B7) Sealed home place id, home country and dismissed travel suggestions.
|
||||
/// Sealed home place id, home country and dismissed travel suggestions.
|
||||
dawarichPrefsEncrypted Bytes? @map("dawarich_prefs_encrypted")
|
||||
/// @pending-consumer(#v143-B7) Opt this Dawarich instance past the public-host requirement.
|
||||
/// @internal: display only; whether the operator list approved the instance at connect time. Egress is re-decided from `DAWARICH_PRIVATE_ORIGINS` on every request, never from this column.
|
||||
dawarichAllowPrivateHost Boolean @default(false) @map("dawarich_allow_private_host")
|
||||
|
||||
// v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level).
|
||||
@@ -9987,27 +9988,27 @@ model MedicationIngredient {
|
||||
|
||||
/// v1.43 — one local day of Dawarich movement context. Daily values only:
|
||||
/// no coordinate, no place name, no visit is ever stored. The country code is
|
||||
/// sealed (AAD as the environment location). Part of the `environment` module.
|
||||
/// sealed under its own label. Part of the `environment` module.
|
||||
model DawarichDay {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// Local calendar day (`YYYY-MM-DD`) in the account's timezone.
|
||||
date String @db.VarChar(10)
|
||||
/// @pending-consumer(#v143-B7) Timeline `summary.total_distance`, metres.
|
||||
/// Timeline `summary.total_distance`, metres.
|
||||
totalDistanceM Int? @map("total_distance_m")
|
||||
/// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.driving`, metres.
|
||||
/// Timeline `summary.mode_distances.driving`, metres.
|
||||
drivingDistanceM Int? @map("driving_distance_m")
|
||||
/// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.train`, metres.
|
||||
/// Timeline `summary.mode_distances.train`, metres.
|
||||
trainDistanceM Int? @map("train_distance_m")
|
||||
/// @pending-consumer(#v143-B7) Timeline `summary.places_visited`.
|
||||
/// Timeline `summary.places_visited`.
|
||||
placesVisited Int? @map("places_visited")
|
||||
/// @pending-consumer(#v143-B7) Minutes away from the home place; NULL = no visit coverage that day.
|
||||
/// Minutes away from the home place; NULL = no visit coverage that day.
|
||||
awayFromHomeMinutes Int? @map("away_from_home_minutes")
|
||||
/// @pending-consumer(#v143-B7) Sealed ISO-3166 alpha-2 country of the day (`/residency`).
|
||||
/// Sealed ISO-3166 alpha-2 country of the day (`/residency`).
|
||||
countryCodeEncrypted Bytes? @map("country_code_encrypted")
|
||||
/// @pending-consumer(#v143-B7) Country differs from the home country.
|
||||
/// Country differs from the home country.
|
||||
isTravelDay Boolean? @map("is_travel_day")
|
||||
/// @pending-consumer(#v143-B7) When the day was last fetched from Dawarich.
|
||||
/// When the day was last fetched from Dawarich.
|
||||
fetchedAt DateTime @map("fetched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -310,6 +310,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dawarich on a private network",
|
||||
"description": "Operator grant for Dawarich instances on the operator's own network, read by the movement context of the Environment module. Unset, a Dawarich URL that resolves to a private address is refused at connect time with private_origin_not_approved. Optional; a public instance never needs it.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DAWARICH_PRIVATE_ORIGINS",
|
||||
"purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"http://dawarich.lan:3000\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry makes the connect form name it."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Document picker",
|
||||
"description": "Operator grant for the document picker, which lets a person search their own Paperless-ngx or Papra instance and import picked documents. Unset, the picker is off. Optional.",
|
||||
|
||||
@@ -37,23 +37,10 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"anamnesis.life_circumstances.confirm",
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
"dawarich/settings/route.ts": ["dawarich.settings.patch"],
|
||||
"dawarich/status/route.ts": ["dawarich.status.get"],
|
||||
"dawarich/sync/route.ts": ["dawarich.sync"],
|
||||
"dawarich/test/route.ts": ["dawarich.test"],
|
||||
"environment/travel/suggestions/[id]/dismiss/route.ts": [
|
||||
"environment.travel_suggestion.dismiss",
|
||||
],
|
||||
"environment/travel/suggestions/route.ts": [
|
||||
"environment.travel_suggestions.get",
|
||||
],
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 15;
|
||||
const REGISTER_SIZE = 6;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.43 — pair guard binding `DawarichDay` in `prisma/schema.prisma` to the
|
||||
* backup that carries it (`src/lib/export/environment-backup.ts`, the
|
||||
* `dawarichDays` arm of the environment section), on the pattern of
|
||||
* `environment-backup-completeness.test.ts`.
|
||||
*
|
||||
* A column missing from the select comes back NULL on every restored day
|
||||
* without a word, because the section schema is `.passthrough()`. So both
|
||||
* ends are compared as literals:
|
||||
*
|
||||
* - every scalar column of `DawarichDay` is in `DAWARICH_DAY_BACKUP_SELECT`
|
||||
* (or excluded here with a reason);
|
||||
* - every selected column is written by the export (`row.<column>`);
|
||||
* - every selected column is written back by the restore (`<column>:`).
|
||||
*
|
||||
* Mutation check: delete `trainDistanceM: true,` from the select and the
|
||||
* first case names it; delete `trainDistanceM:` from the restore and the
|
||||
* restore case names it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
|
||||
const source = readFileSync(
|
||||
join(ROOT, "src", "lib", "export", "environment-backup.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const EXCLUDED: Record<string, string> = {
|
||||
id: "nothing addresses a day by its id; a day is identified by its date",
|
||||
userId:
|
||||
"user-scoped backup; the owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
function block(text: string, start: string, end = "\n}"): string {
|
||||
const from = text.indexOf(start);
|
||||
expect(from, `${start} not found`).toBeGreaterThan(-1);
|
||||
const to = text.indexOf(end, from);
|
||||
expect(to).toBeGreaterThan(from);
|
||||
return text.slice(from, to);
|
||||
}
|
||||
|
||||
function scalarColumns(model: string): string[] {
|
||||
const models = new Set(
|
||||
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const raw of block(schema, `model ${model} {`).split("\n").slice(1)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
|
||||
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line);
|
||||
if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue;
|
||||
out.push(m[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const columns = scalarColumns("DawarichDay");
|
||||
const selected = [
|
||||
...block(source, "const DAWARICH_DAY_BACKUP_SELECT = {").matchAll(
|
||||
/^ {2}(\w+): true,$/gm,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
const exportBody = block(source, "dawarichDays: dawarichRows.map(", "})),");
|
||||
const restoreBody = block(
|
||||
source,
|
||||
"export async function restoreEnvironmentData(",
|
||||
"\n}\n",
|
||||
);
|
||||
const restoreDawarich = block(restoreBody, "tx.dawarichDay.createMany(", "});");
|
||||
|
||||
describe("DawarichDay backup completeness", () => {
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(columns).toContain("countryCodeEncrypted");
|
||||
expect(columns).toContain("awayFromHomeMinutes");
|
||||
expect(selected.length).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it("the backup select covers every column", () => {
|
||||
const missing = columns.filter(
|
||||
(c) => !selected.includes(c) && !(c in EXCLUDED),
|
||||
);
|
||||
expect(
|
||||
missing,
|
||||
"DAWARICH_DAY_BACKUP_SELECT omits these columns; a restore brings them back NULL",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the export writes every selected column into the entry", () => {
|
||||
const unwritten = selected.filter(
|
||||
(c) => !new RegExp(`row\\.${c}\\b`).test(exportBody),
|
||||
);
|
||||
expect(unwritten, "selected but never exported").toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore writes every selected column back", () => {
|
||||
const unwritten = selected.filter(
|
||||
(c) => !new RegExp(`\\b${c}:`).test(restoreDawarich),
|
||||
);
|
||||
expect(unwritten, "selected but never restored").toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore clears the days it replaces", () => {
|
||||
expect(restoreBody).toMatch(/tx\.dawarichDay\.deleteMany\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* v1.43 — Dawarich is a movement context, never a location store. The schema
|
||||
* holds daily values only: no column of `DawarichDay`, and no Dawarich column
|
||||
* on `User`, may hold a coordinate, a place name, an address or a visit.
|
||||
*
|
||||
* The check reads the column names and their doc comments in
|
||||
* `prisma/schema.prisma`. A new column whose name or description says
|
||||
* latitude, longitude, coordinates, geometry, address, label, a place name or
|
||||
* a visit list fails here and has to argue its case in review.
|
||||
*
|
||||
* Mutation check: add `placeName String?` to `DawarichDay` and the first case
|
||||
* names it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const schema = readFileSync(
|
||||
join(process.cwd(), "prisma", "schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const FORBIDDEN =
|
||||
/\b(lat|lon|lng|latitude|longitude|coord\w*|geo\w*|geometry|address|label|street|city|placeName|place_name|visits?Json|track|path|point)\b/i;
|
||||
|
||||
/** Column lines of a model with the `///` doc comment above each. */
|
||||
function columns(model: string): Array<{ name: string; text: string }> {
|
||||
const start = schema.indexOf(`model ${model} {`);
|
||||
expect(start, `model ${model} not found`).toBeGreaterThan(-1);
|
||||
const body = schema.slice(start, schema.indexOf("\n}", start));
|
||||
const out: Array<{ name: string; text: string }> = [];
|
||||
let doc = "";
|
||||
for (const raw of body.split("\n").slice(1)) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith("///")) {
|
||||
doc += ` ${line.slice(3)}`;
|
||||
continue;
|
||||
}
|
||||
const m = /^([a-zA-Z_]\w*)\s+\w/.exec(line);
|
||||
if (m && !line.startsWith("@@"))
|
||||
out.push({ name: m[1], text: `${m[1]} ${doc}` });
|
||||
doc = "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("Dawarich stores no location", () => {
|
||||
it("DawarichDay holds daily values only", () => {
|
||||
const cols = columns("DawarichDay");
|
||||
expect(cols.map((c) => c.name)).toContain("awayFromHomeMinutes");
|
||||
const offending = cols
|
||||
.filter((c) => c.name !== "user")
|
||||
.filter((c) => FORBIDDEN.test(c.name) || FORBIDDEN.test(c.text))
|
||||
.map((c) => c.name);
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
|
||||
it("the User row holds the connection, never a place", () => {
|
||||
const cols = columns("User").filter((c) => /^dawarich/.test(c.name));
|
||||
expect(cols.map((c) => c.name).sort()).toEqual([
|
||||
"dawarichAllowPrivateHost",
|
||||
"dawarichApiKeyEncrypted",
|
||||
"dawarichDays",
|
||||
"dawarichPrefsEncrypted",
|
||||
"dawarichUrlEncrypted",
|
||||
]);
|
||||
const offending = cols
|
||||
.filter((c) => FORBIDDEN.test(c.name))
|
||||
.map((c) => c.name);
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -649,6 +649,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/dawarich-card.tsx",
|
||||
destroys:
|
||||
"the stored Dawarich address, key and home settings, and with the second entry every stored movement day — reconnecting means typing them again; deleted days come back only as far as the instance still holds them",
|
||||
triggers: ["components/settings/integrations/integration-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/document-sources-card.tsx",
|
||||
destroys:
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("record settings contract", () => {
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
@@ -187,6 +188,7 @@ describe("record settings contract", () => {
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
|
||||
@@ -372,6 +372,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
// The saved-credentials confirmation line.
|
||||
// Connected, a fetch queued, the home saved: confirmations of a request
|
||||
// that wrote no record (the fetch itself reports through `health`).
|
||||
"src/components/settings/integrations/dawarich-card.tsx": {
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/settings/integrations/credentials-form.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
|
||||
@@ -7,18 +7,54 @@
|
||||
* `private_origin_not_approved`). The key is checked against the instance
|
||||
* before anything is stored, then a 30-day backfill starts. Part of the
|
||||
* `environment` module; own account only, ten attempts a minute.
|
||||
*
|
||||
* Reconnecting to the same instance keeps the home place and country; a
|
||||
* different instance starts without them, because a place id means nothing
|
||||
* on another instance.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
DawarichApiError,
|
||||
DawarichPolicyError,
|
||||
probeDawarich,
|
||||
} from "@/lib/dawarich/client";
|
||||
import {
|
||||
EMPTY_DAWARICH_PREFS,
|
||||
sealDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import {
|
||||
configuredDawarichPrivateOrigins,
|
||||
DawarichOriginConfigError,
|
||||
evaluateDawarichUrl,
|
||||
} from "@/lib/dawarich/origin";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import {
|
||||
DAWARICH_CONNECT_BACKFILL_DAYS,
|
||||
enqueueDawarichSync,
|
||||
} from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { INVALID_ORIGIN } from "@/lib/private-origin-policy";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.connect" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -40,5 +76,113 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.connect");
|
||||
// A malformed operator list is the operator's to fix: name the entry and
|
||||
// the reason instead of a 500.
|
||||
let privateOrigins: ReadonlySet<string>;
|
||||
try {
|
||||
privateOrigins = configuredDawarichPrivateOrigins();
|
||||
} catch (error) {
|
||||
if (error instanceof DawarichOriginConfigError) {
|
||||
annotate({ meta: { refused: "operator_origin_list_invalid" } });
|
||||
return apiError(error.message, 422, { errorCode: "dawarich.invalid" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The SSRF floor applies to the value that gets stored, not only to the
|
||||
// probe below.
|
||||
const target = evaluateDawarichUrl(parsed.data.url, privateOrigins);
|
||||
if (!target.allowed || !target.baseUrl) {
|
||||
if (target.reasonCode === INVALID_ORIGIN) {
|
||||
return apiError(
|
||||
"Use the address of the Dawarich instance, without credentials or a query",
|
||||
422,
|
||||
{ errorCode: "dawarich.invalid" },
|
||||
);
|
||||
}
|
||||
annotate({ meta: { refused: target.reasonCode } });
|
||||
return apiError(
|
||||
"A Dawarich instance on a private network needs the server operator's approval",
|
||||
422,
|
||||
{ errorCode: "private_origin_not_approved" },
|
||||
);
|
||||
}
|
||||
const baseUrl = target.baseUrl;
|
||||
const apiKey = parsed.data.apiKey;
|
||||
|
||||
try {
|
||||
await probeDawarich({ baseUrl, apiKey });
|
||||
} catch (error) {
|
||||
if (error instanceof DawarichPolicyError) {
|
||||
return apiError(
|
||||
"A Dawarich instance on a private network needs the server operator's approval",
|
||||
422,
|
||||
{ errorCode: "private_origin_not_approved" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof DawarichApiError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
) {
|
||||
return apiError("Dawarich did not accept this API key", 422, {
|
||||
errorCode: "dawarich.credentials_rejected",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
dawarich_probe_status:
|
||||
error instanceof DawarichApiError ? error.status : null,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
"Could not reach the Dawarich instance. Check the address and that it is online.",
|
||||
422,
|
||||
{ errorCode: "dawarich.unreachable" },
|
||||
);
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
const sameInstance =
|
||||
previous?.dawarichUrlEncrypted != null &&
|
||||
decrypt(previous.dawarichUrlEncrypted) === baseUrl;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dawarichUrlEncrypted: encrypt(baseUrl),
|
||||
dawarichApiKeyEncrypted: encrypt(apiKey),
|
||||
dawarichPrefsEncrypted: sameInstance
|
||||
? (previous?.dawarichPrefsEncrypted ?? null)
|
||||
: sealDawarichPrefs(EMPTY_DAWARICH_PREFS),
|
||||
// Display only: the operator list is re-read before every request.
|
||||
dawarichAllowPrivateHost: target.privateOriginApproved,
|
||||
},
|
||||
});
|
||||
await markReconnected(user.id, "dawarich");
|
||||
await auditLog("dawarich.connect", {
|
||||
userId: user.id,
|
||||
details: { privateOrigin: target.privateOriginApproved },
|
||||
});
|
||||
|
||||
// The first month arrives without waiting for the night. Best effort: the
|
||||
// nightly run is the safety net when no worker is bound.
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const outcome = await enqueueDawarichSync({
|
||||
userId: user.id,
|
||||
fromDate: shiftDateKey(
|
||||
yesterdayKey(tz),
|
||||
-(DAWARICH_CONNECT_BACKFILL_DAYS - 1),
|
||||
),
|
||||
}).catch((error: unknown) => {
|
||||
getEvent()?.addWarning(
|
||||
`dawarich backfill enqueue failed: ${error instanceof Error ? error.name : "unknown"}`,
|
||||
);
|
||||
return "failed" as const;
|
||||
});
|
||||
annotate({ meta: { dawarich_backfill: outcome } });
|
||||
|
||||
return apiSuccess({ connected: true as const });
|
||||
});
|
||||
@@ -2,25 +2,32 @@
|
||||
* `POST /api/dawarich/disconnect` — forget the connection; with
|
||||
* `deleteData: true` the stored days go too. Answers
|
||||
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
|
||||
* own account only.
|
||||
* own account only. Disconnecting twice is harmless.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.disconnect" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
// The body is optional: no body is `{ deleteData: false }`.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
}
|
||||
const parsed = dawarichDisconnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -28,5 +35,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.disconnect");
|
||||
const deletedDays = await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dawarichUrlEncrypted: null,
|
||||
dawarichApiKeyEncrypted: null,
|
||||
dawarichPrefsEncrypted: null,
|
||||
dawarichAllowPrivateHost: false,
|
||||
},
|
||||
});
|
||||
if (!parsed.data.deleteData) return 0;
|
||||
const { count } = await tx.dawarichDay.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return count;
|
||||
});
|
||||
await markDisconnected(user.id, "dawarich");
|
||||
await auditLog("dawarich.disconnect", {
|
||||
userId: user.id,
|
||||
details: { deletedDays },
|
||||
});
|
||||
annotate({ meta: { deleted_days: deletedDays } });
|
||||
|
||||
return apiSuccess({ disconnected: true as const, deletedDays });
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `GET /api/dawarich/places` answers the instance's places for the home-place
|
||||
* picker and stores none of them. The database is a spy that records every
|
||||
* call: the only one allowed is the read of the connection.
|
||||
*
|
||||
* Mutation check: make the route `prisma.user.update(...)` the places into
|
||||
* the prefs and the write assertion names it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { calls, fetchPlacesMock } = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
fetchPlacesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (value: string) => value.replace(/^enc:/, ""),
|
||||
decryptBytes: vi.fn(),
|
||||
encryptBytes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/dawarich/client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/dawarich/client")>()),
|
||||
fetchPlaces: fetchPlacesMock,
|
||||
}));
|
||||
|
||||
/** Every delegate call is recorded as `model.method`. */
|
||||
vi.mock("@/lib/db", () => {
|
||||
const delegate = (model: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, method: string) =>
|
||||
vi.fn(async () => {
|
||||
calls.push(`${model}.${method}`);
|
||||
if (model === "user" && method === "findUnique") {
|
||||
return {
|
||||
dawarichUrlEncrypted: "enc:https://dawarich.example.com",
|
||||
dawarichApiKeyEncrypted: "enc:key",
|
||||
dawarichPrefsEncrypted: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, model: string) =>
|
||||
model.startsWith("$")
|
||||
? vi.fn(async () => {
|
||||
calls.push(model);
|
||||
return [];
|
||||
})
|
||||
: delegate(model),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const WRITE =
|
||||
/\.(create|createMany|update|updateMany|upsert|delete|deleteMany)$|^\$(executeRaw|executeRawUnsafe|transaction)/;
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
fetchPlacesMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/dawarich/places", () => {
|
||||
it("answers the places and writes none of them anywhere", async () => {
|
||||
fetchPlacesMock.mockResolvedValue([
|
||||
{ id: 7, name: "Home" },
|
||||
{ id: 9, name: "Office" },
|
||||
]);
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places?q=o"),
|
||||
undefined as never,
|
||||
)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.places).toEqual([
|
||||
{ id: 7, name: "Home" },
|
||||
{ id: 9, name: "Office" },
|
||||
]);
|
||||
expect(fetchPlacesMock).toHaveBeenCalledWith(
|
||||
{ baseUrl: "https://dawarich.example.com", apiKey: "key" },
|
||||
"o",
|
||||
);
|
||||
expect(calls).toEqual(["user.findUnique"]);
|
||||
expect(calls.filter((c) => WRITE.test(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("says the instance is unreachable instead of failing", async () => {
|
||||
fetchPlacesMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places"),
|
||||
undefined as never,
|
||||
)) as Response;
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).meta.errorCode).toBe("dawarich.unreachable");
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,24 @@
|
||||
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
|
||||
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
|
||||
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
|
||||
* Part of the `environment` module; own account only.
|
||||
* Part of the `environment` module; own account only, thirty reads a minute.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { fetchPlaces } from "@/lib/dawarich/client";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.places" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -28,5 +32,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.places.get");
|
||||
const rl = await checkRateLimit(`dawarich-places:${user.id}`, 30, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429, {
|
||||
errorCode: "dawarich.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const places = await fetchPlaces(stored.connection, parsed.data.q);
|
||||
annotate({ meta: { places: places.length } });
|
||||
return apiSuccess({ places });
|
||||
} catch {
|
||||
return apiError("Could not reach the Dawarich instance", 502, {
|
||||
errorCode: "dawarich.unreachable",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2,18 +2,45 @@
|
||||
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
|
||||
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
|
||||
* measured against. 422 `dawarich.home_place_unknown` for a place id the
|
||||
* instance does not have. Part of the `environment` module; own account only.
|
||||
* instance does not have; 409 `dawarich.not_connected`; 502
|
||||
* `dawarich.unreachable` when the place cannot be checked. Part of the
|
||||
* `environment` module; own account only.
|
||||
*
|
||||
* A new home country re-judges the stored travel days at once. A new home
|
||||
* place needs the visits again, so the stored days are fetched again in the
|
||||
* background (up to a year).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { placeExists } from "@/lib/dawarich/client";
|
||||
import {
|
||||
effectiveHomeCountry,
|
||||
loadDawarichConnection,
|
||||
saveDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { rejudgeTravelDays } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
DAWARICH_SYNC_MAX_DAYS,
|
||||
dawarichSettingsSchema,
|
||||
} from "@/lib/validations/dawarich";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.settings.update" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -28,5 +55,70 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.settings.patch");
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
const { prefs } = stored;
|
||||
const { homePlaceId, homeCountry } = parsed.data;
|
||||
|
||||
if (homePlaceId != null && homePlaceId !== prefs.homePlaceId) {
|
||||
let known: boolean;
|
||||
try {
|
||||
known = await placeExists(stored.connection, homePlaceId);
|
||||
} catch {
|
||||
return apiError("Could not reach the Dawarich instance", 502, {
|
||||
errorCode: "dawarich.unreachable",
|
||||
});
|
||||
}
|
||||
if (!known) {
|
||||
return apiError("The Dawarich instance has no such place", 422, {
|
||||
errorCode: "dawarich.home_place_unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const next = {
|
||||
...prefs,
|
||||
homePlaceId: homePlaceId === undefined ? prefs.homePlaceId : homePlaceId,
|
||||
homeCountry: homeCountry === undefined ? prefs.homeCountry : homeCountry,
|
||||
};
|
||||
await saveDawarichPrefs(user.id, next);
|
||||
|
||||
const countryChanged =
|
||||
effectiveHomeCountry(next) !== effectiveHomeCountry(prefs);
|
||||
if (countryChanged) {
|
||||
await rejudgeTravelDays(user.id, effectiveHomeCountry(next));
|
||||
}
|
||||
let refetch = "none";
|
||||
if (next.homePlaceId !== prefs.homePlaceId) {
|
||||
const oldest = await prisma.dawarichDay.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true },
|
||||
});
|
||||
if (oldest) {
|
||||
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
|
||||
const floor = shiftDateKey(yesterday, -(DAWARICH_SYNC_MAX_DAYS - 1));
|
||||
refetch = await enqueueDawarichSync({
|
||||
userId: user.id,
|
||||
fromDate: oldest.date > floor ? oldest.date : floor,
|
||||
});
|
||||
}
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
home_place_set: next.homePlaceId !== null,
|
||||
home_country_set: next.homeCountry !== null,
|
||||
travel_rejudged: countryChanged,
|
||||
refetch,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
homePlaceId: next.homePlaceId,
|
||||
homeCountry: next.homeCountry,
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,17 @@
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadDawarichStatus } from "@/lib/dawarich/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.status" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.status.get");
|
||||
return apiSuccess(await loadDawarichStatus(user.id));
|
||||
});
|
||||
@@ -1,26 +1,44 @@
|
||||
/**
|
||||
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
|
||||
* (at most 365 days back; default: the nightly window). 202
|
||||
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
|
||||
* module; own account only.
|
||||
* (at most 365 days back, ending yesterday; default: the nightly window).
|
||||
* 202 `{ enqueued }`: false when a run for this account is already queued or
|
||||
* no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`.
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
|
||||
import { daysBetweenDateKeys } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
DAWARICH_SYNC_MAX_DAYS,
|
||||
dawarichSyncSchema,
|
||||
} from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.sync.request" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
// The body is optional: no body is the nightly window.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
}
|
||||
const parsed = dawarichSyncSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -28,5 +46,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.sync");
|
||||
if (!(await loadDawarichConnection(user.id))) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
|
||||
const { fromDate } = parsed.data;
|
||||
if (fromDate !== undefined) {
|
||||
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
|
||||
const span = daysBetweenDateKeys(fromDate, yesterday) + 1;
|
||||
if (!Number.isFinite(span) || span < 1 || span > DAWARICH_SYNC_MAX_DAYS) {
|
||||
return apiError(
|
||||
`fromDate must lie within the ${DAWARICH_SYNC_MAX_DAYS} days ending yesterday`,
|
||||
422,
|
||||
{ errorCode: "dawarich.invalid" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = await enqueueDawarichSync({ userId: user.id, fromDate });
|
||||
annotate({ meta: { dawarich_sync: outcome } });
|
||||
return apiSuccess({ enqueued: outcome === "enqueued" }, 202);
|
||||
});
|
||||
@@ -1,15 +1,91 @@
|
||||
/**
|
||||
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
|
||||
* the shared integration probe shape. Part of the `environment` module; own
|
||||
* account only.
|
||||
* the shared integration probe shape. A failed probe is a 502 whose
|
||||
* `errorCode` is one of the shared probe classes (`credentials_rejected`,
|
||||
* `rate_limited`, `upstream_error`, `timeout`, `url_not_public`,
|
||||
* `connection_failed`); 422 `not_configured` without a connection. Part of the
|
||||
* `environment` module; own account only, five probes a minute.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
DawarichApiError,
|
||||
DawarichPolicyError,
|
||||
probeDawarich,
|
||||
} from "@/lib/dawarich/client";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function probeClass(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof DawarichPolicyError) {
|
||||
return {
|
||||
code: "url_not_public",
|
||||
message:
|
||||
"The instance is on a private network the operator has not approved",
|
||||
};
|
||||
}
|
||||
if (error instanceof DawarichApiError) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Dawarich rejected the API key",
|
||||
};
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Dawarich rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (error.status !== null && error.status >= 500) {
|
||||
return { code: "upstream_error", message: "Dawarich returned an error" };
|
||||
}
|
||||
}
|
||||
if (error instanceof SafeFetchError && error.kind === "timeout") {
|
||||
return { code: "timeout", message: "Dawarich request timed out" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Dawarich connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.test" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.test");
|
||||
|
||||
const rl = await checkRateLimit(`dawarich-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await probeDawarich(stored.connection, TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
const probe = probeClass(error);
|
||||
annotate({ meta: { dawarich_test_code: probe.code } });
|
||||
return apiError(probe.message, 502, { errorCode: probe.code });
|
||||
}
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const ledger = await getIntegrationStatus(user.id, "dawarich");
|
||||
return apiSuccess({
|
||||
ok: true as const,
|
||||
lastSyncedAt: ledger.lastSuccessAt,
|
||||
latencyMs,
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,56 @@
|
||||
/**
|
||||
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
|
||||
* this run of travel days again. Answers `{ dismissed: true }`; 404
|
||||
* `environment.travel_suggestion.not_found`. Part of the `environment`
|
||||
* module; own account only.
|
||||
* `environment.travel_suggestion.not_found` for an id that is not an open
|
||||
* suggestion. Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
openDawarichPrefs,
|
||||
saveDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import { listTravelSuggestions } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "environment.travel_suggestion.dismiss" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestion.dismiss");
|
||||
|
||||
const notFound = () =>
|
||||
apiError("No such travel suggestion", 404, {
|
||||
errorCode: "environment.travel_suggestion.not_found",
|
||||
});
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
if (!row?.dawarichUrlEncrypted || id.length > 64) return notFound();
|
||||
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
|
||||
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
|
||||
const open = await listTravelSuggestions(
|
||||
user.id,
|
||||
today,
|
||||
new Set(prefs.dismissedSuggestionIds),
|
||||
);
|
||||
if (!open.some((s) => s.id === id)) return notFound();
|
||||
|
||||
await saveDawarichPrefs(user.id, {
|
||||
...prefs,
|
||||
dismissedSuggestionIds: [...prefs.dismissedSuggestionIds, id],
|
||||
});
|
||||
return apiSuccess({ dismissed: true as const });
|
||||
},
|
||||
);
|
||||
@@ -3,17 +3,39 @@
|
||||
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
|
||||
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
|
||||
* (the person picks the place); dismissing through the sibling route. Part of
|
||||
* the `environment` module; own account only.
|
||||
* the `environment` module; own account only. Without a Dawarich connection
|
||||
* the list is empty.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { openDawarichPrefs } from "@/lib/dawarich/connection";
|
||||
import { listTravelSuggestions } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "environment.travel_suggestions.list" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestions.get");
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
if (!row?.dawarichUrlEncrypted) return apiSuccess({ suggestions: [] });
|
||||
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
|
||||
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
|
||||
const suggestions = await listTravelSuggestions(
|
||||
user.id,
|
||||
today,
|
||||
new Set(prefs.dismissedSuggestionIds),
|
||||
);
|
||||
annotate({ meta: { suggestions: suggestions.length } });
|
||||
return apiSuccess({ suggestions });
|
||||
});
|
||||
@@ -62,6 +62,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -86,6 +86,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// channels in (read from EnvironmentContext); default empty so no env
|
||||
// channel produces a point.
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
|
||||
@@ -338,7 +338,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
expect(envelope.metricFreshnessDegraded).toBe(true);
|
||||
// Still fail-soft: the rest of the envelope survives, all eight entries
|
||||
// present, because a freshness hiccup is never worth the settings surface.
|
||||
expect(envelope.integrations).toHaveLength(8);
|
||||
expect(envelope.integrations).toHaveLength(9);
|
||||
for (const entry of envelope.integrations) {
|
||||
expect(entry.metricFreshness).toEqual([]);
|
||||
}
|
||||
@@ -469,6 +469,7 @@ describe("/api/integrations/status — the unified connection health", () => {
|
||||
oura: ouraHealth,
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
dawarich: null,
|
||||
"apple-health": null,
|
||||
} as never);
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
@@ -69,6 +69,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraStatus,
|
||||
stravaStatus,
|
||||
nightscoutStatus,
|
||||
dawarichStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -89,6 +90,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
getIntegrationStatus(user.id, "nightscout"),
|
||||
getIntegrationStatus(user.id, "dawarich"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -115,6 +117,7 @@ export const GET = apiHandler(async () => {
|
||||
nightscoutUrlEncrypted: true,
|
||||
nightscoutTokenEncrypted: true,
|
||||
nightscoutAllowPrivateHost: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -173,6 +176,7 @@ export const GET = apiHandler(async () => {
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
]);
|
||||
|
||||
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
|
||||
@@ -283,6 +287,7 @@ export const GET = apiHandler(async () => {
|
||||
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
|
||||
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
|
||||
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
|
||||
|
||||
if (metricFreshnessDegraded) {
|
||||
annotate({ meta: { integrations_metric_freshness_degraded: true } });
|
||||
@@ -460,6 +465,18 @@ export const GET = apiHandler(async () => {
|
||||
configured: nightscoutConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & NightscoutExtras,
|
||||
{
|
||||
...publish(dawarichStatus),
|
||||
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
||||
// rides along like on every entry, and goes with the rest in v1.44.
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
health: health["dawarich"],
|
||||
...resolve("dawarich", dawarichStatus, {
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & DawarichExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -560,3 +577,10 @@ interface NightscoutExtras {
|
||||
hasToken: boolean;
|
||||
allowPrivateHost: boolean;
|
||||
}
|
||||
|
||||
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
|
||||
// own status route.
|
||||
interface DawarichExtras {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
@@ -70,6 +70,9 @@ export const GET = apiHandler(async () => {
|
||||
polar: context.user.polarAccessTokenEncrypted !== null,
|
||||
oura: context.user.ouraAccessTokenEncrypted !== null,
|
||||
strava: context.user.stravaAccessTokenEncrypted !== null,
|
||||
// Not offered on a managed profile (no managed integrations before
|
||||
// v1.44); listed so the map stays total.
|
||||
dawarich: false,
|
||||
};
|
||||
// The one server-resolved state per connection, from the same loader the
|
||||
// owner's own envelope uses, so a guardian reads the record's connections in
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import type { DayResponse } from "@/lib/day/contract";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { DAY_SECTION_LABEL } from "./day-sections";
|
||||
|
||||
type DayMovement = NonNullable<DayResponse["movement"]>;
|
||||
|
||||
/**
|
||||
* "On the move": the day's movement context from Dawarich, as the server
|
||||
* resolved it. Distances arrive in the account's unit; a field the day has no
|
||||
* value for is left out, never shown as zero. The country of a travel day is
|
||||
* named in the reader's language.
|
||||
*/
|
||||
export function DayMovementSection({ movement }: { movement: DayMovement }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const unit = movement.distance.unit;
|
||||
const distance = (value: number) =>
|
||||
t(unit === "mi" ? "day.movement.miles" : "day.movement.kilometres", {
|
||||
value: fmt.number(value, 1),
|
||||
});
|
||||
|
||||
const rows: Array<{ key: string; label: string; value: string }> = [];
|
||||
if (movement.distance.total !== null) {
|
||||
rows.push({
|
||||
key: "total",
|
||||
label: t("day.movement.total"),
|
||||
value: distance(movement.distance.total),
|
||||
});
|
||||
}
|
||||
if (movement.distance.driving) {
|
||||
rows.push({
|
||||
key: "driving",
|
||||
label: t("day.movement.driving"),
|
||||
value: distance(movement.distance.driving),
|
||||
});
|
||||
}
|
||||
if (movement.distance.train) {
|
||||
rows.push({
|
||||
key: "train",
|
||||
label: t("day.movement.train"),
|
||||
value: distance(movement.distance.train),
|
||||
});
|
||||
}
|
||||
if (movement.placesVisited !== null) {
|
||||
rows.push({
|
||||
key: "places",
|
||||
label: t("day.movement.places"),
|
||||
value: fmt.number(movement.placesVisited, 0),
|
||||
});
|
||||
}
|
||||
if (movement.awayFromHomeMinutes !== null) {
|
||||
rows.push({
|
||||
key: "away",
|
||||
label: t("day.movement.away"),
|
||||
value: formatDurationMinutes(movement.awayFromHomeMinutes, t),
|
||||
});
|
||||
}
|
||||
if (movement.travel?.isTravelDay) {
|
||||
const code = movement.travel.countryCode;
|
||||
rows.push({
|
||||
key: "travel",
|
||||
label: t("day.movement.travel"),
|
||||
value: code ? countryName(code, locale) : t("day.movement.travelYes"),
|
||||
});
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-movement">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.movement")}</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="min-w-0" data-movement={row.key}>
|
||||
<dt className="text-muted-foreground truncate text-xs">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="truncate text-sm tabular-nums">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function countryName(code: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DayRunning,
|
||||
DayValues,
|
||||
} from "./day-sections";
|
||||
import { DayMovementSection } from "./day-movement";
|
||||
import { DayScores } from "./day-scores";
|
||||
import { tileKeyOf } from "./day-values-model";
|
||||
import { shiftDateKey } from "./day-url";
|
||||
@@ -148,7 +149,8 @@ export function DayView({
|
||||
data.values.length === 0 &&
|
||||
data.events.length === 0 &&
|
||||
data.running.length === 0 &&
|
||||
(data.scores ?? []).length === 0;
|
||||
(data.scores ?? []).length === 0 &&
|
||||
!data.movement;
|
||||
const notShared = data
|
||||
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
|
||||
: false;
|
||||
@@ -302,6 +304,9 @@ export function DayView({
|
||||
/>
|
||||
)}
|
||||
<DayScores scores={data.scores ?? []} />
|
||||
{data.movement ? (
|
||||
<DayMovementSection movement={data.movement} />
|
||||
) : null}
|
||||
<DayEvents events={data.events} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -77,6 +77,9 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
|
||||
ENV_PM25: "environment.fields.pm25",
|
||||
ENV_OZONE_8H: "environment.fields.ozone8h",
|
||||
ENV_POLLEN_MAX: "environment.fields.pollenMax",
|
||||
// v1.43 — the Dawarich movement channels.
|
||||
MOVEMENT_AWAY: "environment.movement.awayFromHome",
|
||||
MOVEMENT_DISTANCE: "environment.movement.distance",
|
||||
};
|
||||
|
||||
/** Keep only the discovered pairs that involve the mood channel. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react";
|
||||
|
||||
import { TravelSuggestions } from "@/components/settings/environment-travel-suggestions";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -415,6 +416,16 @@ export function EnvironmentSection() {
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.travel.description")}
|
||||
</p>
|
||||
<TravelSuggestions
|
||||
enabled={enabled}
|
||||
onUse={({ startDate, endDate, place }) => {
|
||||
setTravelStart(startDate);
|
||||
setTravelEnd(endDate);
|
||||
setTravelQuery(place);
|
||||
setTravelLoc(null);
|
||||
setTravelSearched(false);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{data && data.travel.length > 0 ? (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface TravelSuggestion {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
function countryName(code: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trips suggested from Dawarich travel days. "Use" fills the trip form below
|
||||
* with the dates and the country, and the person picks the place; the saved
|
||||
* trip then covers the run and the suggestion goes. "Dismiss" hides it for
|
||||
* good. Renders nothing while there is nothing to suggest, which includes
|
||||
* every account without a Dawarich connection.
|
||||
*/
|
||||
export function TravelSuggestions({
|
||||
enabled,
|
||||
onUse,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onUse: (suggestion: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
place: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const suggestions = useQuery({
|
||||
queryKey: queryKeys.environmentTravelSuggestions(),
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<{ suggestions: TravelSuggestion[] }>(
|
||||
"/api/environment/travel/suggestions",
|
||||
),
|
||||
});
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost(
|
||||
`/api/environment/travel/suggestions/${encodeURIComponent(id)}/dismiss`,
|
||||
),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.environmentTravelSuggestions(),
|
||||
}),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.sections.environment.saveError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
|
||||
const list = suggestions.data?.suggestions ?? [];
|
||||
if (list.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="travel-suggestions">
|
||||
<p className="text-sm">
|
||||
{t("settings.sections.environment.travel.suggestionsIntro")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{list.map((s) => {
|
||||
const place = countryName(s.countryCode, locale);
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
data-slot="travel-suggestion"
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{place}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("settings.sections.environment.travel.range", {
|
||||
start: fmt.dateShortSmartCalendar(s.startDate),
|
||||
end: fmt.dateShortSmartCalendar(s.endDate),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() =>
|
||||
onUse({ startDate: s.startDate, endDate: s.endDate, place })
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
{t("settings.sections.environment.travel.suggestionUse")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t(
|
||||
"settings.sections.environment.travel.suggestionDismiss",
|
||||
)}
|
||||
disabled={dismiss.isPending}
|
||||
onClick={() => dismiss.mutate(s.id)}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,11 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/settings/integrations",
|
||||
}));
|
||||
|
||||
// The Dawarich card belongs to the opt-in environment module.
|
||||
let environmentOn = true;
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
user: { id: "u1", role: "USER", modules: { environment: environmentOn } },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -150,6 +152,18 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
|
||||
expect(render()).not.toContain('data-testid="integration-fallback-row"');
|
||||
});
|
||||
|
||||
it("shows a stored Dawarich connection read-only while the environment module is off", () => {
|
||||
environmentOn = false;
|
||||
try {
|
||||
envelope = fullEnvelope();
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-testid="dawarich-card"');
|
||||
expect(html).toContain('data-integration="dawarich"');
|
||||
} finally {
|
||||
environmentOn = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders no fallback row for a card-less provider that is not configured", () => {
|
||||
// Absence stays absence: an unconfigured provider is not a problem to
|
||||
// report, so the panel says nothing about it.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
|
||||
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
|
||||
@@ -193,6 +194,7 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
]);
|
||||
|
||||
export function ConnectionsPanel({
|
||||
@@ -207,7 +209,9 @@ export function ConnectionsPanel({
|
||||
callbackUrls: IntegrationCallbackUrls;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// Dawarich is part of the environment module, which is opt-in.
|
||||
const environmentOn = user?.modules?.environment === true;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -296,7 +300,12 @@ export function ConnectionsPanel({
|
||||
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
// With the environment module off the Dawarich card is not shown, so
|
||||
// a connection still stored falls through to the read-only row.
|
||||
!(
|
||||
CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
(entry.integration !== "dawarich" || environmentOn)
|
||||
) &&
|
||||
(entry.configured || entry.connected),
|
||||
)
|
||||
.map((entry) => ({
|
||||
@@ -376,6 +385,11 @@ export function ConnectionsPanel({
|
||||
viewModel={pickStatus(integrationStatus, "nightscout")}
|
||||
/>
|
||||
</div>
|
||||
{environmentOn ? (
|
||||
<div id="dawarich" className="scroll-mt-28">
|
||||
<DawarichCard viewModel={pickStatus(integrationStatus, "dawarich")} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Every configured envelope entry no card above claims. Normally this
|
||||
renders nothing; on an account carrying a dropped-but-still-running
|
||||
integration it renders the honest status that provider otherwise has
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
// Dawarich movement context. Like Nightscout it is an address and a key the
|
||||
// self-hoster pastes once; unlike it, it only ever reads daily values (how
|
||||
// far, by car or train, how long away from home, which country) and never a
|
||||
// coordinate. The card reads the consolidated envelope for `health`, and the
|
||||
// Dawarich status route for what only this card shows: the home place, the
|
||||
// home country and how many days are stored. It belongs to the environment
|
||||
// module and is not shown while that module is off.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Loader2,
|
||||
MapPinned,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ActionResultLines, useConnectionTest } from "./integration-actions";
|
||||
import {
|
||||
IntegrationCard,
|
||||
type IntegrationMenuAction,
|
||||
} from "./integration-card";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
integrationDocsHref,
|
||||
} from "./setup-guide-link";
|
||||
import type { IntegrationStatusViewModel } from "./shared";
|
||||
|
||||
interface DawarichStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
homePlaceSet: boolean;
|
||||
homeCountry: string | null;
|
||||
daysStored: number;
|
||||
oldestDay: string | null;
|
||||
latestDay: string | null;
|
||||
health: ConnectionHealth | null;
|
||||
}
|
||||
|
||||
/** Error codes the connect route answers, with the sentence each one gets. */
|
||||
const CONNECT_ERROR_KEYS: Record<string, string> = {
|
||||
"dawarich.invalid": "settings.dawarichErrors.invalid",
|
||||
private_origin_not_approved: "settings.dawarichErrors.privateOrigin",
|
||||
"dawarich.credentials_rejected": "settings.dawarichErrors.credentials",
|
||||
"dawarich.unreachable": "settings.dawarichErrors.unreachable",
|
||||
"dawarich.rate_limited": "settings.dawarichErrors.rateLimited",
|
||||
"dawarich.home_place_unknown": "settings.dawarichErrors.homePlaceUnknown",
|
||||
"dawarich.not_connected": "settings.dawarichErrors.notConnected",
|
||||
};
|
||||
|
||||
function errorKeyOf(error: unknown): string {
|
||||
const code =
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: null;
|
||||
return (
|
||||
(code && CONNECT_ERROR_KEYS[code]) ?? "settings.dawarichErrors.generic"
|
||||
);
|
||||
}
|
||||
|
||||
export function DawarichCard({
|
||||
viewModel,
|
||||
}: {
|
||||
viewModel?: IntegrationStatusViewModel;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const connected = viewModel?.connected ?? false;
|
||||
const test = useConnectionTest("/api/dawarich/test");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dawarichStatus() });
|
||||
};
|
||||
|
||||
const sync = useMutation({
|
||||
mutationFn: () => apiPost<{ enqueued: boolean }>("/api/dawarich/sync", {}),
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t(
|
||||
result.enqueued
|
||||
? "settings.dawarichSyncQueued"
|
||||
: "settings.dawarichSyncAlreadyQueued",
|
||||
),
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => showErrorToast(t(errorKeyOf(error)), { cause: error }),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (deleteData: boolean) =>
|
||||
apiPost("/api/dawarich/disconnect", { deleteData }),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.environment() });
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("settings.dawarichErrors.generic"), { cause: error }),
|
||||
});
|
||||
|
||||
function openForm() {
|
||||
setEditing(true);
|
||||
requestAnimationFrame(() => {
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("#dawarich-url")
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
const form = (
|
||||
<DawarichForm
|
||||
formRef={formRef}
|
||||
connected={connected}
|
||||
onConnected={() => {
|
||||
setEditing(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<IntegrationCard
|
||||
provider="dawarich"
|
||||
providerName={t("settings.dawarich")}
|
||||
icon={MapPinned}
|
||||
titleAccessory={<TagChip>{t("settings.dawarichTag")}</TagChip>}
|
||||
description={
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix="settings.dawarich"
|
||||
provider="dawarich"
|
||||
/>
|
||||
}
|
||||
loading={viewModel === undefined}
|
||||
connected={false}
|
||||
health={null}
|
||||
setup={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const menu: IntegrationMenuAction[] = [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("settings.dawarichSync"),
|
||||
icon: RefreshCw,
|
||||
disabled: sync.isPending,
|
||||
onSelect: () => sync.mutate(),
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: t("settings.testConnection.test"),
|
||||
icon: Plug,
|
||||
disabled: test.testing,
|
||||
onSelect: test.run,
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
label: t("settings.integrationCard.menu.address"),
|
||||
icon: KeyRound,
|
||||
onSelect: () => setEditing((open) => !open),
|
||||
},
|
||||
{
|
||||
id: "setup-guide",
|
||||
label: t("settings.integrationSetupGuide"),
|
||||
icon: BookOpen,
|
||||
href: integrationDocsHref("dawarich"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "disconnect",
|
||||
label: t("settings.dawarichDisconnect"),
|
||||
icon: Unlink,
|
||||
destructive: true,
|
||||
onSelect: () => disconnect.mutate(false),
|
||||
confirm: {
|
||||
title: t("settings.dawarichDisconnectTitle"),
|
||||
body: t("settings.dawarichDisconnectDescription"),
|
||||
confirmLabel: t("settings.dawarichDisconnect"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "disconnect-delete",
|
||||
label: t("settings.dawarichDisconnectDelete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onSelect: () => disconnect.mutate(true),
|
||||
confirm: {
|
||||
title: t("settings.dawarichDisconnectDeleteTitle"),
|
||||
body: t("settings.dawarichDisconnectDeleteDescription"),
|
||||
confirmLabel: t("settings.dawarichDisconnectDelete"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<IntegrationCard
|
||||
provider="dawarich"
|
||||
providerName={t("settings.dawarich")}
|
||||
icon={MapPinned}
|
||||
titleAccessory={<TagChip>{t("settings.dawarichTag")}</TagChip>}
|
||||
connected
|
||||
health={viewModel?.health ?? null}
|
||||
primary={{ onReconnect: openForm, onResume: openForm }}
|
||||
menu={menu}
|
||||
>
|
||||
<DawarichHome />
|
||||
<ActionResultLines provider="dawarich" test={test} />
|
||||
{editing ? form : null}
|
||||
</IntegrationCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The home place and home country, and what is stored. */
|
||||
function DawarichHome() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const status = useQuery({
|
||||
queryKey: queryKeys.dawarichStatus(),
|
||||
queryFn: () => apiGet<DawarichStatus>("/api/dawarich/status"),
|
||||
});
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [placeId, setPlaceId] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
const places = useQuery({
|
||||
queryKey: queryKeys.dawarichPlaces(""),
|
||||
queryFn: () =>
|
||||
apiGet<{ places: Array<{ id: number; name: string }> }>(
|
||||
"/api/dawarich/places",
|
||||
),
|
||||
enabled: picking,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: {
|
||||
homePlaceId?: number | null;
|
||||
homeCountry?: string | null;
|
||||
}) => apiPatch("/api/dawarich/settings", body),
|
||||
onSuccess: () => {
|
||||
toast.success(t("settings.dawarichHomeSaved"));
|
||||
setPicking(false);
|
||||
setPlaceId("");
|
||||
setCountry("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dawarichStatus() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.environmentTravelSuggestions(),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorToast(t(errorKeyOf(error)), { cause: error }),
|
||||
});
|
||||
|
||||
const data = status.data;
|
||||
if (!data) return null;
|
||||
const countryValue = country.trim().toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="dawarich-home">
|
||||
<p className="text-sm">
|
||||
{data.homePlaceSet
|
||||
? t("settings.dawarichHomePlaceSet")
|
||||
: t("settings.dawarichHomePlaceMissing")}{" "}
|
||||
{data.homeCountry
|
||||
? t("settings.dawarichHomeCountryIs", { country: data.homeCountry })
|
||||
: t("settings.dawarichHomeCountryMissing")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dawarichDaysStored", { count: data.daysStored })}
|
||||
</p>
|
||||
{picking ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dawarich-home-place">
|
||||
{t("settings.dawarichHomePlace")}
|
||||
</Label>
|
||||
{places.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(errorKeyOf(places.error))}
|
||||
</p>
|
||||
) : (
|
||||
<NativeSelect
|
||||
id="dawarich-home-place"
|
||||
value={placeId}
|
||||
disabled={places.isLoading}
|
||||
onChange={(e) => setPlaceId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("settings.dawarichHomePlaceChoose")}</option>
|
||||
{(places.data?.places ?? []).map((place) => (
|
||||
<option key={place.id} value={String(place.id)}>
|
||||
{place.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dawarich-home-country">
|
||||
{t("settings.dawarichHomeCountry")}
|
||||
</Label>
|
||||
<Input
|
||||
id="dawarich-home-country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
placeholder={data.homeCountry ?? "DE"}
|
||||
maxLength={2}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
className="w-24"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dawarichHomeCountryHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardActions>
|
||||
{picking ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setPicking(true)}
|
||||
data-testid="dawarich-pick-home"
|
||||
>
|
||||
<MapPinned className="size-3.5" aria-hidden="true" />
|
||||
{t("settings.dawarichHomePlaceChange")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={
|
||||
save.isPending ||
|
||||
(placeId === "" && !/^[A-Z]{2}$/.test(countryValue))
|
||||
}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
...(placeId !== "" ? { homePlaceId: Number(placeId) } : {}),
|
||||
...(/^[A-Z]{2}$/.test(countryValue)
|
||||
? { homeCountry: countryValue }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
data-testid="dawarich-save-home"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("settings.dawarichHomeSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DawarichForm({
|
||||
formRef,
|
||||
connected,
|
||||
onConnected,
|
||||
}: {
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
connected: boolean;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [url, setUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const connect = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost("/api/dawarich/connect", {
|
||||
url: url.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setApiKey("");
|
||||
toast.success(t("settings.dawarichConnected"));
|
||||
onConnected();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
connect.mutate();
|
||||
}}
|
||||
className="space-y-3"
|
||||
data-testid="dawarich-form"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dawarich-url">{t("settings.dawarichUrl")}</Label>
|
||||
<Input
|
||||
id="dawarich-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://dawarich.example.com"
|
||||
maxLength={2048}
|
||||
autoComplete="off"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dawarich-api-key">{t("settings.dawarichApiKey")}</Label>
|
||||
<PasswordInput
|
||||
id="dawarich-api-key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dawarichApiKeyHelp")}
|
||||
</p>
|
||||
</div>
|
||||
{connect.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(errorKeyOf(connect.error))}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={connected ? "outline" : "default"}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={connect.isPending || !url.trim() || !apiKey.trim()}
|
||||
data-slot={connected ? undefined : "integration-primary-action"}
|
||||
data-action={connected ? undefined : "connect"}
|
||||
data-testid="dawarich-connect"
|
||||
>
|
||||
{connect.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : connected ? (
|
||||
<Save className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2 className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{connected
|
||||
? t("settings.dawarichUpdate")
|
||||
: t("settings.dawarichConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
dawarich: "Dawarich",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,9 @@ export type IntegrationDocsProvider =
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava";
|
||||
| "strava"
|
||||
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
|
||||
| "dawarich";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
|
||||
@@ -26,7 +26,9 @@ export type IntegrationKey =
|
||||
| "strava"
|
||||
// Nightscout folds onto the envelope so the card stops fetching its own
|
||||
// status and inherits the shared verdict.
|
||||
| "nightscout";
|
||||
| "nightscout"
|
||||
// v1.43 — Dawarich movement context; publishes `health` only.
|
||||
| "dawarich";
|
||||
/**
|
||||
* One envelope entry as the cards read it. Since v1.42.4 the cards render
|
||||
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
|
||||
symptomDefinition: { findMany: async () => [] },
|
||||
dawarichDay: { findMany: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parsePlaces,
|
||||
parseResidency,
|
||||
parseTimelineDays,
|
||||
parseVisits,
|
||||
} from "../client";
|
||||
|
||||
describe("parseTimelineDays", () => {
|
||||
it("keeps the summary figures and drops everything else", () => {
|
||||
const days = parseTimelineDays({
|
||||
days: [
|
||||
{
|
||||
date: "2026-06-15",
|
||||
summary: {
|
||||
total_distance: 48.3,
|
||||
places_visited: 4,
|
||||
mode_distances: { train: 30.5, driving: 12, walking: 1.2 },
|
||||
},
|
||||
bounds: { sw_lat: 41.1, sw_lng: 2.1 },
|
||||
entries: [{ type: "visit", name: "Office" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(days).toEqual([
|
||||
{
|
||||
date: "2026-06-15",
|
||||
totalKm: 48.3,
|
||||
drivingKm: 12,
|
||||
trainKm: 30.5,
|
||||
placesVisited: 4,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(days)).not.toContain("Office");
|
||||
});
|
||||
|
||||
it("reads a mode Dawarich left out as zero for that day", () => {
|
||||
const [day] = parseTimelineDays({
|
||||
days: [{ date: "2026-06-15", summary: { total_distance: 2.1 } }],
|
||||
});
|
||||
expect(day.drivingKm).toBe(0);
|
||||
expect(day.trainKm).toBe(0);
|
||||
expect(day.placesVisited).toBeNull();
|
||||
});
|
||||
|
||||
it("answers nothing for a payload that is not a timeline", () => {
|
||||
expect(parseTimelineDays({ error: "nope" })).toEqual([]);
|
||||
expect(parseTimelineDays(null)).toEqual([]);
|
||||
expect(
|
||||
parseTimelineDays({ days: [{ date: "yesterday", summary: {} }] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVisits", () => {
|
||||
it("reduces a visit to its place id and span, and drops declined ones", () => {
|
||||
const visits = parseVisits([
|
||||
{
|
||||
id: 1,
|
||||
started_at: "2026-06-15T07:00:00Z",
|
||||
ended_at: "2026-06-15T15:00:00Z",
|
||||
status: "confirmed",
|
||||
name: "Office",
|
||||
place: { id: 9, latitude: 52.5, longitude: 13.4 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
started_at: "2026-06-15T16:00:00Z",
|
||||
ended_at: "2026-06-15T17:00:00Z",
|
||||
status: "declined",
|
||||
place: { id: 7 },
|
||||
},
|
||||
{ id: 3, started_at: "bad", ended_at: "2026-06-15T17:00:00Z" },
|
||||
]);
|
||||
expect(visits).toEqual([
|
||||
{
|
||||
placeId: 9,
|
||||
startMs: Date.parse("2026-06-15T07:00:00Z"),
|
||||
endMs: Date.parse("2026-06-15T15:00:00Z"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseResidency", () => {
|
||||
it("translates each day's country name into its alpha-2 code", () => {
|
||||
const map = parseResidency({
|
||||
year: 2026,
|
||||
daily_countries: {
|
||||
"2026-06-15": "Spain",
|
||||
"2026-06-14": "Germany",
|
||||
"2026-06-13": "Atlantis",
|
||||
},
|
||||
countries: [
|
||||
{ country_name: "Spain", iso_a2: "ES" },
|
||||
{ country_name: "Germany", iso_a2: "de" },
|
||||
{ country_name: "Atlantis", iso_a2: null },
|
||||
],
|
||||
});
|
||||
expect([...map]).toEqual([
|
||||
["2026-06-15", "ES"],
|
||||
["2026-06-14", "DE"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlaces", () => {
|
||||
const payload = [
|
||||
{ id: 9, name: "Office", latitude: 52.5 },
|
||||
{ id: 7, name: "Home", longitude: 13.4 },
|
||||
{ id: 3, name: " " },
|
||||
{ id: "x", name: "Broken" },
|
||||
];
|
||||
|
||||
it("keeps id and name, sorted by name", () => {
|
||||
expect(parsePlaces(payload)).toEqual([
|
||||
{ id: 7, name: "Home" },
|
||||
{ id: 9, name: "Office" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by the query, ignoring case", () => {
|
||||
expect(parsePlaces(payload, "off")).toEqual([{ id: 9, name: "Office" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
awayFromHomeMinutes,
|
||||
buildDayValues,
|
||||
mostFrequentCountry,
|
||||
travelDay,
|
||||
} from "../day-values";
|
||||
|
||||
const at = (iso: string) => Date.parse(iso);
|
||||
const HOME = 7;
|
||||
// 2026-06-15 in Europe/Berlin is 22:00Z on the 14th to 22:00Z on the 15th.
|
||||
const dayStart = new Date("2026-06-14T22:00:00Z");
|
||||
const dayEnd = new Date("2026-06-15T22:00:00Z");
|
||||
|
||||
describe("awayFromHomeMinutes", () => {
|
||||
it("subtracts the minutes the home visits cover inside the day", () => {
|
||||
const visits = [
|
||||
{
|
||||
placeId: HOME,
|
||||
startMs: at("2026-06-14T18:00:00Z"),
|
||||
endMs: at("2026-06-15T06:00:00Z"),
|
||||
},
|
||||
{
|
||||
placeId: 9,
|
||||
startMs: at("2026-06-15T07:00:00Z"),
|
||||
endMs: at("2026-06-15T15:00:00Z"),
|
||||
},
|
||||
{
|
||||
placeId: HOME,
|
||||
startMs: at("2026-06-15T16:00:00Z"),
|
||||
endMs: at("2026-06-16T05:00:00Z"),
|
||||
},
|
||||
];
|
||||
// 8 h + 6 h at home out of 24 h.
|
||||
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(600);
|
||||
});
|
||||
|
||||
it("counts overlapping home visits once", () => {
|
||||
const visits = [
|
||||
{
|
||||
placeId: HOME,
|
||||
startMs: at("2026-06-15T00:00:00Z"),
|
||||
endMs: at("2026-06-15T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
placeId: HOME,
|
||||
startMs: at("2026-06-15T05:00:00Z"),
|
||||
endMs: at("2026-06-15T12:00:00Z"),
|
||||
},
|
||||
];
|
||||
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(
|
||||
1440 - 12 * 60,
|
||||
);
|
||||
});
|
||||
|
||||
it("is null without a single visit that day: no coverage, not zero", () => {
|
||||
const visits = [
|
||||
{
|
||||
placeId: HOME,
|
||||
startMs: at("2026-06-13T10:00:00Z"),
|
||||
endMs: at("2026-06-13T12:00:00Z"),
|
||||
},
|
||||
];
|
||||
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBeNull();
|
||||
});
|
||||
|
||||
it("is null without a home place", () => {
|
||||
const visits = [
|
||||
{
|
||||
placeId: 9,
|
||||
startMs: at("2026-06-15T07:00:00Z"),
|
||||
endMs: at("2026-06-15T15:00:00Z"),
|
||||
},
|
||||
];
|
||||
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a day spent elsewhere as the whole day away", () => {
|
||||
const visits = [
|
||||
{
|
||||
placeId: 9,
|
||||
startMs: at("2026-06-15T07:00:00Z"),
|
||||
endMs: at("2026-06-15T15:00:00Z"),
|
||||
},
|
||||
];
|
||||
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe("travelDay", () => {
|
||||
it("needs a country and a home country", () => {
|
||||
expect(travelDay("ES", "DE")).toBe(true);
|
||||
expect(travelDay("DE", "DE")).toBe(false);
|
||||
expect(travelDay(null, "DE")).toBeNull();
|
||||
expect(travelDay("ES", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mostFrequentCountry", () => {
|
||||
it("counts the window only and breaks a tie alphabetically", () => {
|
||||
const map = new Map([
|
||||
["2026-01-01", "ES"],
|
||||
["2026-01-02", "DE"],
|
||||
["2025-01-01", "FR"],
|
||||
["2025-01-02", "FR"],
|
||||
["2025-01-03", "FR"],
|
||||
]);
|
||||
expect(mostFrequentCountry(map, "2026-01-01", "2026-12-31")).toBe("DE");
|
||||
expect(mostFrequentCountry(map, "2025-01-01", "2026-12-31")).toBe("FR");
|
||||
expect(
|
||||
mostFrequentCountry(new Map(), "2025-01-01", "2026-12-31"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDayValues", () => {
|
||||
it("writes metres, keeps nulls honest and leaves out empty days", () => {
|
||||
const rows = buildDayValues({
|
||||
days: ["2026-06-15", "2026-06-16", "2026-06-17"],
|
||||
tz: "Europe/Berlin",
|
||||
timeline: [
|
||||
{
|
||||
date: "2026-06-15",
|
||||
totalKm: 48.3,
|
||||
drivingKm: 12,
|
||||
trainKm: 30.5,
|
||||
placesVisited: 4,
|
||||
},
|
||||
],
|
||||
visits: [
|
||||
{
|
||||
placeId: 9,
|
||||
startMs: at("2026-06-15T07:00:00Z"),
|
||||
endMs: at("2026-06-15T15:00:00Z"),
|
||||
},
|
||||
],
|
||||
countries: new Map([["2026-06-15", "ES"]]),
|
||||
homePlaceId: HOME,
|
||||
homeCountry: "DE",
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
date: "2026-06-15",
|
||||
totalDistanceM: 48_300,
|
||||
drivingDistanceM: 12_000,
|
||||
trainDistanceM: 30_500,
|
||||
placesVisited: 4,
|
||||
awayFromHomeMinutes: 1440,
|
||||
countryCode: "ES",
|
||||
isTravelDay: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
configuredDawarichPrivateOrigins,
|
||||
DawarichOriginConfigError,
|
||||
evaluateDawarichUrl,
|
||||
} from "../origin";
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set();
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DAWARICH_PRIVATE_ORIGINS;
|
||||
});
|
||||
|
||||
describe("evaluateDawarichUrl", () => {
|
||||
it("lets a public instance through and keeps its path", () => {
|
||||
const target = evaluateDawarichUrl("https://8.8.8.8/dawarich/", NONE);
|
||||
expect(target).toMatchObject({
|
||||
allowed: true,
|
||||
baseUrl: "https://8.8.8.8/dawarich",
|
||||
privateOriginApproved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a private instance the operator did not list", () => {
|
||||
const target = evaluateDawarichUrl("http://192.168.1.20:3000", NONE);
|
||||
expect(target.allowed).toBe(false);
|
||||
expect(target.reasonCode).toBe("private_origin_not_approved");
|
||||
});
|
||||
|
||||
it("opens exactly the listed origin, not a sibling port", () => {
|
||||
const grants = new Set(["http://192.168.1.20:3000"]);
|
||||
expect(
|
||||
evaluateDawarichUrl("http://192.168.1.20:3000", grants),
|
||||
).toMatchObject({ allowed: true, privateOriginApproved: true });
|
||||
expect(
|
||||
evaluateDawarichUrl("http://192.168.1.20:3001", grants).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses credentials and a query in the address", () => {
|
||||
expect(
|
||||
evaluateDawarichUrl("https://user:pw@8.8.8.8", NONE).reasonCode,
|
||||
).toBe("invalid_origin");
|
||||
expect(
|
||||
evaluateDawarichUrl("https://8.8.8.8/?api_key=x", NONE).reasonCode,
|
||||
).toBe("invalid_origin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuredDawarichPrivateOrigins", () => {
|
||||
it("reads the operator list", () => {
|
||||
process.env.DAWARICH_PRIVATE_ORIGINS =
|
||||
"http://dawarich.lan:3000, http://10.0.0.5";
|
||||
expect([...configuredDawarichPrivateOrigins()]).toEqual([
|
||||
"http://dawarich.lan:3000",
|
||||
"http://10.0.0.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a malformed entry instead of granting part of it", () => {
|
||||
process.env.DAWARICH_PRIVATE_ORIGINS = "http://*.lan";
|
||||
expect(() => configuredDawarichPrivateOrigins()).toThrow(
|
||||
DawarichOriginConfigError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { travelRuns } from "../travel";
|
||||
import { distanceInUnit } from "../movement";
|
||||
|
||||
describe("travelRuns", () => {
|
||||
it("groups consecutive days in one country and splits on a gap or a border", () => {
|
||||
expect(
|
||||
travelRuns([
|
||||
{ date: "2026-06-16", countryCode: "ES" },
|
||||
{ date: "2026-06-15", countryCode: "ES" },
|
||||
{ date: "2026-06-17", countryCode: "FR" },
|
||||
{ date: "2026-06-20", countryCode: "FR" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: "2026-06-15_2026-06-16_ES",
|
||||
startDate: "2026-06-15",
|
||||
endDate: "2026-06-16",
|
||||
countryCode: "ES",
|
||||
},
|
||||
{
|
||||
id: "2026-06-17_2026-06-17_FR",
|
||||
startDate: "2026-06-17",
|
||||
endDate: "2026-06-17",
|
||||
countryCode: "FR",
|
||||
},
|
||||
{
|
||||
id: "2026-06-20_2026-06-20_FR",
|
||||
startDate: "2026-06-20",
|
||||
endDate: "2026-06-20",
|
||||
countryCode: "FR",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distanceInUnit", () => {
|
||||
it("answers one decimal in the account's unit", () => {
|
||||
expect(distanceInUnit(48_300, "km")).toBe(48.3);
|
||||
expect(distanceInUnit(16_093, "mi")).toBe(10);
|
||||
expect(distanceInUnit(null, "km")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Dawarich API client: the wire shapes HealthLog reads and nothing more.
|
||||
*
|
||||
* Dawarich (https://dawarich.app) is a self-hosted location history. HealthLog
|
||||
* reads four endpoints, all with `Authorization: Bearer <api key>`:
|
||||
*
|
||||
* GET /api/v1/timeline?start_at&end_at&distance_unit=km
|
||||
* one entry per local day (the instance's own zone) with a `summary`:
|
||||
* `total_distance`, `places_visited`, `mode_distances.{driving,train}`
|
||||
* in km to one decimal. At most 31 days per request.
|
||||
* GET /api/v1/visits?start_at&end_at
|
||||
* visits that STARTED in the window: `started_at`, `ended_at`,
|
||||
* `status`, `place.id`. The coordinates and the name in the payload
|
||||
* are read past and dropped here; nothing but the place id survives.
|
||||
* GET /api/v1/residency?year=YYYY
|
||||
* `daily_countries` (UTC date to country name) and `countries[]`
|
||||
* (country name to ISO 3166-1 alpha-2). Answers 403 on a hosted plan
|
||||
* without API access; then there is no travel signal.
|
||||
* GET /api/v1/places, /api/v1/places/:id
|
||||
* to pick and check the home place; id and name only, never stored.
|
||||
* GET /api/v1/users/me
|
||||
* the cheapest authenticated call, used to check a key.
|
||||
*
|
||||
* Every call goes through `safeFetch`: `requirePublicHost` for a public
|
||||
* instance, the operator-approved pinned dispatcher for an origin listed in
|
||||
* `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking
|
||||
* an origin off the list stops the next request.
|
||||
*/
|
||||
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
import {
|
||||
configuredDawarichPrivateOrigins,
|
||||
evaluateDawarichUrl,
|
||||
} from "./origin";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Dawarich refuses a timeline window longer than 31 days. */
|
||||
export const TIMELINE_MAX_DAYS = 31;
|
||||
|
||||
/** The places list is read once per request and capped before filtering. */
|
||||
const PLACES_PAGE_SIZE = 500;
|
||||
|
||||
/** At most this many places go back to the picker. */
|
||||
const PLACES_RESULT_LIMIT = 200;
|
||||
|
||||
export interface DawarichConnection {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** The instance answered with a status outside 2xx, or not with JSON. */
|
||||
export class DawarichApiError extends Error {
|
||||
readonly status: number | null;
|
||||
constructor(message: string, status: number | null) {
|
||||
super(message);
|
||||
this.name = "DawarichApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** The address is private and not on the operator's list, or not usable at all. */
|
||||
export class DawarichPolicyError extends Error {
|
||||
readonly reasonCode: string;
|
||||
constructor(reasonCode: string) {
|
||||
super(reasonCode);
|
||||
this.name = "DawarichPolicyError";
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
async function dawarichGet(
|
||||
conn: DawarichConnection,
|
||||
path: string,
|
||||
query: Record<string, string> = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<unknown> {
|
||||
const target = evaluateDawarichUrl(
|
||||
conn.baseUrl,
|
||||
configuredDawarichPrivateOrigins(),
|
||||
);
|
||||
if (!target.allowed || !target.baseUrl || !target.origin) {
|
||||
throw new DawarichPolicyError(
|
||||
target.reasonCode ?? "private_origin_not_approved",
|
||||
);
|
||||
}
|
||||
const url = new URL(`${target.baseUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await safeFetch(
|
||||
url.toString(),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${conn.apiKey}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
requirePublicHost: !target.privateOriginApproved,
|
||||
...(target.privateOriginApproved
|
||||
? { operatorApprovedPrivateOrigin: target.origin }
|
||||
: {}),
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeFetchError && error.kind === "private_host") {
|
||||
throw new DawarichPolicyError("private_origin_not_approved");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new DawarichApiError("Dawarich returned a non-JSON body", null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────────────
|
||||
|
||||
/** One day's summary, distances in km as Dawarich reports them. */
|
||||
export interface TimelineDaySummary {
|
||||
date: string;
|
||||
totalKm: number | null;
|
||||
drivingKm: number | null;
|
||||
trainKm: number | null;
|
||||
placesVisited: number | null;
|
||||
}
|
||||
|
||||
function finiteOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the day summaries out of a timeline answer. A mode absent from
|
||||
* `mode_distances` is zero for that day (Dawarich leaves out a mode that moved
|
||||
* less than half a kilometre), so it reads as 0 whenever the summary itself is
|
||||
* there. Everything else in the payload (entries, bounds, names) is dropped.
|
||||
*/
|
||||
export function parseTimelineDays(payload: unknown): TimelineDaySummary[] {
|
||||
const days = (payload as { days?: unknown } | null)?.days;
|
||||
if (!Array.isArray(days)) return [];
|
||||
const out: TimelineDaySummary[] = [];
|
||||
for (const raw of days) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const day = raw as { date?: unknown; summary?: unknown };
|
||||
if (typeof day.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(day.date)) {
|
||||
continue;
|
||||
}
|
||||
const summary =
|
||||
day.summary && typeof day.summary === "object"
|
||||
? (day.summary as Record<string, unknown>)
|
||||
: null;
|
||||
if (!summary) continue;
|
||||
const modes =
|
||||
summary.mode_distances && typeof summary.mode_distances === "object"
|
||||
? (summary.mode_distances as Record<string, unknown>)
|
||||
: {};
|
||||
out.push({
|
||||
date: day.date,
|
||||
totalKm: finiteOrNull(summary.total_distance),
|
||||
drivingKm: finiteOrNull(modes.driving) ?? 0,
|
||||
trainKm: finiteOrNull(modes.train) ?? 0,
|
||||
placesVisited:
|
||||
typeof summary.places_visited === "number" &&
|
||||
Number.isInteger(summary.places_visited) &&
|
||||
summary.places_visited >= 0
|
||||
? summary.places_visited
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
conn: DawarichConnection,
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
): Promise<TimelineDaySummary[]> {
|
||||
return parseTimelineDays(
|
||||
await dawarichGet(conn, "/api/v1/timeline", {
|
||||
start_at: startAt.toISOString(),
|
||||
end_at: endAt.toISOString(),
|
||||
distance_unit: "km",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Visits ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A visit reduced to what time away from home needs. */
|
||||
export interface VisitSpan {
|
||||
placeId: number | null;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the visits. A declined visit (the person said it did not happen) is
|
||||
* dropped; so is one without a usable time span. Coordinates and names in the
|
||||
* payload are never copied out.
|
||||
*/
|
||||
export function parseVisits(payload: unknown): VisitSpan[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const out: VisitSpan[] = [];
|
||||
for (const raw of payload) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const visit = raw as Record<string, unknown>;
|
||||
if (visit.status === "declined") continue;
|
||||
const startMs =
|
||||
typeof visit.started_at === "string" ? Date.parse(visit.started_at) : NaN;
|
||||
const endMs =
|
||||
typeof visit.ended_at === "string" ? Date.parse(visit.ended_at) : NaN;
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
||||
if (endMs <= startMs) continue;
|
||||
const place =
|
||||
visit.place && typeof visit.place === "object"
|
||||
? (visit.place as Record<string, unknown>)
|
||||
: null;
|
||||
const placeId =
|
||||
typeof place?.id === "number" && Number.isInteger(place.id)
|
||||
? place.id
|
||||
: null;
|
||||
out.push({ placeId, startMs, endMs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchVisits(
|
||||
conn: DawarichConnection,
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
): Promise<VisitSpan[]> {
|
||||
return parseVisits(
|
||||
await dawarichGet(conn, "/api/v1/visits", {
|
||||
start_at: startAt.toISOString(),
|
||||
end_at: endAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Residency ────────────────────────────────────────────────────────
|
||||
|
||||
/** UTC date to ISO 3166-1 alpha-2, for the days the year holds a country. */
|
||||
export type DailyCountries = Map<string, string>;
|
||||
|
||||
/**
|
||||
* Read `daily_countries` and translate each country name through the
|
||||
* `countries[]` table into its alpha-2 code. A name without a code is dropped:
|
||||
* a country HealthLog cannot name is no basis for a travel day.
|
||||
*/
|
||||
export function parseResidency(payload: unknown): DailyCountries {
|
||||
const out: DailyCountries = new Map();
|
||||
if (!payload || typeof payload !== "object") return out;
|
||||
const body = payload as Record<string, unknown>;
|
||||
const codeByName = new Map<string, string>();
|
||||
if (Array.isArray(body.countries)) {
|
||||
for (const raw of body.countries) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const country = raw as Record<string, unknown>;
|
||||
if (
|
||||
typeof country.country_name === "string" &&
|
||||
typeof country.iso_a2 === "string" &&
|
||||
/^[A-Za-z]{2}$/.test(country.iso_a2)
|
||||
) {
|
||||
codeByName.set(country.country_name, country.iso_a2.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
const daily =
|
||||
body.daily_countries && typeof body.daily_countries === "object"
|
||||
? (body.daily_countries as Record<string, unknown>)
|
||||
: {};
|
||||
for (const [date, name] of Object.entries(daily)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || typeof name !== "string") continue;
|
||||
const code = codeByName.get(name);
|
||||
if (code) out.set(date, code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One year of residency, or null when the instance does not offer it (a
|
||||
* hosted plan without API access answers 403, an older instance 404). Any
|
||||
* other failure is thrown like every other call.
|
||||
*/
|
||||
export async function fetchResidency(
|
||||
conn: DawarichConnection,
|
||||
year: number,
|
||||
): Promise<DailyCountries | null> {
|
||||
try {
|
||||
return parseResidency(
|
||||
await dawarichGet(conn, "/api/v1/residency", { year: String(year) }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DawarichApiError &&
|
||||
(error.status === 403 || error.status === 404)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Places ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DawarichPlace {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Id and name of each place, filtered by `q`, sorted by name, capped. */
|
||||
export function parsePlaces(payload: unknown, q?: string): DawarichPlace[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const needle = q?.trim().toLocaleLowerCase() ?? "";
|
||||
const out: DawarichPlace[] = [];
|
||||
for (const raw of payload) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const place = raw as Record<string, unknown>;
|
||||
if (typeof place.id !== "number" || !Number.isInteger(place.id)) continue;
|
||||
const name = typeof place.name === "string" ? place.name.trim() : "";
|
||||
if (!name) continue;
|
||||
if (needle && !name.toLocaleLowerCase().includes(needle)) continue;
|
||||
out.push({ id: place.id, name: name.slice(0, 200) });
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
|
||||
return out.slice(0, PLACES_RESULT_LIMIT);
|
||||
}
|
||||
|
||||
export async function fetchPlaces(
|
||||
conn: DawarichConnection,
|
||||
q?: string,
|
||||
): Promise<DawarichPlace[]> {
|
||||
return parsePlaces(
|
||||
await dawarichGet(conn, "/api/v1/places", {
|
||||
filter: "all",
|
||||
page: "1",
|
||||
per_page: String(PLACES_PAGE_SIZE),
|
||||
}),
|
||||
q,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the instance knows a place with this id. */
|
||||
export async function placeExists(
|
||||
conn: DawarichConnection,
|
||||
id: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await dawarichGet(conn, `/api/v1/places/${id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof DawarichApiError && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the key with the cheapest authenticated call. */
|
||||
export async function probeDawarich(
|
||||
conn: DawarichConnection,
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
await dawarichGet(conn, "/api/v1/users/me", {}, timeoutMs);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* The stored Dawarich connection: the sealed URL and key on the user row, the
|
||||
* sealed preferences beside them, and each day's sealed country code.
|
||||
*
|
||||
* Preferences are one sealed JSON value (AAD `healthlog/dawarich-prefs/v1`):
|
||||
*
|
||||
* homePlaceId a place id on the instance, the anchor of time away
|
||||
* from home. An id, never the place itself.
|
||||
* homeCountry ISO 3166-1 alpha-2 the person chose, or null.
|
||||
* derivedHomeCountry the most frequent residency country of the last
|
||||
* 365 days, refreshed by every sync; used while
|
||||
* `homeCountry` is null.
|
||||
* dismissedSuggestionIds travel suggestions the person turned down.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, decryptBytes, encryptBytes } from "@/lib/crypto";
|
||||
import {
|
||||
DAWARICH_DAY_COUNTRY_AAD,
|
||||
DAWARICH_PREFS_AAD,
|
||||
} from "@/lib/crypto/encrypted-columns";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
import type { DawarichConnection } from "./client";
|
||||
|
||||
export interface DawarichPrefs {
|
||||
homePlaceId: number | null;
|
||||
homeCountry: string | null;
|
||||
derivedHomeCountry: string | null;
|
||||
dismissedSuggestionIds: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_DAWARICH_PREFS: DawarichPrefs = {
|
||||
homePlaceId: null,
|
||||
homeCountry: null,
|
||||
derivedHomeCountry: null,
|
||||
dismissedSuggestionIds: [],
|
||||
};
|
||||
|
||||
/** At most this many dismissed suggestions are remembered, newest kept. */
|
||||
const DISMISSED_LIMIT = 200;
|
||||
|
||||
const COUNTRY = /^[A-Z]{2}$/;
|
||||
|
||||
function toBytes(sealed: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(new ArrayBuffer(sealed.byteLength));
|
||||
out.set(sealed);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sealDawarichPrefs(
|
||||
prefs: DawarichPrefs,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
return toBytes(
|
||||
encryptBytes(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
homePlaceId: prefs.homePlaceId,
|
||||
homeCountry: prefs.homeCountry,
|
||||
derivedHomeCountry: prefs.derivedHomeCountry,
|
||||
dismissedSuggestionIds:
|
||||
prefs.dismissedSuggestionIds.slice(-DISMISSED_LIMIT),
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
DAWARICH_PREFS_AAD,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sealed preferences. A value that does not open, or holds an
|
||||
* unexpected shape, reads as empty preferences with a warning on the event:
|
||||
* losing the home place is recoverable from the settings card, failing every
|
||||
* read of the connection is not.
|
||||
*/
|
||||
export function openDawarichPrefs(
|
||||
sealed: Uint8Array | null | undefined,
|
||||
): DawarichPrefs {
|
||||
if (!sealed) return { ...EMPTY_DAWARICH_PREFS };
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
decryptBytes(Buffer.from(sealed), DAWARICH_PREFS_AAD).toString("utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const country = (value: unknown) =>
|
||||
typeof value === "string" && COUNTRY.test(value) ? value : null;
|
||||
return {
|
||||
homePlaceId:
|
||||
typeof raw.homePlaceId === "number" &&
|
||||
Number.isInteger(raw.homePlaceId) &&
|
||||
raw.homePlaceId > 0
|
||||
? raw.homePlaceId
|
||||
: null,
|
||||
homeCountry: country(raw.homeCountry),
|
||||
derivedHomeCountry: country(raw.derivedHomeCountry),
|
||||
dismissedSuggestionIds: Array.isArray(raw.dismissedSuggestionIds)
|
||||
? raw.dismissedSuggestionIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length <= 64,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`dawarich prefs did not open: ${error instanceof Error ? error.name : "unknown"}`,
|
||||
);
|
||||
return { ...EMPTY_DAWARICH_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
/** The home country in force: the chosen one, else the derived one. */
|
||||
export function effectiveHomeCountry(prefs: DawarichPrefs): string | null {
|
||||
return prefs.homeCountry ?? prefs.derivedHomeCountry;
|
||||
}
|
||||
|
||||
export function sealDayCountry(code: string): Uint8Array<ArrayBuffer> {
|
||||
return toBytes(
|
||||
encryptBytes(Buffer.from(code, "utf8"), DAWARICH_DAY_COUNTRY_AAD),
|
||||
);
|
||||
}
|
||||
|
||||
/** A day's country code, or null when the value does not open. */
|
||||
export function openDayCountry(sealed: Uint8Array | null): string | null {
|
||||
if (!sealed) return null;
|
||||
try {
|
||||
const code = decryptBytes(
|
||||
Buffer.from(sealed),
|
||||
DAWARICH_DAY_COUNTRY_AAD,
|
||||
).toString("utf8");
|
||||
return COUNTRY.test(code) ? code : null;
|
||||
} catch {
|
||||
getEvent()?.addWarning("dawarich day country did not open");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredDawarich {
|
||||
connection: DawarichConnection;
|
||||
prefs: DawarichPrefs;
|
||||
}
|
||||
|
||||
/** The account's connection, or null when none is stored. */
|
||||
export async function loadDawarichConnection(
|
||||
userId: string,
|
||||
): Promise<StoredDawarich | null> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dawarichUrlEncrypted: true,
|
||||
dawarichApiKeyEncrypted: true,
|
||||
dawarichPrefsEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!row?.dawarichUrlEncrypted || !row.dawarichApiKeyEncrypted) return null;
|
||||
return {
|
||||
connection: {
|
||||
baseUrl: decrypt(row.dawarichUrlEncrypted),
|
||||
apiKey: decrypt(row.dawarichApiKeyEncrypted),
|
||||
},
|
||||
prefs: openDawarichPrefs(row.dawarichPrefsEncrypted),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the sealed preferences. */
|
||||
export async function saveDawarichPrefs(
|
||||
userId: string,
|
||||
prefs: DawarichPrefs,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { dawarichPrefsEncrypted: sealDawarichPrefs(prefs) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Fold what Dawarich answered into one row of daily values per local day.
|
||||
*
|
||||
* Pure: the sync fetches, this decides. Nothing here sees a coordinate or a
|
||||
* name; the client has already reduced visits to a place id and a span, and
|
||||
* the residency to a country code per date.
|
||||
*
|
||||
* distances from the timeline summary, km to whole metres.
|
||||
* places `places_visited` of the summary.
|
||||
* away from home the day's minutes minus the minutes a visit at the home
|
||||
* place covers. Without a home place, or without a single
|
||||
* visit touching the day, there is no coverage and the value
|
||||
* is null, never zero.
|
||||
* country the residency country of the date. Dawarich keys residency
|
||||
* by UTC date, so on a day that crosses a border late in the
|
||||
* evening the country may be the next morning's; the travel
|
||||
* run is days long, one edge day does not change it.
|
||||
* travel day the country differs from the home country; null without
|
||||
* either.
|
||||
*/
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
|
||||
import type { DailyCountries, TimelineDaySummary, VisitSpan } from "./client";
|
||||
|
||||
export interface DawarichDayValues {
|
||||
date: string;
|
||||
totalDistanceM: number | null;
|
||||
drivingDistanceM: number | null;
|
||||
trainDistanceM: number | null;
|
||||
placesVisited: number | null;
|
||||
awayFromHomeMinutes: number | null;
|
||||
countryCode: string | null;
|
||||
isTravelDay: boolean | null;
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
function kmToMetres(km: number | null): number | null {
|
||||
return km === null ? null : Math.round(km * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes of `[dayStart, dayEnd)` not covered by a visit at `homePlaceId`.
|
||||
* Null when no home place is set or no visit at all touches the day.
|
||||
*/
|
||||
export function awayFromHomeMinutes(
|
||||
visits: readonly VisitSpan[],
|
||||
dayStart: Date,
|
||||
dayEnd: Date,
|
||||
homePlaceId: number | null,
|
||||
): number | null {
|
||||
if (homePlaceId === null) return null;
|
||||
const start = dayStart.getTime();
|
||||
const end = dayEnd.getTime();
|
||||
const touching = visits.filter((v) => v.startMs < end && v.endMs > start);
|
||||
if (touching.length === 0) return null;
|
||||
const home = touching
|
||||
.filter((v) => v.placeId === homePlaceId)
|
||||
.map((v) => [Math.max(v.startMs, start), Math.min(v.endMs, end)] as const)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
// Merge overlapping home spans so a visit Dawarich split in two is not
|
||||
// counted twice.
|
||||
let covered = 0;
|
||||
let runStart: number | null = null;
|
||||
let runEnd = 0;
|
||||
for (const [s, e] of home) {
|
||||
if (runStart === null || s > runEnd) {
|
||||
if (runStart !== null) covered += runEnd - runStart;
|
||||
runStart = s;
|
||||
runEnd = e;
|
||||
} else if (e > runEnd) {
|
||||
runEnd = e;
|
||||
}
|
||||
}
|
||||
if (runStart !== null) covered += runEnd - runStart;
|
||||
return Math.max(0, Math.round((end - start - covered) / MINUTE_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* The most frequent country over the given dates, or null when none has one.
|
||||
* Ties go to the alphabetically first code, so the answer does not depend on
|
||||
* the order Dawarich listed the days in.
|
||||
*/
|
||||
export function mostFrequentCountry(
|
||||
countries: DailyCountries,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
): string | null {
|
||||
const counts = new Map<string, number>();
|
||||
for (const [date, code] of countries) {
|
||||
if (date < fromDate || date > toDate) continue;
|
||||
counts.set(code, (counts.get(code) ?? 0) + 1);
|
||||
}
|
||||
let best: string | null = null;
|
||||
let bestCount = 0;
|
||||
for (const [code, count] of [...counts].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (count > bestCount) {
|
||||
best = code;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per day that Dawarich holds anything for. A day with no summary, no
|
||||
* visit and no country is left out: there is nothing to say about it, and an
|
||||
* empty row would read as "measured, nothing happened".
|
||||
*/
|
||||
export function buildDayValues(input: {
|
||||
days: readonly string[];
|
||||
tz: string;
|
||||
timeline: readonly TimelineDaySummary[];
|
||||
visits: readonly VisitSpan[];
|
||||
countries: DailyCountries | null;
|
||||
homePlaceId: number | null;
|
||||
homeCountry: string | null;
|
||||
}): DawarichDayValues[] {
|
||||
const summaryByDate = new Map(input.timeline.map((d) => [d.date, d]));
|
||||
const out: DawarichDayValues[] = [];
|
||||
for (const date of input.days) {
|
||||
const summary = summaryByDate.get(date) ?? null;
|
||||
const { dayStart, dayEnd } = localDayWindow(date, input.tz);
|
||||
const away = awayFromHomeMinutes(
|
||||
input.visits,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
input.homePlaceId,
|
||||
);
|
||||
const touched = input.visits.some(
|
||||
(v) => v.startMs < dayEnd.getTime() && v.endMs > dayStart.getTime(),
|
||||
);
|
||||
const countryCode = input.countries?.get(date) ?? null;
|
||||
if (!summary && !touched && !countryCode) continue;
|
||||
out.push({
|
||||
date,
|
||||
totalDistanceM: kmToMetres(summary?.totalKm ?? null),
|
||||
drivingDistanceM: kmToMetres(summary?.drivingKm ?? null),
|
||||
trainDistanceM: kmToMetres(summary?.trainKm ?? null),
|
||||
placesVisited: summary?.placesVisited ?? null,
|
||||
awayFromHomeMinutes: away,
|
||||
countryCode,
|
||||
isTravelDay: travelDay(countryCode, input.homeCountry),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A travel day needs both a country and a home country to compare with. */
|
||||
export function travelDay(
|
||||
countryCode: string | null,
|
||||
homeCountry: string | null,
|
||||
): boolean | null {
|
||||
if (!countryCode || !homeCountry) return null;
|
||||
return countryCode !== homeCountry;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The day's movement block (`DayMovement`), resolved for every reader of a
|
||||
* day: the web view, the native client, and through the model projection the
|
||||
* Coach and the MCP endpoint.
|
||||
*
|
||||
* Distances leave in the account's unit, to one decimal; a client renders
|
||||
* them as they come. A null field means "no coverage that day", never zero.
|
||||
*/
|
||||
import type { DayResponse } from "@/lib/day/contract";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
import { openDayCountry } from "./connection";
|
||||
|
||||
type DayMovement = NonNullable<DayResponse["movement"]>;
|
||||
|
||||
const METRES_PER_MILE = 1609.344;
|
||||
|
||||
/** Metres in the account's unit, to one decimal. */
|
||||
export function distanceInUnit(
|
||||
metres: number | null,
|
||||
unit: "km" | "mi",
|
||||
): number | null {
|
||||
if (metres === null) return null;
|
||||
const value = unit === "mi" ? metres / METRES_PER_MILE : metres / 1000;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* The movement block for one day, `undefined` when the record has no Dawarich
|
||||
* connection (the field is then left off), null when it has one but no values
|
||||
* for the day.
|
||||
*/
|
||||
export async function readDayMovement(
|
||||
userId: string,
|
||||
day: string,
|
||||
): Promise<DayMovement | null | undefined> {
|
||||
const [user, row] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dawarichUrlEncrypted: true, unitPreference: true },
|
||||
}),
|
||||
prisma.dawarichDay.findUnique({
|
||||
where: { userId_date: { userId, date: day } },
|
||||
select: {
|
||||
totalDistanceM: true,
|
||||
drivingDistanceM: true,
|
||||
trainDistanceM: true,
|
||||
placesVisited: true,
|
||||
awayFromHomeMinutes: true,
|
||||
countryCodeEncrypted: true,
|
||||
isTravelDay: true,
|
||||
fetchedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (!user?.dawarichUrlEncrypted) return undefined;
|
||||
if (!row) return null;
|
||||
const unit = user.unitPreference === "imperial" ? "mi" : "km";
|
||||
return {
|
||||
source: "dawarich",
|
||||
distance: {
|
||||
unit,
|
||||
total: distanceInUnit(row.totalDistanceM, unit),
|
||||
driving: distanceInUnit(row.drivingDistanceM, unit),
|
||||
train: distanceInUnit(row.trainDistanceM, unit),
|
||||
},
|
||||
placesVisited: row.placesVisited,
|
||||
awayFromHomeMinutes: row.awayFromHomeMinutes,
|
||||
travel:
|
||||
row.isTravelDay === null
|
||||
? null
|
||||
: {
|
||||
isTravelDay: row.isTravelDay,
|
||||
countryCode: openDayCountry(row.countryCodeEncrypted),
|
||||
},
|
||||
fetchedAt: row.fetchedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Which Dawarich instances HealthLog may call.
|
||||
*
|
||||
* Dawarich is self-hosted, and most instances live on the operator's own
|
||||
* network. The rule is the Nightscout one: a public host is reachable without
|
||||
* configuration, a private one only when its exact origin is listed in
|
||||
* `DAWARICH_PRIVATE_ORIGINS` (the shared grant grammar in
|
||||
* `@/lib/private-origin-policy`: one `scheme://host[:port]` per entry, comma
|
||||
* separated, never a suffix or a range). The request body can never widen it.
|
||||
*
|
||||
* Unlike a Nightscout address, a Dawarich base URL may carry a path (an
|
||||
* instance served under a sub-path behind a reverse proxy). The grant is
|
||||
* matched on the origin; the path is kept as the base the API paths hang off.
|
||||
*/
|
||||
import {
|
||||
evaluateOriginGrant,
|
||||
INVALID_ORIGIN,
|
||||
originOfUrl,
|
||||
parsePrivateOrigins,
|
||||
type OriginReason,
|
||||
} from "@/lib/private-origin-policy";
|
||||
|
||||
/**
|
||||
* `DAWARICH_PRIVATE_ORIGINS` holds an entry that is not a grant. The message
|
||||
* names the entry (scheme and host only) and why, so the connect form shows
|
||||
* the operator what to edit.
|
||||
*/
|
||||
export class DawarichOriginConfigError extends Error {
|
||||
constructor(redactedEntry: string, reason: string) {
|
||||
super(
|
||||
`Invalid DAWARICH_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`,
|
||||
);
|
||||
this.name = "DawarichOriginConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator's exact-origin grants; throws on a malformed entry. */
|
||||
export function configuredDawarichPrivateOrigins(): ReadonlySet<string> {
|
||||
return parsePrivateOrigins(
|
||||
process.env.DAWARICH_PRIVATE_ORIGINS,
|
||||
(redactedEntry, reason) => {
|
||||
throw new DawarichOriginConfigError(redactedEntry, reason);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface DawarichTarget {
|
||||
allowed: boolean;
|
||||
/** The base URL without a trailing slash, null when the input is not a usable URL. */
|
||||
baseUrl: string | null;
|
||||
/** The canonical origin of `baseUrl`; the value the private grant is matched on. */
|
||||
origin: string | null;
|
||||
privateOriginApproved: boolean;
|
||||
reasonCode: OriginReason | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied Dawarich base URL against the operator's grants.
|
||||
* Credentials, a query and a fragment make the URL unusable (`invalid_origin`):
|
||||
* the API key travels in a header, never in the address.
|
||||
*/
|
||||
export function evaluateDawarichUrl(
|
||||
value: string,
|
||||
privateOrigins: ReadonlySet<string>,
|
||||
): DawarichTarget {
|
||||
const refused = (reasonCode: OriginReason): DawarichTarget => ({
|
||||
allowed: false,
|
||||
baseUrl: null,
|
||||
origin: null,
|
||||
privateOriginApproved: false,
|
||||
reasonCode,
|
||||
});
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return refused(INVALID_ORIGIN);
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
return refused(INVALID_ORIGIN);
|
||||
}
|
||||
const origin = originOfUrl(value);
|
||||
if (!origin) return refused(INVALID_ORIGIN);
|
||||
const verdict = evaluateOriginGrant(origin, privateOrigins, value);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
return {
|
||||
allowed: verdict.allowed,
|
||||
baseUrl: verdict.allowed ? `${origin}${path}` : null,
|
||||
origin,
|
||||
privateOriginApproved: verdict.privateOriginApproved,
|
||||
reasonCode: verdict.reasonCode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* `DawarichStatus`, resolved once for every reader (the status route and the
|
||||
* settings card through it).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
|
||||
import { effectiveHomeCountry, openDawarichPrefs } from "./connection";
|
||||
|
||||
export interface DawarichStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
homePlaceSet: boolean;
|
||||
homeCountry: string | null;
|
||||
daysStored: number;
|
||||
oldestDay: string | null;
|
||||
latestDay: string | null;
|
||||
health: ConnectionHealth | null;
|
||||
}
|
||||
|
||||
export async function loadDawarichStatus(
|
||||
userId: string,
|
||||
): Promise<DawarichStatus> {
|
||||
const [user, span, daysStored] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dawarichUrlEncrypted: true,
|
||||
dawarichApiKeyEncrypted: true,
|
||||
dawarichPrefsEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.dawarichDay.aggregate({
|
||||
where: { userId },
|
||||
_min: { date: true },
|
||||
_max: { date: true },
|
||||
}),
|
||||
prisma.dawarichDay.count({ where: { userId } }),
|
||||
]);
|
||||
const configured = !!user?.dawarichUrlEncrypted;
|
||||
const connected = configured && !!user?.dawarichApiKeyEncrypted;
|
||||
const prefs = openDawarichPrefs(user?.dawarichPrefsEncrypted);
|
||||
return {
|
||||
connected,
|
||||
configured,
|
||||
homePlaceSet: prefs.homePlaceId !== null,
|
||||
homeCountry: effectiveHomeCountry(prefs),
|
||||
daysStored,
|
||||
oldestDay: span._min.date ?? null,
|
||||
latestDay: span._max.date ?? null,
|
||||
health: connected
|
||||
? await loadOneConnectionHealth(userId, "dawarich")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Fetch a range of days from Dawarich and store their daily values.
|
||||
*
|
||||
* Per run: one timeline request per 30 days, one visits request for the whole
|
||||
* range (reaching a week back, so a stay at home that began before the range
|
||||
* still counts), and one residency request per calendar year the range or the
|
||||
* home-country window touches. Each stored day is an upsert on
|
||||
* `(user_id, date)`, so a run over days already stored refreshes them.
|
||||
*
|
||||
* The outcome lands on the shared integration ledger (`dawarich`): a clean run
|
||||
* records a success, a failed request a classified failure. The connection
|
||||
* health and the watchdog read that ledger like every other connection.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
type FailureKind,
|
||||
} from "@/lib/integrations/status";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
DawarichApiError,
|
||||
DawarichPolicyError,
|
||||
fetchResidency,
|
||||
fetchTimeline,
|
||||
fetchVisits,
|
||||
TIMELINE_MAX_DAYS,
|
||||
type DailyCountries,
|
||||
type TimelineDaySummary,
|
||||
} from "./client";
|
||||
import {
|
||||
effectiveHomeCountry,
|
||||
loadDawarichConnection,
|
||||
saveDawarichPrefs,
|
||||
sealDayCountry,
|
||||
} from "./connection";
|
||||
import { buildDayValues, mostFrequentCountry } from "./day-values";
|
||||
|
||||
/** The window the home country is derived from. */
|
||||
const HOME_COUNTRY_WINDOW_DAYS = 365;
|
||||
|
||||
/** How far before the range the visits request reaches. */
|
||||
const VISIT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Days per timeline request, inside Dawarich's 31-day limit. */
|
||||
const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 1;
|
||||
|
||||
export interface DawarichSyncResult {
|
||||
/** Days written this run (new or refreshed). */
|
||||
daysStored: number;
|
||||
/** Nothing ran: there is no stored connection. */
|
||||
notConnected: boolean;
|
||||
}
|
||||
|
||||
/** Map a failed request onto the ledger's failure kind. */
|
||||
export function classifyDawarichFailure(error: unknown): FailureKind {
|
||||
if (error instanceof DawarichPolicyError) return "persistent";
|
||||
if (error instanceof DawarichApiError && error.status !== null) {
|
||||
if (error.status === 401 || error.status === 403) return "reauth_required";
|
||||
if (error.status >= 400 && error.status < 500 && error.status !== 429) {
|
||||
return "persistent";
|
||||
}
|
||||
}
|
||||
// 5xx, 429, timeouts and unreachable hosts: try again next run.
|
||||
return "transient";
|
||||
}
|
||||
|
||||
/** The short code a failure is recorded with; never a message or a URL. */
|
||||
export function dawarichFailureCode(error: unknown): string {
|
||||
if (error instanceof DawarichPolicyError) return error.reasonCode;
|
||||
if (error instanceof DawarichApiError) {
|
||||
return error.status === null ? "invalid_body" : String(error.status);
|
||||
}
|
||||
return "unreachable";
|
||||
}
|
||||
|
||||
async function recordFailure(userId: string, error: unknown): Promise<void> {
|
||||
const code = dawarichFailureCode(error);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "dawarich",
|
||||
kind: classifyDawarichFailure(error),
|
||||
// The code alone: an upstream message can carry the instance address, and
|
||||
// the address is the person's own network.
|
||||
message: `dawarich ${code}`,
|
||||
errorCode: code,
|
||||
});
|
||||
}
|
||||
|
||||
function daysOf(fromDate: string, toDate: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let d = fromDate; d <= toDate; d = shiftDateKey(d, 1)) out.push(d);
|
||||
return out;
|
||||
}
|
||||
|
||||
function yearsOf(...ranges: Array<[string, string]>): number[] {
|
||||
const years = new Set<number>();
|
||||
for (const [from, to] of ranges) {
|
||||
for (let y = Number(from.slice(0, 4)); y <= Number(to.slice(0, 4)); y++) {
|
||||
years.add(y);
|
||||
}
|
||||
}
|
||||
return [...years].sort();
|
||||
}
|
||||
|
||||
/** Yesterday in the account's zone: the newest day a run fetches. */
|
||||
export function yesterdayKey(tz: string, now: Date = new Date()): string {
|
||||
return shiftDateKey(userDayKey(now, tz), -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and store `[fromDate, toDate]` (local day keys, inclusive). Throws on
|
||||
* a failed request after recording it on the ledger (marked, so a caller does
|
||||
* not record it twice).
|
||||
*/
|
||||
export async function syncDawarichRange(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<DawarichSyncResult> {
|
||||
const stored = await loadDawarichConnection(userId);
|
||||
if (!stored) return { daysStored: 0, notConnected: true };
|
||||
if (fromDate > toDate) return { daysStored: 0, notConnected: false };
|
||||
|
||||
const { connection, prefs } = stored;
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const days = daysOf(fromDate, toDate);
|
||||
const today = userDayKey(now, tz);
|
||||
const homeWindowFrom = shiftDateKey(today, -HOME_COUNTRY_WINDOW_DAYS);
|
||||
|
||||
let timeline: TimelineDaySummary[] = [];
|
||||
let visits: Awaited<ReturnType<typeof fetchVisits>> = [];
|
||||
let countries: DailyCountries | null = null;
|
||||
try {
|
||||
for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) {
|
||||
const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS);
|
||||
const { dayStart } = localDayWindow(chunk[0], tz);
|
||||
const { dayEnd } = localDayWindow(chunk[chunk.length - 1], tz);
|
||||
timeline = timeline.concat(
|
||||
await fetchTimeline(
|
||||
connection,
|
||||
dayStart,
|
||||
new Date(dayEnd.getTime() - 1000),
|
||||
),
|
||||
);
|
||||
}
|
||||
const rangeStart = localDayWindow(fromDate, tz).dayStart;
|
||||
const rangeEnd = localDayWindow(toDate, tz).dayEnd;
|
||||
visits = await fetchVisits(
|
||||
connection,
|
||||
new Date(rangeStart.getTime() - VISIT_LOOKBACK_MS),
|
||||
rangeEnd,
|
||||
);
|
||||
const years = yearsOf(
|
||||
[fromDate, toDate],
|
||||
...(prefs.homeCountry === null
|
||||
? [[homeWindowFrom, today] as [string, string]]
|
||||
: []),
|
||||
);
|
||||
for (const year of years) {
|
||||
const yearCountries = await fetchResidency(connection, year);
|
||||
if (!yearCountries) continue;
|
||||
countries ??= new Map();
|
||||
for (const [date, code] of yearCountries) countries.set(date, code);
|
||||
}
|
||||
} catch (error) {
|
||||
await recordFailure(userId, error);
|
||||
throw markSyncFailureRecorded(error);
|
||||
}
|
||||
|
||||
// The derived home country follows the residency the instance holds; a run
|
||||
// that could not read residency keeps the previous one.
|
||||
let nextPrefs = prefs;
|
||||
if (countries && prefs.homeCountry === null) {
|
||||
const derived = mostFrequentCountry(countries, homeWindowFrom, today);
|
||||
if (derived !== prefs.derivedHomeCountry) {
|
||||
nextPrefs = { ...prefs, derivedHomeCountry: derived };
|
||||
await saveDawarichPrefs(userId, nextPrefs);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = buildDayValues({
|
||||
days,
|
||||
tz,
|
||||
timeline,
|
||||
visits,
|
||||
countries,
|
||||
homePlaceId: nextPrefs.homePlaceId,
|
||||
homeCountry: effectiveHomeCountry(nextPrefs),
|
||||
});
|
||||
|
||||
const fetchedAt = new Date();
|
||||
await prisma.$transaction(
|
||||
rows.map((row) => {
|
||||
const data = {
|
||||
totalDistanceM: row.totalDistanceM,
|
||||
drivingDistanceM: row.drivingDistanceM,
|
||||
trainDistanceM: row.trainDistanceM,
|
||||
placesVisited: row.placesVisited,
|
||||
awayFromHomeMinutes: row.awayFromHomeMinutes,
|
||||
countryCodeEncrypted: row.countryCode
|
||||
? sealDayCountry(row.countryCode)
|
||||
: null,
|
||||
isTravelDay: row.isTravelDay,
|
||||
fetchedAt,
|
||||
};
|
||||
return prisma.dawarichDay.upsert({
|
||||
where: { userId_date: { userId, date: row.date } },
|
||||
create: { userId, date: row.date, ...data },
|
||||
update: data,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await recordSyncSuccess(userId, "dawarich");
|
||||
return { daysStored: rows.length, notConnected: false };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Travel days: re-judging them when the home country changes, and offering
|
||||
* runs of them as trips for the environment module.
|
||||
*
|
||||
* A suggestion is a run of consecutive travel days in one country that no
|
||||
* stored trip (`EnvironmentTravelLocation`) overlaps. Its id is derived from
|
||||
* its dates and country, so it is stable across reads and a dismissal sticks
|
||||
* until the run itself changes (one more day of travel is a new suggestion).
|
||||
* Accepting goes through `POST /api/environment/travel`, where the person
|
||||
* picks the place; the trip then covers the run and the suggestion is gone.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { openDayCountry } from "./connection";
|
||||
import { travelDay } from "./day-values";
|
||||
|
||||
export interface TravelSuggestion {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
/** Suggestions reach back this far; an older trip is history, not a prompt. */
|
||||
const SUGGESTION_WINDOW_DAYS = 365;
|
||||
|
||||
/** A run needs at least this many days to be worth a trip. */
|
||||
const MIN_RUN_DAYS = 2;
|
||||
|
||||
export function suggestionId(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
countryCode: string,
|
||||
): string {
|
||||
return `${startDate}_${endDate}_${countryCode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group travel days into runs: consecutive dates in the same country. Pure;
|
||||
* input order does not matter.
|
||||
*/
|
||||
export function travelRuns(
|
||||
days: ReadonlyArray<{ date: string; countryCode: string }>,
|
||||
): TravelSuggestion[] {
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const runs: TravelSuggestion[] = [];
|
||||
let current: { start: string; end: string; code: string } | null = null;
|
||||
for (const day of sorted) {
|
||||
if (
|
||||
current &&
|
||||
day.countryCode === current.code &&
|
||||
day.date === shiftDateKey(current.end, 1)
|
||||
) {
|
||||
current.end = day.date;
|
||||
continue;
|
||||
}
|
||||
if (current) runs.push(toSuggestion(current));
|
||||
current = { start: day.date, end: day.date, code: day.countryCode };
|
||||
}
|
||||
if (current) runs.push(toSuggestion(current));
|
||||
return runs;
|
||||
}
|
||||
|
||||
function toSuggestion(run: {
|
||||
start: string;
|
||||
end: string;
|
||||
code: string;
|
||||
}): TravelSuggestion {
|
||||
return {
|
||||
id: suggestionId(run.start, run.end, run.code),
|
||||
startDate: run.start,
|
||||
endDate: run.end,
|
||||
countryCode: run.code,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The open suggestions: runs of at least two travel days in the last year
|
||||
* that no stored trip overlaps and the person has not dismissed. Newest first.
|
||||
*/
|
||||
export async function listTravelSuggestions(
|
||||
userId: string,
|
||||
today: string,
|
||||
dismissed: ReadonlySet<string>,
|
||||
): Promise<TravelSuggestion[]> {
|
||||
const from = shiftDateKey(today, -SUGGESTION_WINDOW_DAYS);
|
||||
const [rows, trips] = await Promise.all([
|
||||
prisma.dawarichDay.findMany({
|
||||
where: { userId, isTravelDay: true, date: { gte: from } },
|
||||
select: { date: true, countryCodeEncrypted: true },
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId, endDate: { gte: from } },
|
||||
select: { startDate: true, endDate: true },
|
||||
}),
|
||||
]);
|
||||
const days: Array<{ date: string; countryCode: string }> = [];
|
||||
for (const row of rows) {
|
||||
const code = openDayCountry(row.countryCodeEncrypted);
|
||||
if (code) days.push({ date: row.date, countryCode: code });
|
||||
}
|
||||
return travelRuns(days)
|
||||
.filter(
|
||||
(run) =>
|
||||
daysIn(run) >= MIN_RUN_DAYS &&
|
||||
!dismissed.has(run.id) &&
|
||||
!trips.some(
|
||||
(trip) =>
|
||||
trip.startDate <= run.endDate && trip.endDate >= run.startDate,
|
||||
),
|
||||
)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function daysIn(run: TravelSuggestion): number {
|
||||
let n = 1;
|
||||
for (let d = run.startDate; d < run.endDate; d = shiftDateKey(d, 1)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-judge every stored day against a new home country. Days without a
|
||||
* country stay null; the rest flip in two statements.
|
||||
*/
|
||||
export async function rejudgeTravelDays(
|
||||
userId: string,
|
||||
homeCountry: string | null,
|
||||
): Promise<void> {
|
||||
const rows = await prisma.dawarichDay.findMany({
|
||||
where: { userId, countryCodeEncrypted: { not: null } },
|
||||
select: { id: true, countryCodeEncrypted: true },
|
||||
});
|
||||
const travel: string[] = [];
|
||||
const home: string[] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const row of rows) {
|
||||
const verdict = travelDay(
|
||||
openDayCountry(row.countryCodeEncrypted),
|
||||
homeCountry,
|
||||
);
|
||||
if (verdict === true) travel.push(row.id);
|
||||
else if (verdict === false) home.push(row.id);
|
||||
else unknown.push(row.id);
|
||||
}
|
||||
await prisma.$transaction([
|
||||
prisma.dawarichDay.updateMany({
|
||||
where: { userId, id: { in: travel } },
|
||||
data: { isTravelDay: true },
|
||||
}),
|
||||
prisma.dawarichDay.updateMany({
|
||||
where: { userId, id: { in: home } },
|
||||
data: { isTravelDay: false },
|
||||
}),
|
||||
prisma.dawarichDay.updateMany({
|
||||
where: { userId, id: { in: unknown } },
|
||||
data: { isTravelDay: null },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
import { readDayMovement } from "@/lib/dawarich/movement";
|
||||
import type { DayEvent, DayResponse, DayRunningItem } from "@/lib/day/contract";
|
||||
import { loadNotableRange } from "@/lib/day/notable";
|
||||
import {
|
||||
@@ -101,7 +102,9 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
const recordSections = [...access.readable].filter(
|
||||
(section) => RECORD_SECTION_READERS[section] !== undefined,
|
||||
);
|
||||
const [readings, parts, scores] = await Promise.all([
|
||||
// The movement context rides the environment section: owner-only, and
|
||||
// gone with the module.
|
||||
const [readings, parts, scores, movement] = await Promise.all([
|
||||
valuesReadable || sleepReadable
|
||||
? resolveDayReadings(
|
||||
recordId,
|
||||
@@ -131,6 +134,9 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
floor: args.floor ?? null,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
access.readable.has("environment")
|
||||
? readDayMovement(recordId, day)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const presentTypes = [...readings.rowsByType.keys()];
|
||||
@@ -179,5 +185,6 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
|
||||
scores,
|
||||
sections,
|
||||
...(movement === undefined ? {} : { movement }),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
* own, separately fenced reads where a feature needs them;
|
||||
* - names a switched-off module's section as `module_disabled`, so a model
|
||||
* says "that module is off" instead of "nothing happened";
|
||||
* - drops the country of a travel day from the movement block: a model
|
||||
* reads that it was a travel day, not where the person was;
|
||||
* - leaves out what the caller excludes (`exclude`): the Coach passes the
|
||||
* person's Coach exclusions as sections, measurement types and scores,
|
||||
* so a metric left out of every other Coach read is left out of the day
|
||||
@@ -78,6 +80,21 @@ export interface ModelDay {
|
||||
max: number;
|
||||
band: { lo: number; hi: number; n: number } | null;
|
||||
}>;
|
||||
/**
|
||||
* The day's movement context (Dawarich), without the travel country. Absent
|
||||
* when the day has none to give or the environment section is excluded.
|
||||
*/
|
||||
movement?: {
|
||||
distance: {
|
||||
unit: "km" | "mi";
|
||||
total: number | null;
|
||||
driving: number | null;
|
||||
train: number | null;
|
||||
};
|
||||
placesVisited: number | null;
|
||||
awayFromHomeMinutes: number | null;
|
||||
travel: { isTravelDay: boolean } | null;
|
||||
} | null;
|
||||
/** Sections the reader does not get, and why. Never a model-excluded one. */
|
||||
unavailable: Array<{
|
||||
section: DaySectionKey;
|
||||
@@ -189,6 +206,18 @@ export function projectDayForModel(
|
||||
band: s.band,
|
||||
}))
|
||||
: [],
|
||||
...(day.movement !== undefined && visible("environment")
|
||||
? {
|
||||
movement: day.movement && {
|
||||
distance: day.movement.distance,
|
||||
placesVisited: day.movement.placesVisited,
|
||||
awayFromHomeMinutes: day.movement.awayFromHomeMinutes,
|
||||
travel: day.movement.travel && {
|
||||
isTravelDay: day.movement.travel.isTravelDay,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
@@ -438,6 +438,7 @@ function makePrisma() {
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The Coach transcript. Deliberately NOT `?? []` at the call site, so a
|
||||
// future `include` that forgets the relation fails here loudly instead of
|
||||
// exporting an account whose Coach never spoke.
|
||||
|
||||
@@ -304,6 +304,7 @@ function sourceClient() {
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -531,6 +531,10 @@ export const TWO_ENDED_MODELS = [
|
||||
// fortnight at home, days after a restore that reported success.
|
||||
"EnvironmentContext",
|
||||
"EnvironmentTravelLocation",
|
||||
// v1.43 — the Dawarich movement days ride the same environment section
|
||||
// (`src/lib/export/environment-backup.ts`): owned values a pruned instance
|
||||
// can no longer hand back.
|
||||
"DawarichDay",
|
||||
// How a schedule got to where it is. The register said a restored
|
||||
// medication keeps today's schedule and loses the record of when the dose or
|
||||
// the timing moved. Measured, it costs more than a record: the era minter
|
||||
@@ -638,8 +642,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
|
||||
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
// v1.43 — the new tables are declared here with the schema and move to
|
||||
// TWO_ENDED_MODELS when their section writer and restore branch land.
|
||||
DawarichDay:
|
||||
"The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.",
|
||||
WorkoutRoute:
|
||||
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
|
||||
WorkoutSamples:
|
||||
|
||||
@@ -195,6 +195,7 @@ const SUMMARY_COUNTS = [
|
||||
"userAchievements",
|
||||
"environmentContexts",
|
||||
"environmentTravelLocations",
|
||||
"dawarichDays",
|
||||
"ecgRecordings",
|
||||
] as const satisfies readonly (keyof BackupSummary & keyof FullBackupCounts)[];
|
||||
|
||||
|
||||
@@ -72,12 +72,24 @@
|
||||
* alike. Every one is optional on the way in: a file written before them
|
||||
* says nothing about air quality, and the restored day then reads as never
|
||||
* fetched (`aqFetchedAt` absent), so the nightly gap fill asks for it.
|
||||
*
|
||||
* ## The Dawarich days (v1.43)
|
||||
*
|
||||
* The movement context belongs to the same module and rides the same section
|
||||
* (`dawarichDays`): distances, places visited, minutes away from home, the
|
||||
* travel-day verdict, and the day's country. The country is the one sealed
|
||||
* column and follows the two purposes like a location: readable
|
||||
* `countryCode` in a portable file, `countryCodeEncrypted` (base64) in a
|
||||
* disaster-recovery file. A file written before v1.43 carries no key and
|
||||
* restores none. The connection itself (URL, key, preferences) is a
|
||||
* credential and never travels.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
|
||||
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
|
||||
import { openDayCountry, sealDayCountry } from "@/lib/dawarich/connection";
|
||||
import {
|
||||
openLocation,
|
||||
readLocation,
|
||||
@@ -178,14 +190,36 @@ export interface EnvironmentTravelLocationBackupEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** One local day of Dawarich movement values. */
|
||||
export interface DawarichDayBackupEntry {
|
||||
/** `YYYY-MM-DD` in the account's zone. A string end to end. */
|
||||
date: string;
|
||||
totalDistanceM: number | null;
|
||||
drivingDistanceM: number | null;
|
||||
trainDistanceM: number | null;
|
||||
placesVisited: number | null;
|
||||
awayFromHomeMinutes: number | null;
|
||||
/** Readable ISO 3166-1 alpha-2; portable files only. */
|
||||
countryCode: string | null;
|
||||
/** The sealed country, base64; disaster-recovery files only. */
|
||||
countryCodeEncrypted?: string | null;
|
||||
isTravelDay: boolean | null;
|
||||
/** When the day was last fetched from Dawarich. Verbatim. */
|
||||
fetchedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackupSection {
|
||||
environmentContexts: EnvironmentContextBackupEntry[];
|
||||
environmentTravelLocations: EnvironmentTravelLocationBackupEntry[];
|
||||
dawarichDays: DawarichDayBackupEntry[];
|
||||
}
|
||||
|
||||
export interface EnvironmentBackupCounts {
|
||||
environmentContexts: number;
|
||||
environmentTravelLocations: number;
|
||||
dawarichDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +280,20 @@ const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
|
||||
updatedAt: true,
|
||||
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
|
||||
|
||||
const DAWARICH_DAY_BACKUP_SELECT = {
|
||||
date: true,
|
||||
totalDistanceM: true,
|
||||
drivingDistanceM: true,
|
||||
trainDistanceM: true,
|
||||
placesVisited: true,
|
||||
awayFromHomeMinutes: true,
|
||||
countryCodeEncrypted: true,
|
||||
isTravelDay: true,
|
||||
fetchedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const satisfies Prisma.DawarichDaySelect;
|
||||
|
||||
/** A sealed column as it rides a disaster-recovery file. */
|
||||
function toBase64(bytes: Uint8Array | null): string | null {
|
||||
return bytes && bytes.byteLength > 0
|
||||
@@ -287,13 +335,13 @@ function locationFields(
|
||||
export async function buildEnvironmentBackupSection(
|
||||
prisma: Pick<
|
||||
PrismaClient,
|
||||
"environmentContext" | "environmentTravelLocation"
|
||||
"environmentContext" | "environmentTravelLocation" | "dawarichDay"
|
||||
>,
|
||||
userId: string,
|
||||
options: EnvironmentBackupOptions = {},
|
||||
): Promise<EnvironmentBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const [contextRows, travelRows] = await Promise.all([
|
||||
const [contextRows, travelRows, dawarichRows] = await Promise.all([
|
||||
prisma.environmentContext.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: "asc" },
|
||||
@@ -304,6 +352,11 @@ export async function buildEnvironmentBackupSection(
|
||||
orderBy: { startDate: "asc" },
|
||||
select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT,
|
||||
}),
|
||||
prisma.dawarichDay.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: "asc" },
|
||||
select: DAWARICH_DAY_BACKUP_SELECT,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -363,6 +416,24 @@ export async function buildEnvironmentBackupSection(
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
})),
|
||||
dawarichDays: dawarichRows.map((row) => ({
|
||||
date: row.date,
|
||||
totalDistanceM: row.totalDistanceM,
|
||||
drivingDistanceM: row.drivingDistanceM,
|
||||
trainDistanceM: row.trainDistanceM,
|
||||
placesVisited: row.placesVisited,
|
||||
awayFromHomeMinutes: row.awayFromHomeMinutes,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
countryCode: null,
|
||||
countryCodeEncrypted: toBase64(row.countryCodeEncrypted),
|
||||
}
|
||||
: { countryCode: openDayCountry(row.countryCodeEncrypted) }),
|
||||
isTravelDay: row.isTravelDay,
|
||||
fetchedAt: row.fetchedAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -373,6 +444,7 @@ export function countEnvironmentBackupSection(
|
||||
return {
|
||||
environmentContexts: section.environmentContexts.length,
|
||||
environmentTravelLocations: section.environmentTravelLocations.length,
|
||||
dawarichDays: section.dawarichDays.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,6 +452,7 @@ export function countEnvironmentBackupSection(
|
||||
export interface EnvironmentRestoreCleared {
|
||||
environmentContexts: number;
|
||||
environmentTravelLocations: number;
|
||||
dawarichDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,9 +547,40 @@ function restoredLocation(
|
||||
return sealLocation({ lat: entry.lat, lon: entry.lon, label: entry.label });
|
||||
}
|
||||
|
||||
export type RestoredDawarichDay = Pick<DawarichDayBackupEntry, "date"> &
|
||||
OptionalNullable<Omit<DawarichDayBackupEntry, "date">>;
|
||||
|
||||
/**
|
||||
* The sealed country a restored day is written with: the file's sealed value
|
||||
* as it came (named in `unopened` when this host cannot open it), or the
|
||||
* readable code sealed under this host's key; null with neither.
|
||||
*/
|
||||
function restoredCountry(
|
||||
entry: RestoredDawarichDay,
|
||||
unopened: string[],
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (typeof entry.countryCodeEncrypted === "string") {
|
||||
const buffer = Buffer.from(entry.countryCodeEncrypted, "base64");
|
||||
if (buffer.byteLength > 0) {
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
if (openDayCountry(bytes) === null) {
|
||||
unopened.push(`dawarichDays.${entry.date}`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
return typeof entry.countryCode === "string" &&
|
||||
/^[A-Z]{2}$/.test(entry.countryCode)
|
||||
? sealDayCountry(entry.countryCode)
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface EnvironmentRestoreInput {
|
||||
environmentContexts: RestoredEnvironmentContext[];
|
||||
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
|
||||
/** Absent in a file written before v1.43; nothing to restore then. */
|
||||
dawarichDays?: RestoredDawarichDay[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,9 +606,10 @@ export async function restoreEnvironmentData(
|
||||
): Promise<EnvironmentRestoreCleared> {
|
||||
// Sealed locations this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
const [clearedTravel, clearedContexts] = await Promise.all([
|
||||
const [clearedTravel, clearedContexts, clearedDawarich] = await Promise.all([
|
||||
tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }),
|
||||
tx.environmentContext.deleteMany({ where: { userId: ownerId } }),
|
||||
tx.dawarichDay.deleteMany({ where: { userId: ownerId } }),
|
||||
]);
|
||||
|
||||
if (payload.environmentTravelLocations.length > 0) {
|
||||
@@ -570,6 +675,28 @@ export async function restoreEnvironmentData(
|
||||
});
|
||||
}
|
||||
|
||||
const dawarichDays = payload.dawarichDays ?? [];
|
||||
if (dawarichDays.length > 0) {
|
||||
await tx.dawarichDay.createMany({
|
||||
data: dawarichDays.map((entry) => ({
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
totalDistanceM: entry.totalDistanceM ?? null,
|
||||
drivingDistanceM: entry.drivingDistanceM ?? null,
|
||||
trainDistanceM: entry.trainDistanceM ?? null,
|
||||
placesVisited: entry.placesVisited ?? null,
|
||||
awayFromHomeMinutes: entry.awayFromHomeMinutes ?? null,
|
||||
countryCodeEncrypted: restoredCountry(entry, unopened),
|
||||
isTravelDay: entry.isTravelDay ?? null,
|
||||
// Verbatim, like the weather rows: when Dawarich was read, not when
|
||||
// the row came back. A file without it reads as fetched now.
|
||||
fetchedAt: entry.fetchedAt ? new Date(entry.fetchedAt) : new Date(),
|
||||
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
|
||||
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (skips) {
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
@@ -582,5 +709,6 @@ export async function restoreEnvironmentData(
|
||||
return {
|
||||
environmentContexts: clearedContexts.count,
|
||||
environmentTravelLocations: clearedTravel.count,
|
||||
dawarichDays: clearedDawarich.count,
|
||||
};
|
||||
}
|
||||
@@ -198,6 +198,7 @@ export interface RestoreResponse {
|
||||
userAchievements: number;
|
||||
environmentContexts: number;
|
||||
environmentTravelLocations: number;
|
||||
dawarichDays: number;
|
||||
ecgRecordings: number;
|
||||
symptomDefinitions: number;
|
||||
lifeEvents: number;
|
||||
@@ -2833,6 +2834,7 @@ export async function restoreBackup(
|
||||
environmentContexts: environmentCleared.environmentContexts,
|
||||
environmentTravelLocations:
|
||||
environmentCleared.environmentTravelLocations,
|
||||
dawarichDays: environmentCleared.dawarichDays,
|
||||
ecgRecordings: ecgCleared.ecgRecordings,
|
||||
symptomDefinitions: symptomsCleared.symptomDefinitions,
|
||||
lifeEvents: lifeEventsCleared.lifeEvents,
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({
|
||||
fetchEnvironmentSeries: vi.fn(async () => [
|
||||
{ key: "ENV_TEMP_MEAN", role: "behaviour", points: pts },
|
||||
]),
|
||||
fetchMovementSeries: vi.fn(async () => [
|
||||
{ key: "MOVEMENT_AWAY", role: "behaviour", points: pts },
|
||||
]),
|
||||
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
|
||||
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
|
||||
]),
|
||||
@@ -54,7 +57,10 @@ import {
|
||||
assembleDiscoveryMatrix,
|
||||
maskSeriesByModules,
|
||||
} from "@/lib/insights/discovery-matrix";
|
||||
import { fetchEnvironmentSeries } from "@/lib/insights/correlation-channel-series";
|
||||
import {
|
||||
fetchEnvironmentSeries,
|
||||
fetchMovementSeries,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
|
||||
const OPTS = {
|
||||
tz: "UTC",
|
||||
@@ -110,6 +116,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
|
||||
"SYMPTOM_SEVERITY",
|
||||
"SYMPTOM:def1",
|
||||
"ENV_TEMP_MEAN",
|
||||
"MOVEMENT_AWAY",
|
||||
]) {
|
||||
expect(keys, gone).not.toContain(gone);
|
||||
expect(byMetric.has(gone), gone).toBe(false);
|
||||
@@ -132,6 +139,19 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not read the Dawarich days with the environment module off", async () => {
|
||||
const read = vi.mocked(fetchMovementSeries);
|
||||
read.mockClear();
|
||||
await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: { environment: false },
|
||||
});
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
const on = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(on.series.map((s) => s.key)).toContain("MOVEMENT_AWAY");
|
||||
});
|
||||
|
||||
it("drops nothing for AI analysis off", async () => {
|
||||
const all = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
const off = await assembleDiscoveryMatrix("u1", {
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
illnessDayLog: { findMany: vi.fn() },
|
||||
environmentContext: { findMany: vi.fn() },
|
||||
dawarichDay: { findMany: vi.fn(async () => []) },
|
||||
customMetric: { findMany: vi.fn() },
|
||||
symptomDefinition: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
import {
|
||||
FACTOR_CHANNEL_PREFIX,
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
MOVEMENT_AWAY_CHANNEL_KEY,
|
||||
MOVEMENT_DISTANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type DailySeriesPoint,
|
||||
type LabDrawPoint,
|
||||
@@ -58,7 +60,7 @@ import {
|
||||
pickMainNightAndNaps,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { isNearUtc, shiftDateKey } from "@/lib/tz/format";
|
||||
import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type {
|
||||
@@ -332,6 +334,49 @@ export function averageWithPreviousDay(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 — the movement BEHAVIOUR channels from the stored Dawarich days:
|
||||
* hours away from home and kilometres travelled. A day without a value (no
|
||||
* home place, no visit, no summary) has no point; it is no coverage, not
|
||||
* zero. Empty for an account that never connected Dawarich.
|
||||
*/
|
||||
export async function fetchMovementSeries(
|
||||
userId: string,
|
||||
tz: string,
|
||||
since: Date,
|
||||
): Promise<NamedSeries[]> {
|
||||
const rows = await prisma.dawarichDay.findMany({
|
||||
where: { userId, date: { gte: userDayKey(since, tz) } },
|
||||
orderBy: { date: "asc" },
|
||||
take: 1000,
|
||||
select: { date: true, awayFromHomeMinutes: true, totalDistanceM: true },
|
||||
});
|
||||
const away: DailySeriesPoint[] = [];
|
||||
const distance: DailySeriesPoint[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.awayFromHomeMinutes !== null) {
|
||||
away.push({ day: row.date, value: row.awayFromHomeMinutes / 60 });
|
||||
}
|
||||
if (row.totalDistanceM !== null) {
|
||||
distance.push({ day: row.date, value: row.totalDistanceM / 1000 });
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: MOVEMENT_AWAY_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
label: "time away from home",
|
||||
points: away,
|
||||
},
|
||||
{
|
||||
key: MOVEMENT_DISTANCE_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
label: "distance travelled",
|
||||
points: distance,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit deterministic ceiling on opt-in custom behaviour channels. */
|
||||
export const MAX_CUSTOM_CORRELATION_CHANNELS = 8;
|
||||
export const CUSTOM_METRIC_CHANNEL_PREFIX = "CUSTOM_METRIC:";
|
||||
|
||||
@@ -135,6 +135,7 @@ export function metricFamily(key: string): string {
|
||||
// near-tautology). They stay free to pair cross-domain against mood / sleep /
|
||||
// vitals — the whole point of the module.
|
||||
if (key.startsWith("ENV_")) return "ENVIRONMENT";
|
||||
if (key.startsWith("MOVEMENT_")) return "MOVEMENT";
|
||||
if (key.startsWith("CUSTOM_METRIC:")) return key;
|
||||
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
|
||||
// their OWN single-channel family, so the loop's same-family guard collapses
|
||||
@@ -453,6 +454,16 @@ export const MEDICATION_COMPLIANCE_CHANNEL_KEY = "MEDICATION_COMPLIANCE";
|
||||
*/
|
||||
export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY";
|
||||
|
||||
/**
|
||||
* v1.43 — the Dawarich movement context as two BEHAVIOUR channels: minutes
|
||||
* away from home and the day's total distance. Both share the `MOVEMENT`
|
||||
* family (a day far from home is a day with distance; pairing the two with
|
||||
* each other would be a tautology) and pair at the default next-day lag:
|
||||
* "a day out, then the night's sleep". Owned by the environment module.
|
||||
*/
|
||||
export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY";
|
||||
export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE";
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum
|
||||
* values: each is backed by a different model (MOOD → MoodEntry,
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
|
||||
symptomDefinition: { findMany: async () => [] },
|
||||
dawarichDay: { findMany: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
DISCOVERY_OUTCOMES,
|
||||
discoveryMeasurementTypes,
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
MOVEMENT_AWAY_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
fetchCustomMetricBehaviourSeries,
|
||||
fetchSymptomEventSeries,
|
||||
fetchEnvironmentSeries,
|
||||
fetchMovementSeries,
|
||||
fetchMeasurementDailySeriesTiered,
|
||||
fetchMeasurementWindowSeries,
|
||||
fetchMoodFactorWindowSeries,
|
||||
@@ -152,6 +154,8 @@ export interface DiscoveryMatrixDiagnostics {
|
||||
symptomDays: number;
|
||||
/** Stored daily points summed across the environmental-exposure channels. */
|
||||
environmentDays: number;
|
||||
/** Stored daily points summed across the two movement channels. */
|
||||
movementDays: number;
|
||||
customMetricChannels: number;
|
||||
customMetricDays: number;
|
||||
/** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */
|
||||
@@ -299,6 +303,7 @@ export async function assembleDiscoveryMatrix(
|
||||
environmentSeries,
|
||||
customMetricSeries,
|
||||
symptomEventSeries,
|
||||
movementSeries,
|
||||
] = await Promise.all([
|
||||
readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode),
|
||||
readMood(userId, tz, since, opts.includeMoodFactors === true),
|
||||
@@ -316,6 +321,14 @@ export async function assembleDiscoveryMatrix(
|
||||
: Promise.resolve([] as NamedSeries[]),
|
||||
fetchCustomMetricBehaviourSeries(userId, tz, since),
|
||||
fetchSymptomEventSeries(userId, tz, since),
|
||||
// v1.43 — the Dawarich movement channels, skipped like the weather when
|
||||
// the environment module is off.
|
||||
isSurfaceVisible(
|
||||
correlationChannelSurfaceId(MOVEMENT_AWAY_CHANNEL_KEY),
|
||||
opts.modules,
|
||||
)
|
||||
? fetchMovementSeries(userId, tz, since)
|
||||
: Promise.resolve([] as NamedSeries[]),
|
||||
]);
|
||||
|
||||
const { factorSeries } = mood;
|
||||
@@ -347,7 +360,7 @@ export async function assembleDiscoveryMatrix(
|
||||
// exposure already averaged across D−1 and D (see `fetchEnvironmentSeries`).
|
||||
// Both run under the same n ≥ 20 / FDR / effect-size gates, so a thin
|
||||
// weather or custom series degrades to absent.
|
||||
series.push(...environmentSeries, ...customMetricSeries);
|
||||
series.push(...environmentSeries, ...movementSeries, ...customMetricSeries);
|
||||
// The person's own symptoms are OUTCOMES: "short sleep, then an aura the
|
||||
// next day" is the question they log them to answer. Each is its own
|
||||
// channel and its own family, capped at eight; the illness module owns them
|
||||
@@ -387,6 +400,7 @@ export async function assembleDiscoveryMatrix(
|
||||
(sum, s) => sum + s.points.length,
|
||||
0,
|
||||
),
|
||||
movementDays: movementSeries.reduce((sum, s) => sum + s.points.length, 0),
|
||||
customMetricChannels: customMetricSeries.length,
|
||||
customMetricDays: customMetricSeries.reduce(
|
||||
(sum, s) => sum + s.points.length,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
|
||||
symptomDefinition: { findMany: async () => [] },
|
||||
dawarichDay: { findMany: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -200,9 +200,11 @@ describe("resolveSyncVerdict — the ladder", () => {
|
||||
).toEqual({ verdict: "fresh", since: null });
|
||||
});
|
||||
|
||||
it("puts every ledger-backed integration on the polled cadence", () => {
|
||||
for (const cadence of Object.values(INTEGRATION_CADENCE)) {
|
||||
expect(cadence.attemptStaleAfterMs).toBe(24 * HOUR);
|
||||
it("puts every hourly ledger-backed integration on the polled cadence", () => {
|
||||
for (const [key, cadence] of Object.entries(INTEGRATION_CADENCE)) {
|
||||
// Dawarich runs once a night; a run is overdue after two days.
|
||||
const expected = key === "dawarich" ? 48 * HOUR : 24 * HOUR;
|
||||
expect(cadence.attemptStaleAfterMs, key).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { readOuraScopeDenied } from "@/lib/oura/credentials";
|
||||
import { ouraScopeReconnectList } from "@/lib/oura/scope";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export type ConnectionHealthMap = Record<
|
||||
ConnectionKey,
|
||||
@@ -101,6 +103,20 @@ async function newestWorkoutAt(
|
||||
return row?.startedAt ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dawarich stores days, not readings: its newest data is the start of the
|
||||
* newest stored day, in the account's zone.
|
||||
*/
|
||||
async function newestDawarichDayAt(userId: string): Promise<Date | null> {
|
||||
const row = await prisma.dawarichDay.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
return localDayWindow(row.date, await resolveUserTimezone(userId)).dayStart;
|
||||
}
|
||||
|
||||
function newestOf(...values: Array<Date | null | undefined>): Date | null {
|
||||
let max: Date | null = null;
|
||||
for (const v of values) {
|
||||
@@ -166,6 +182,7 @@ export async function loadConnectionFacts(
|
||||
ouraScopeDenied: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
nightscoutUrlEncrypted: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
healthKitLastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
@@ -223,6 +240,7 @@ export async function loadConnectionFacts(
|
||||
oura: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
strava: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
nightscout: !!dbUser?.nightscoutUrlEncrypted,
|
||||
dawarich: !!dbUser?.dawarichUrlEncrypted,
|
||||
// Apple Health has nothing to connect; it is "connected" once the phone
|
||||
// has delivered anything (a live batch, or rows from the export import).
|
||||
"apple-health": false,
|
||||
@@ -245,6 +263,10 @@ export async function loadConnectionFacts(
|
||||
return;
|
||||
}
|
||||
if (!connected[key]) return;
|
||||
if (key === "dawarich") {
|
||||
lastData.set(key, await newestDawarichDayAt(userId));
|
||||
return;
|
||||
}
|
||||
const mSource = INTEGRATION_MEASUREMENT_SOURCE[key as IntegrationKey];
|
||||
const wSource = INTEGRATION_WORKOUT_SOURCE[key as IntegrationKey];
|
||||
const [m, w] = await Promise.all([
|
||||
|
||||
@@ -43,6 +43,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"apple-health",
|
||||
];
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ export type IntegrationKey =
|
||||
// `fitbit` transport, which sunsets Sept 2026.
|
||||
| "google-health"
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava";
|
||||
| "strava"
|
||||
// v1.43 — Dawarich movement context (daily values, nightly).
|
||||
| "dawarich";
|
||||
|
||||
/**
|
||||
* Failure kinds carried into `recordSyncFailure`.
|
||||
@@ -1280,6 +1282,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
dawarich: "Dawarich",
|
||||
};
|
||||
|
||||
const FAILURE_KIND_COPY: Record<
|
||||
|
||||
@@ -102,9 +102,19 @@ export const PUSH_CADENCE: SyncCadence = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `IntegrationKey` is polled by an hourly cron registered in
|
||||
* `src/lib/jobs/reminder/register-integration-sync.ts`. Apple Health is not an
|
||||
* `IntegrationKey` — it takes `PUSH_CADENCE` directly.
|
||||
* Dawarich runs once a night (`dawarich-sync`, 03:20). A run is overdue after
|
||||
* two days, and a successful run is the freshness: a phone that recorded
|
||||
* nothing is not a failing connection.
|
||||
*/
|
||||
const NIGHTLY_CADENCE: SyncCadence = {
|
||||
attemptStaleAfterMs: 2 * ATTEMPT_STALE_AFTER_MS,
|
||||
dataStaleAfterMs: DATA_STALE_AFTER_MS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `IntegrationKey` but Dawarich is polled by an hourly cron registered
|
||||
* in `src/lib/jobs/reminder/register-integration-sync.ts`; Dawarich nightly.
|
||||
* Apple Health is not an `IntegrationKey` — it takes `PUSH_CADENCE` directly.
|
||||
*/
|
||||
export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
|
||||
withings: POLLED_CADENCE,
|
||||
@@ -115,6 +125,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
|
||||
oura: POLLED_CADENCE,
|
||||
"google-health": POLLED_CADENCE,
|
||||
strava: POLLED_CADENCE,
|
||||
dawarich: NIGHTLY_CADENCE,
|
||||
};
|
||||
|
||||
export interface SyncVerdictInput {
|
||||
|
||||
@@ -69,6 +69,10 @@ export const INCIDENT_AFTER_MS = DAY_MS;
|
||||
* low on storage, in low-power mode or simply not unlocked, and a day of
|
||||
* that is ordinary too.
|
||||
*
|
||||
* Dawarich is on-demand as well: it records where a phone was, and a phone
|
||||
* left at home or switched off for days is ordinary. It is watched for
|
||||
* errors only, never for silence.
|
||||
*
|
||||
* The history gate already limits the check to sources that deliver almost
|
||||
* daily; the window only decides how long a gap is ordinary.
|
||||
*/
|
||||
@@ -81,6 +85,7 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
|
||||
"google-health": { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
|
||||
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const LABELS: Record<ConnectionKey, string> = {
|
||||
oura: "Oura",
|
||||
strava: "Strava",
|
||||
nightscout: "Nightscout",
|
||||
dawarich: "Dawarich",
|
||||
"apple-health": "Apple Health",
|
||||
};
|
||||
|
||||
@@ -127,6 +128,7 @@ async function listWatchedUserIds(): Promise<string[]> {
|
||||
{ ouraAccessTokenEncrypted: { not: null } },
|
||||
{ stravaAccessTokenEncrypted: { not: null } },
|
||||
{ nightscoutUrlEncrypted: { not: null } },
|
||||
{ dawarichUrlEncrypted: { not: null } },
|
||||
{ healthKitLastSyncedAt: { not: null } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* v1.43 — the Dawarich movement context, fetched once a night.
|
||||
*
|
||||
* The daily tick (empty payload) fans out one job per account with a stored
|
||||
* connection and the `environment` module on. A per-account job fetches the
|
||||
* last seven days ending yesterday, so a phone that uploaded late fills in the
|
||||
* days it missed; the connect route and the settings card send an explicit
|
||||
* `fromDate` for the 30-day backfill and for a longer catch-up (up to 365
|
||||
* days). Without a connection, or with the module off, nothing leaves the host.
|
||||
*
|
||||
* Every run is its own background event (`job.dawarich_sync`), so its lines
|
||||
* and its failure carry the action they belong to.
|
||||
*/
|
||||
import type { Job, PgBoss } from "pg-boss";
|
||||
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { syncDawarichRange, yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isReauthRequired } from "@/lib/integrations/status";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getOperatorModuleAvailability,
|
||||
isModuleEnabled,
|
||||
normalisePrefs,
|
||||
resolveModuleEnabled,
|
||||
} from "@/lib/modules/gate";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { DAWARICH_SYNC_MAX_DAYS } from "@/lib/validations/dawarich";
|
||||
|
||||
export const DAWARICH_SYNC_QUEUE = "dawarich-sync";
|
||||
|
||||
/** Daily 03:20 Europe/Berlin, after the night's environment fetch. */
|
||||
export const DAWARICH_SYNC_CRON = "20 3 * * *";
|
||||
|
||||
/** The nightly window: yesterday and the six days before it. */
|
||||
const NIGHTLY_WINDOW_DAYS = 7;
|
||||
|
||||
/** The backfill a fresh connection starts with. */
|
||||
export const DAWARICH_CONNECT_BACKFILL_DAYS = 30;
|
||||
|
||||
/** One account has at most one requested run per slot. */
|
||||
const REQUEST_SINGLETON_SECONDS = 5 * 60;
|
||||
|
||||
export interface DawarichSyncPayload {
|
||||
userId?: string;
|
||||
/** First day to fetch (`YYYY-MM-DD`); the nightly window when absent. */
|
||||
fromDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one account's run from a request (connect, the settings card). One run
|
||||
* per account per five minutes; a send inside the slot is refused and says so.
|
||||
*/
|
||||
export async function enqueueDawarichSync(payload: {
|
||||
userId: string;
|
||||
fromDate?: string;
|
||||
}): Promise<"enqueued" | "no_worker" | "already_queued"> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return "no_worker";
|
||||
const id = await boss.send(DAWARICH_SYNC_QUEUE, payload, {
|
||||
singletonKey: `dawarich-sync:${payload.userId}`,
|
||||
singletonSeconds: REQUEST_SINGLETON_SECONDS,
|
||||
});
|
||||
return id ? "enqueued" : "already_queued";
|
||||
}
|
||||
|
||||
async function discover(boss: PgBoss): Promise<JobOutcome> {
|
||||
const [candidates, operatorAvailability] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { dawarichUrlEncrypted: { not: null } },
|
||||
select: { id: true, modulePreferencesJson: true },
|
||||
}),
|
||||
getOperatorModuleAvailability(),
|
||||
]);
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id, modulePreferencesJson } of candidates) {
|
||||
const enabled = resolveModuleEnabled(
|
||||
"environment",
|
||||
{
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferences: normalisePrefs(modulePreferencesJson),
|
||||
cycleTrackingEnabled: null,
|
||||
},
|
||||
false,
|
||||
operatorAvailability,
|
||||
);
|
||||
// A connection waiting for a new key or paused after a day of failures
|
||||
// is not retried every night; reconnecting starts it again.
|
||||
if (!enabled || (await isReauthRequired(id, "dawarich"))) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await boss.send(
|
||||
DAWARICH_SYNC_QUEUE,
|
||||
{ userId: id } satisfies DawarichSyncPayload,
|
||||
{ singletonKey: `dawarich-sync:${id}` },
|
||||
);
|
||||
enqueued += 1;
|
||||
}
|
||||
annotate({
|
||||
action: { name: "dawarich.sync.discover" },
|
||||
meta: { users_enqueued: enqueued, users_skipped: skipped },
|
||||
});
|
||||
return jobDone({ users_enqueued: enqueued, users_skipped: skipped });
|
||||
}
|
||||
|
||||
async function runForUser(
|
||||
userId: string,
|
||||
fromDate: string | undefined,
|
||||
): Promise<JobOutcome> {
|
||||
annotate({ action: { name: "dawarich.sync.run" } });
|
||||
if (!(await isModuleEnabled(userId, "environment"))) {
|
||||
return jobDone({ skipped: "environment_module_disabled" });
|
||||
}
|
||||
if (!(await loadDawarichConnection(userId))) {
|
||||
return jobDone({ skipped: "not_connected" });
|
||||
}
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const toDate = yesterdayKey(tz);
|
||||
const from = fromDate ?? shiftDateKey(toDate, -(NIGHTLY_WINDOW_DAYS - 1));
|
||||
// Clamp so a crafted payload cannot fan out an unbounded range.
|
||||
const span = daysBetweenDateKeys(from, toDate) + 1;
|
||||
const start =
|
||||
Number.isFinite(span) && span <= DAWARICH_SYNC_MAX_DAYS
|
||||
? from
|
||||
: shiftDateKey(toDate, -(DAWARICH_SYNC_MAX_DAYS - 1));
|
||||
try {
|
||||
const result = await syncDawarichRange(userId, start, toDate);
|
||||
annotate({ meta: { dawarich_days_stored: result.daysStored } });
|
||||
return jobDone({ days_stored: result.daysStored });
|
||||
} catch (error) {
|
||||
// Already on the ledger, classified; the job fails so the retry policy
|
||||
// applies to a transient outage.
|
||||
return jobFailed("dawarich fetch failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** The queue handler: discovery on an empty payload, one account otherwise. */
|
||||
export async function handleDawarichSync(
|
||||
boss: PgBoss,
|
||||
jobs: Job<DawarichSyncPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
let failure: JobOutcome | null = null;
|
||||
for (const job of jobs) {
|
||||
const outcome = await withBackgroundEvent("job.dawarich_sync", () =>
|
||||
job.data?.userId
|
||||
? runForUser(job.data.userId, job.data.fromDate)
|
||||
: discover(boss),
|
||||
);
|
||||
if (!outcome.ok && failure === null) failure = outcome;
|
||||
}
|
||||
return failure ?? jobDone({ jobs: jobs.length });
|
||||
}
|
||||
@@ -478,6 +478,9 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"environment-fetch": short(
|
||||
"A fan-out of sends, or one account's bounded archive range.",
|
||||
),
|
||||
"dawarich-sync": short(
|
||||
"A fan-out of sends, or one account's range: one timeline request per 30 days (at most 13), one visits request and at most two residency requests.",
|
||||
),
|
||||
"environment-aq-history": short(
|
||||
"A fan-out of sends, or one account's history run: at most eight ranges of up to 90 days, two requests each, then a follow-up.",
|
||||
),
|
||||
|
||||
@@ -202,6 +202,12 @@ import {
|
||||
handleEnvironmentFetch,
|
||||
type EnvironmentFetchPayload,
|
||||
} from "@/lib/jobs/environment-fetch";
|
||||
import {
|
||||
DAWARICH_SYNC_CRON,
|
||||
DAWARICH_SYNC_QUEUE,
|
||||
handleDawarichSync,
|
||||
type DawarichSyncPayload,
|
||||
} from "@/lib/jobs/dawarich-sync";
|
||||
import {
|
||||
ENVIRONMENT_AQ_HISTORY_CRON,
|
||||
ENVIRONMENT_AQ_HISTORY_QUEUE,
|
||||
@@ -435,6 +441,9 @@ const allQueues = [
|
||||
// v1.42 — the air-quality history backfill: per account, from boot and
|
||||
// nightly discovery, the home being set and air quality switched on.
|
||||
ENVIRONMENT_AQ_HISTORY_QUEUE,
|
||||
// v1.43 — the Dawarich movement context: nightly discovery, per-account
|
||||
// runs, and the connect / settings backfills.
|
||||
DAWARICH_SYNC_QUEUE,
|
||||
DATA_BACKUP_QUEUE,
|
||||
RATE_LIMIT_CLEANUP_QUEUE,
|
||||
IDEMPOTENCY_CLEANUP_QUEUE,
|
||||
@@ -623,6 +632,9 @@ const schedules: ScheduleEntry[] = [
|
||||
// v1.42 — daily 02:40 discovery for the air-quality history backfill, after
|
||||
// the night's environment fetch has taken its share of the budget.
|
||||
[ENVIRONMENT_AQ_HISTORY_QUEUE, ENVIRONMENT_AQ_HISTORY_CRON],
|
||||
// v1.43 — daily 03:20 discovery for the Dawarich movement context. A
|
||||
// failed run is an outage at the instance; the default retries apply.
|
||||
[DAWARICH_SYNC_QUEUE, DAWARICH_SYNC_CRON],
|
||||
// The send options are the point of this tuple: without them the weekly
|
||||
// snapshot inherits pg-boss's 15-minute expiration and is killed mid-run on
|
||||
// any sizeable record. See `data-backup-policy.ts` for why the window rides
|
||||
@@ -1850,6 +1862,15 @@ export async function registerMaintenanceQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.43 — Dawarich movement context. Serial: one account at a time, so a
|
||||
// slow instance never crowds the request pool.
|
||||
await createAndWork<DawarichSyncPayload>(
|
||||
boss,
|
||||
DAWARICH_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
(jobs) => handleDawarichSync(boss, jobs),
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ const subsystemSurface = {
|
||||
// register-maintenance.ts
|
||||
"environment-fetch": { audience: "account" },
|
||||
"environment-aq-history": { audience: "system" },
|
||||
"dawarich-sync": { audience: "account" },
|
||||
"data-backup": { audience: "system" },
|
||||
"rate-limit-cleanup": { audience: "system" },
|
||||
"idempotency-cleanup": { audience: "system" },
|
||||
|
||||
@@ -1057,6 +1057,21 @@ const getDayOutput: z.ZodRawShape = {
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
// v1.43 — the movement context (Dawarich), without a travel country.
|
||||
movement: z
|
||||
.object({
|
||||
distance: z.object({
|
||||
unit: z.enum(["km", "mi"]),
|
||||
total: z.number().nullable(),
|
||||
driving: z.number().nullable(),
|
||||
train: z.number().nullable(),
|
||||
}),
|
||||
placesVisited: z.number().nullable(),
|
||||
awayFromHomeMinutes: z.number().nullable(),
|
||||
travel: z.object({ isTravelDay: z.boolean() }).nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
unavailable: z.array(
|
||||
z.object({
|
||||
section: modelDaySection,
|
||||
|
||||
@@ -56,6 +56,8 @@ import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules";
|
||||
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
|
||||
import {
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
MOVEMENT_AWAY_CHANNEL_KEY,
|
||||
MOVEMENT_DISTANCE_CHANNEL_KEY,
|
||||
DISCOVERY_OUTCOMES,
|
||||
FACTOR_CHANNEL_PREFIX,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
@@ -179,11 +181,13 @@ describe("every surface id names a surface that exists", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("correlation: a discovery channel, a weather field or the lab pass", () => {
|
||||
it("correlation: a discovery channel, a weather field, a movement channel or the lab pass", () => {
|
||||
const channels = new Set<string>([
|
||||
...DISCOVERY_BEHAVIOURS,
|
||||
...DISCOVERY_OUTCOMES,
|
||||
...ENVIRONMENT_FIELDS.map((f) => f.key),
|
||||
MOVEMENT_AWAY_CHANNEL_KEY,
|
||||
MOVEMENT_DISTANCE_CHANNEL_KEY,
|
||||
"LAB_DRAWS",
|
||||
]);
|
||||
for (const id of localIds("correlation")) {
|
||||
|
||||
@@ -217,6 +217,9 @@ const STATIC_SURFACE_MODULE = {
|
||||
"correlation:SYMPTOM_SEVERITY": "illness",
|
||||
// The labs ↔ outcome pass over lab draws.
|
||||
"correlation:LAB_DRAWS": "labs",
|
||||
// v1.43 — the Dawarich movement channels ride the environment module.
|
||||
"correlation:MOVEMENT_AWAY": "environment",
|
||||
"correlation:MOVEMENT_DISTANCE": "environment",
|
||||
|
||||
// ── Day view sections (v1.42) ──
|
||||
// The readings carry no owner here: each value follows its own type's
|
||||
|
||||
@@ -127,7 +127,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
|
||||
"disable-coach": ["disable-coach.body.invalid_json"],
|
||||
dawarich: ["dawarich.invalid", "dawarich.rate_limited"],
|
||||
dawarich: [
|
||||
"dawarich.credentials_rejected",
|
||||
"dawarich.home_place_unknown",
|
||||
"dawarich.invalid",
|
||||
"dawarich.not_connected",
|
||||
"dawarich.rate_limited",
|
||||
"dawarich.unreachable",
|
||||
],
|
||||
day: ["day.invalid"],
|
||||
demo: ["demo.readOnly"],
|
||||
documents: [
|
||||
@@ -193,6 +200,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"environment.no_home",
|
||||
"environment.range_too_large",
|
||||
"environment.travel.not_found",
|
||||
"environment.travel_suggestion.not_found",
|
||||
],
|
||||
export: ["export.selection.empty", "export.selection.unknown_leaf"],
|
||||
familyHistory: ["familyHistory.invalid"],
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
moduleDisabledResponse,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -76,7 +75,6 @@ const responses = (
|
||||
...moduleDisabledResponse,
|
||||
...extra,
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
});
|
||||
|
||||
const errorOnly = (description: string) => ({
|
||||
@@ -165,27 +163,33 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Integrations"],
|
||||
summary: "Dawarich home place and country (v1.43)",
|
||||
description:
|
||||
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.",
|
||||
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have; 409 `dawarich.not_connected`; 502 `dawarich.unreachable` when the place cannot be checked. A new home country re-judges the stored travel days at once; a new home place fetches the stored days again in the background (up to a year).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: dawarichSettings } },
|
||||
},
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The settings after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
homePlaceId: z.number().int().nullable(),
|
||||
homeCountry: z.string().nullable(),
|
||||
}),
|
||||
"DawarichSettingsEnvelope",
|
||||
),
|
||||
responses: responses(
|
||||
{
|
||||
"200": {
|
||||
description: "The settings after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
homePlaceId: z.number().int().nullable(),
|
||||
homeCountry: z.string().nullable(),
|
||||
}),
|
||||
"DawarichSettingsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
"409": errorOnly("`dawarich.not_connected`."),
|
||||
"502": errorOnly("`dawarich.unreachable`."),
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/test": {
|
||||
@@ -193,24 +197,29 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Integrations"],
|
||||
summary: "Probe the Dawarich connection (v1.43)",
|
||||
description:
|
||||
"Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those of the other `/test` routes.",
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The probe reached the instance.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
ok: z.literal(true),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
latencyMs: z.number().int(),
|
||||
}),
|
||||
"DawarichProbeEnvelope",
|
||||
),
|
||||
"Answers the shared integration probe shape. A failed probe is a 502 whose `errorCode` is one of the shared probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `url_not_public`, `connection_failed`); 422 `not_configured` without a connection; 429 `rate_limited_self` past five probes a minute.",
|
||||
responses: responses(
|
||||
{
|
||||
"200": {
|
||||
description: "The probe reached the instance.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
ok: z.literal(true),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
latencyMs: z.number().int(),
|
||||
}),
|
||||
"DawarichProbeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
"502": errorOnly("A probe class; see the description."),
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/sync": {
|
||||
@@ -218,7 +227,7 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Integrations"],
|
||||
summary: "Fetch Dawarich daily values again (v1.43)",
|
||||
description:
|
||||
"From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
|
||||
"From `fromDate` (inside the 365 days ending yesterday; without it the nightly window of seven days). `enqueued` is false when a run for this account is already queued or no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: dawarichSyncSchema } },
|
||||
@@ -246,7 +255,7 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Integrations"],
|
||||
summary: "Disconnect Dawarich (v1.43)",
|
||||
description:
|
||||
"Forgets the connection; with `deleteData: true` the stored days go too.",
|
||||
"Forgets the connection; with `deleteData: true` the stored days go too. No body is `{ deleteData: false }`. Disconnecting twice is harmless.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: dawarichDisconnectSchema } },
|
||||
@@ -273,6 +282,8 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
get: {
|
||||
tags: ["Environment"],
|
||||
summary: "Suggested trips from Dawarich travel days (v1.43)",
|
||||
description:
|
||||
"Runs of at least two consecutive travel days in one country, inside the last year, that no stored trip overlaps and the person has not dismissed; newest first. Empty without a Dawarich connection.",
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The suggestions.",
|
||||
|
||||
@@ -1046,6 +1046,7 @@ const integrationEnvelopeEntry = z
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
])
|
||||
.describe("Which provider this entry describes."),
|
||||
state: integrationLedgerState,
|
||||
@@ -1121,18 +1122,18 @@ const integrationStatusEnvelope = z
|
||||
metricFreshnessDegraded: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for all eight providers: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete.",
|
||||
"The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for every provider: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete.",
|
||||
),
|
||||
integrations: z
|
||||
.array(integrationEnvelopeEntry)
|
||||
.describe(
|
||||
"All eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.",
|
||||
"All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "IntegrationStatusEnvelope",
|
||||
description:
|
||||
"The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of eight round-trips or this one; the per-provider routes remain for callers that want a single provider.",
|
||||
"The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of nine round-trips or this one; the per-provider routes remain for callers that want a single provider.",
|
||||
});
|
||||
|
||||
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -2551,10 +2552,10 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Integrations"],
|
||||
summary: "Read every integration's status in one call",
|
||||
description:
|
||||
"The consolidated envelope behind the integrations settings surface: all eight providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over eight per-provider round-trips. Pure read. Auth via cookie or Bearer.",
|
||||
"The consolidated envelope behind the integrations settings surface: all nine providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over nine per-provider round-trips. Pure read. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "All eight provider entries plus the alert threshold.",
|
||||
description: "All nine provider entries plus the alert threshold.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
|
||||
@@ -13,4 +13,10 @@ export const environmentKeys = {
|
||||
/** Geocoding search results for a query string (cached per query). */
|
||||
environmentGeocode: (query: string) =>
|
||||
["environment", "geocode", query] as const,
|
||||
/**
|
||||
* v1.43 — trips suggested from Dawarich travel days. Under the root prefix,
|
||||
* so adding a trip (which covers a suggestion) refreshes the list too.
|
||||
*/
|
||||
environmentTravelSuggestions: () =>
|
||||
["environment", "travel-suggestions"] as const,
|
||||
};
|
||||
@@ -63,6 +63,11 @@ export const integrationKeys = {
|
||||
strava: () => ["strava"] as const,
|
||||
stravaStatus: () => ["strava", "status"] as const,
|
||||
|
||||
// v1.43 — the Dawarich movement context card: its status (home place and
|
||||
// country, stored days) and the instance's places, read live per query.
|
||||
dawarichStatus: () => ["dawarich", "status"] as const,
|
||||
dawarichPlaces: (query: string) => ["dawarich", "places", query] as const,
|
||||
|
||||
integrationsStatus: () => ["integrations", "status"] as const,
|
||||
// Apple Health live-sync freshness comes from the existing HealthKit config
|
||||
// endpoint. It is intentionally separate from the OAuth integration ledger:
|
||||
|
||||
@@ -113,6 +113,8 @@ export interface BackupSummary {
|
||||
environmentContexts: number;
|
||||
/** Declared stretches spent away from home. */
|
||||
environmentTravelLocations: number;
|
||||
/** Local days of Dawarich movement values. */
|
||||
dawarichDays: number;
|
||||
/** ECG strips, with their rhythm verdict and their trace. */
|
||||
ecgRecordings: number;
|
||||
}
|
||||
@@ -209,6 +211,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
userAchievements: payload.userAchievements.length,
|
||||
environmentContexts: payload.environmentContexts.length,
|
||||
environmentTravelLocations: payload.environmentTravelLocations.length,
|
||||
dawarichDays: payload.dawarichDays.length,
|
||||
ecgRecordings: payload.ecgRecordings.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1651,6 +1651,37 @@ const environmentTravelLocationBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* v1.43 — one local day of Dawarich movement values. Daily values only; the
|
||||
* country rides readable (portable) or sealed (disaster recovery).
|
||||
*/
|
||||
const dawarichDayBackupSchema = z
|
||||
.object({
|
||||
date: environmentDayKey,
|
||||
totalDistanceM: z.number().int().nonnegative().nullable().optional(),
|
||||
drivingDistanceM: z.number().int().nonnegative().nullable().optional(),
|
||||
trainDistanceM: z.number().int().nonnegative().nullable().optional(),
|
||||
placesVisited: z.number().int().nonnegative().nullable().optional(),
|
||||
awayFromHomeMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(25 * 60)
|
||||
.nullable()
|
||||
.optional(),
|
||||
countryCode: z
|
||||
.string()
|
||||
.regex(/^[A-Z]{2}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
countryCodeEncrypted: base64BytesSchema.nullable().optional(),
|
||||
isTravelDay: z.boolean().nullable().optional(),
|
||||
fetchedAt: isoDateTime.optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A turn's stored tables, bounded at restore. The live path never stores more
|
||||
* than `RESULTS_MAX_BYTES` of table JSON per turn (`fitResultsToStorage`), so
|
||||
@@ -2293,6 +2324,9 @@ export const backupPayloadSchema = z
|
||||
environmentTravelLocations: z
|
||||
.array(environmentTravelLocationBackupSchema)
|
||||
.default([]),
|
||||
// v1.43 — the Dawarich movement days, restored with the environment
|
||||
// section. A file written before them carries no key.
|
||||
dawarichDays: z.array(dawarichDayBackupSchema).default([]),
|
||||
// The ECG strips. Defaulted for the same reason as the sections above: a
|
||||
// file written before the recordings travelled carries no key, and an
|
||||
// account whose watch has never taken one writes [].
|
||||
|
||||
@@ -53,6 +53,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { sealDayCountry } from "@/lib/dawarich/connection";
|
||||
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
@@ -342,6 +343,7 @@ const COUNT_BACK: Record<
|
||||
p.environmentContext.count({ where: { userId } }),
|
||||
EnvironmentTravelLocation: (p, userId) =>
|
||||
p.environmentTravelLocation.count({ where: { userId } }),
|
||||
DawarichDay: (p, userId) => p.dawarichDay.count({ where: { userId } }),
|
||||
EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }),
|
||||
// No own `userId` column — reached through the drug, like the schedules.
|
||||
MedicationEfficacyTarget: (p, userId) =>
|
||||
@@ -1514,6 +1516,21 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
// a restore rounds anything finer, so finer values would not round-trip.
|
||||
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
|
||||
// the writers store it; it is the only copy since v1.43.
|
||||
// v1.43 — one Dawarich day abroad, with its sealed country.
|
||||
await prisma.dawarichDay.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
date: "2026-06-15",
|
||||
totalDistanceM: 48_300,
|
||||
drivingDistanceM: 12_000,
|
||||
trainDistanceM: 30_500,
|
||||
placesVisited: 4,
|
||||
awayFromHomeMinutes: 1440,
|
||||
countryCodeEncrypted: sealDayCountry("ES"),
|
||||
isTravelDay: true,
|
||||
fetchedAt: new Date("2026-06-16T03:20:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
|
||||
Reference in new issue
Block a user