mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge feat/v125-environmental into release/v1.25.0
Environmental context module (migration 0217).
This commit is contained in:
commit
a1b4f0d00e
45 files changed
+2725
-64
No files matched your search
@@ -234,3 +234,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
|
||||
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
|
||||
# IP_GEO_LOOKUP_DISABLED="1"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Environmental-context module (Open-Meteo) -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# The opt-in Environment module fetches daily weather / daylight for the user's
|
||||
# coarse home (or a travel override) from Open-Meteo. The hosted endpoints are
|
||||
# keyless and free for non-commercial use; data is CC BY 4.0. Both URLs are
|
||||
# optional -- unset uses the hosted defaults below. A privacy-maximalist or
|
||||
# commercial self-hoster can run their own Open-Meteo instance and point these
|
||||
# at it (a private/LAN host is allowed -- the fetch is server-side and does NOT
|
||||
# force the public-host guard, so an internal instance is not blocked).
|
||||
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
|
||||
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
|
||||
@@ -9746,6 +9746,8 @@ components:
|
||||
type: boolean
|
||||
doctorReport:
|
||||
type: boolean
|
||||
environment:
|
||||
type: boolean
|
||||
mcp:
|
||||
type: boolean
|
||||
additionalProperties: false
|
||||
@@ -16786,6 +16788,9 @@ components:
|
||||
doctorReport:
|
||||
type: boolean
|
||||
description: Whether the "doctorReport" module is enabled for this account.
|
||||
environment:
|
||||
type: boolean
|
||||
description: Whether the "environment" module is enabled for this account.
|
||||
mcp:
|
||||
type: boolean
|
||||
description: Whether the "mcp" module is enabled for this account.
|
||||
@@ -16803,6 +16808,7 @@ components:
|
||||
- insights
|
||||
- medications
|
||||
- doctorReport
|
||||
- environment
|
||||
- mcp
|
||||
additionalProperties: false
|
||||
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
|
||||
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Anamnese",
|
||||
"subtitle": "Verwalte deine Allergien und deine Familienanamnese.",
|
||||
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Umwelt",
|
||||
"subtitle": "Wetter, Tageslicht und Standort-Kontext.",
|
||||
"disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.",
|
||||
"searchPlaceholder": "Stadt suchen",
|
||||
"search": "Suchen",
|
||||
"searchError": "Ort konnte nicht gesucht werden.",
|
||||
"use": "Verwenden",
|
||||
"select": "Auswählen",
|
||||
"saveError": "Änderung konnte nicht gespeichert werden.",
|
||||
"homeSaved": "Heimatort gespeichert.",
|
||||
"travelAdded": "Reise-Ausnahme hinzugefügt.",
|
||||
"travelRemoved": "Reise-Ausnahme entfernt.",
|
||||
"backfillQueued": "Nacherfassung eingeplant.",
|
||||
"backfillError": "Nacherfassung konnte nicht gestartet werden.",
|
||||
"startDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"storedDays": "{count} Tage Wetter gespeichert.",
|
||||
"home": {
|
||||
"title": "Heimatort",
|
||||
"description": "Ein grober Ort auf Stadtebene – niemals ein genauer Standortverlauf. Wetter und Tageslicht werden für diesen Ort an allen Tagen ohne Reise-Ausnahme abgerufen.",
|
||||
"current": "Aktuell: {label}",
|
||||
"none": "Noch kein Heimatort festgelegt."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Reise-Ausnahmen",
|
||||
"description": "Für einen Zeitraum, den du woanders verbracht hast, den Ort festlegen, damit das Wetter zu deinem tatsächlichen Aufenthalt passt.",
|
||||
"none": "Keine Reise-Ausnahmen.",
|
||||
"add": "{label} hinzufügen",
|
||||
"remove": "Reise-Ausnahme entfernen"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
"description": "Wetter für einen vergangenen Zeitraum abrufen und speichern, damit ältere Tage in die Korrelationen einfließen.",
|
||||
"run": "Nacherfassen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "Externer Assistentenzugriff (MCP)",
|
||||
"description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Umwelt",
|
||||
"description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "Der Eintrag konnte nicht gespeichert werden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Tagestemperatur",
|
||||
"tempMin": "Nachttiefstwert",
|
||||
"sunshine": "Sonnenschein",
|
||||
"daylight": "Tageslicht",
|
||||
"precip": "Niederschlag",
|
||||
"pressureMean": "Luftdruck",
|
||||
"pressureDelta": "Druckschwankung"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Medical history",
|
||||
"subtitle": "Manage your allergies and family history.",
|
||||
"description": "Structured allergies and family history records."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Environment",
|
||||
"subtitle": "Weather, daylight, and location context.",
|
||||
"disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.",
|
||||
"searchPlaceholder": "Search for a city",
|
||||
"search": "Search",
|
||||
"searchError": "Could not search for that place.",
|
||||
"use": "Use",
|
||||
"select": "Select",
|
||||
"saveError": "Could not save your change.",
|
||||
"homeSaved": "Home location saved.",
|
||||
"travelAdded": "Travel override added.",
|
||||
"travelRemoved": "Travel override removed.",
|
||||
"backfillQueued": "Backfill queued.",
|
||||
"backfillError": "Could not start the backfill.",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"storedDays": "{count} days of weather stored.",
|
||||
"home": {
|
||||
"title": "Home location",
|
||||
"description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no travel override applies.",
|
||||
"current": "Current: {label}",
|
||||
"none": "No home location set yet."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Travel overrides",
|
||||
"description": "For a date range you spent elsewhere, set the location so the weather matches where you actually were.",
|
||||
"none": "No travel overrides.",
|
||||
"add": "Add {label}",
|
||||
"remove": "Remove travel override"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
"description": "Fetch and store weather for a past date range so older days can join the correlations.",
|
||||
"run": "Backfill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "External assistant access (MCP)",
|
||||
"description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environment",
|
||||
"description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "Couldn't save the entry."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Daily temperature",
|
||||
"tempMin": "Overnight low",
|
||||
"sunshine": "Sunshine",
|
||||
"daylight": "Daylight",
|
||||
"precip": "Precipitation",
|
||||
"pressureMean": "Barometric pressure",
|
||||
"pressureDelta": "Pressure swing"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Historial médico",
|
||||
"subtitle": "Gestiona tus alergias y tus antecedentes familiares.",
|
||||
"description": "Registros estructurados de alergias y antecedentes familiares."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Entorno",
|
||||
"subtitle": "Contexto de tiempo, luz diurna y ubicación.",
|
||||
"disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.",
|
||||
"searchPlaceholder": "Buscar una ciudad",
|
||||
"search": "Buscar",
|
||||
"searchError": "No se pudo buscar ese lugar.",
|
||||
"use": "Usar",
|
||||
"select": "Seleccionar",
|
||||
"saveError": "No se pudo guardar el cambio.",
|
||||
"homeSaved": "Ubicación principal guardada.",
|
||||
"travelAdded": "Excepción de viaje añadida.",
|
||||
"travelRemoved": "Excepción de viaje eliminada.",
|
||||
"backfillQueued": "Relleno programado.",
|
||||
"backfillError": "No se pudo iniciar el relleno.",
|
||||
"startDate": "Fecha de inicio",
|
||||
"endDate": "Fecha de fin",
|
||||
"storedDays": "{count} días de tiempo almacenados.",
|
||||
"home": {
|
||||
"title": "Ubicación principal",
|
||||
"description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y las horas de luz se obtienen para este lugar los días sin excepción de viaje.",
|
||||
"current": "Actual: {label}",
|
||||
"none": "Aún no hay ubicación principal."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Excepciones de viaje",
|
||||
"description": "Para un rango de fechas que pasaste en otro sitio, define la ubicación para que el tiempo coincida con dónde estuviste realmente.",
|
||||
"none": "Sin excepciones de viaje.",
|
||||
"add": "Añadir {label}",
|
||||
"remove": "Eliminar excepción de viaje"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
"description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días anteriores entren en las correlaciones.",
|
||||
"run": "Rellenar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "Acceso de asistente externo (MCP)",
|
||||
"description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Entorno",
|
||||
"description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "No se ha podido guardar la entrada."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Temperatura diaria",
|
||||
"tempMin": "Mínima nocturna",
|
||||
"sunshine": "Sol",
|
||||
"daylight": "Luz diurna",
|
||||
"precip": "Precipitación",
|
||||
"pressureMean": "Presión atmosférica",
|
||||
"pressureDelta": "Variación de presión"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Antécédents médicaux",
|
||||
"subtitle": "Gérez vos allergies et vos antécédents familiaux.",
|
||||
"description": "Dossiers structurés des allergies et des antécédents familiaux."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Environnement",
|
||||
"subtitle": "Contexte météo, lumière du jour et lieu.",
|
||||
"disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.",
|
||||
"searchPlaceholder": "Rechercher une ville",
|
||||
"search": "Rechercher",
|
||||
"searchError": "Impossible de rechercher ce lieu.",
|
||||
"use": "Utiliser",
|
||||
"select": "Sélectionner",
|
||||
"saveError": "Impossible d'enregistrer la modification.",
|
||||
"homeSaved": "Lieu de référence enregistré.",
|
||||
"travelAdded": "Exception de voyage ajoutée.",
|
||||
"travelRemoved": "Exception de voyage supprimée.",
|
||||
"backfillQueued": "Récupération planifiée.",
|
||||
"backfillError": "Impossible de lancer la récupération.",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"storedDays": "{count} jours de météo enregistrés.",
|
||||
"home": {
|
||||
"title": "Lieu de référence",
|
||||
"description": "Un lieu approximatif à l'échelle de la ville, jamais un suivi précis. La météo et la durée du jour sont récupérées pour ce lieu les jours sans exception de voyage.",
|
||||
"current": "Actuel : {label}",
|
||||
"none": "Aucun lieu de référence défini."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Exceptions de voyage",
|
||||
"description": "Pour une période passée ailleurs, définissez le lieu afin que la météo corresponde à l'endroit où vous étiez réellement.",
|
||||
"none": "Aucune exception de voyage.",
|
||||
"add": "Ajouter {label}",
|
||||
"remove": "Supprimer l'exception de voyage"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
"description": "Récupérez et enregistrez la météo d'une période passée pour que les jours plus anciens entrent dans les corrélations.",
|
||||
"run": "Récupérer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "Accès assistant externe (MCP)",
|
||||
"description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environnement",
|
||||
"description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "Impossible d'enregistrer l'entrée."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Température quotidienne",
|
||||
"tempMin": "Minimale nocturne",
|
||||
"sunshine": "Ensoleillement",
|
||||
"daylight": "Durée du jour",
|
||||
"precip": "Précipitations",
|
||||
"pressureMean": "Pression atmosphérique",
|
||||
"pressureDelta": "Variation de pression"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Anamnesi",
|
||||
"subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.",
|
||||
"description": "Record strutturati di allergie e anamnesi familiare."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Ambiente",
|
||||
"subtitle": "Contesto meteo, luce del giorno e posizione.",
|
||||
"disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.",
|
||||
"searchPlaceholder": "Cerca una città",
|
||||
"search": "Cerca",
|
||||
"searchError": "Impossibile cercare quel luogo.",
|
||||
"use": "Usa",
|
||||
"select": "Seleziona",
|
||||
"saveError": "Impossibile salvare la modifica.",
|
||||
"homeSaved": "Posizione principale salvata.",
|
||||
"travelAdded": "Eccezione di viaggio aggiunta.",
|
||||
"travelRemoved": "Eccezione di viaggio rimossa.",
|
||||
"backfillQueued": "Recupero pianificato.",
|
||||
"backfillError": "Impossibile avviare il recupero.",
|
||||
"startDate": "Data di inizio",
|
||||
"endDate": "Data di fine",
|
||||
"storedDays": "{count} giorni di meteo salvati.",
|
||||
"home": {
|
||||
"title": "Posizione principale",
|
||||
"description": "Una posizione approssimativa a livello di città, mai un tracciamento preciso. Il meteo e le ore di luce vengono recuperati per questo luogo nei giorni senza eccezioni di viaggio.",
|
||||
"current": "Attuale: {label}",
|
||||
"none": "Nessuna posizione principale impostata."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Eccezioni di viaggio",
|
||||
"description": "Per un intervallo di date trascorso altrove, imposta la posizione in modo che il meteo corrisponda a dove eri davvero.",
|
||||
"none": "Nessuna eccezione di viaggio.",
|
||||
"add": "Aggiungi {label}",
|
||||
"remove": "Rimuovi eccezione di viaggio"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
"description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni.",
|
||||
"run": "Recupera"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "Accesso assistente esterno (MCP)",
|
||||
"description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Ambiente",
|
||||
"description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "Impossibile salvare la voce."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Temperatura giornaliera",
|
||||
"tempMin": "Minima notturna",
|
||||
"sunshine": "Sole",
|
||||
"daylight": "Ore di luce",
|
||||
"precip": "Precipitazioni",
|
||||
"pressureMean": "Pressione atmosferica",
|
||||
"pressureDelta": "Variazione di pressione"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4279,6 +4279,43 @@
|
||||
"title": "Wywiad medyczny",
|
||||
"subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.",
|
||||
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Środowisko",
|
||||
"subtitle": "Kontekst pogody, światła dziennego i lokalizacji.",
|
||||
"disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.",
|
||||
"searchPlaceholder": "Szukaj miasta",
|
||||
"search": "Szukaj",
|
||||
"searchError": "Nie udało się wyszukać tego miejsca.",
|
||||
"use": "Użyj",
|
||||
"select": "Wybierz",
|
||||
"saveError": "Nie udało się zapisać zmiany.",
|
||||
"homeSaved": "Zapisano lokalizację domową.",
|
||||
"travelAdded": "Dodano wyjątek podróży.",
|
||||
"travelRemoved": "Usunięto wyjątek podróży.",
|
||||
"backfillQueued": "Zaplanowano uzupełnianie.",
|
||||
"backfillError": "Nie udało się rozpocząć uzupełniania.",
|
||||
"startDate": "Data początkowa",
|
||||
"endDate": "Data końcowa",
|
||||
"storedDays": "Zapisano dni pogody: {count}.",
|
||||
"home": {
|
||||
"title": "Lokalizacja domowa",
|
||||
"description": "Przybliżona lokalizacja na poziomie miasta — nigdy dokładny ślad. Pogoda i długość dnia są pobierane dla tego miejsca w dni bez wyjątku podróży.",
|
||||
"current": "Obecnie: {label}",
|
||||
"none": "Nie ustawiono jeszcze lokalizacji domowej."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Wyjątki podróży",
|
||||
"description": "Dla zakresu dat spędzonego gdzie indziej ustaw lokalizację, aby pogoda odpowiadała temu, gdzie naprawdę byłeś.",
|
||||
"none": "Brak wyjątków podróży.",
|
||||
"add": "Dodaj {label}",
|
||||
"remove": "Usuń wyjątek podróży"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji.",
|
||||
"run": "Uzupełnij"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7220,6 +7257,10 @@
|
||||
"mcp": {
|
||||
"label": "Dostęp asystenta zewnętrznego (MCP)",
|
||||
"description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Środowisko",
|
||||
"description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7671,5 +7712,16 @@
|
||||
"saveError": "Nie udało się zapisać wpisu."
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"fields": {
|
||||
"tempMean": "Temperatura dzienna",
|
||||
"tempMin": "Minimum nocne",
|
||||
"sunshine": "Nasłonecznienie",
|
||||
"daylight": "Światło dzienne",
|
||||
"precip": "Opady",
|
||||
"pressureMean": "Ciśnienie atmosferyczne",
|
||||
"pressureDelta": "Wahanie ciśnienia"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
-- v1.25 (W-ENV) — environmental-context module (opt-in, server-side v1).
|
||||
--
|
||||
-- Adds the per-day weather/daylight observation store and the manual travel
|
||||
-- overrides, plus the coarse home-location columns on `users`. The module is
|
||||
-- opt-in (default OFF via the module registry); with it off no row is written
|
||||
-- and no outbound weather fetch runs.
|
||||
--
|
||||
-- Location granularity is coarse (city level / rounded lat-lon) by design — the
|
||||
-- weather model only needs ~10–25 km and HealthLog never stores a GPS track.
|
||||
--
|
||||
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
|
||||
-- `DO $$`) so reruns are safe on prod.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "environment_travel_locations";
|
||||
-- DROP TABLE IF EXISTS "environment_contexts";
|
||||
-- DROP TYPE IF EXISTS "environment_location_source";
|
||||
-- ALTER TABLE "users"
|
||||
-- DROP COLUMN IF EXISTS "home_lat",
|
||||
-- DROP COLUMN IF EXISTS "home_lon",
|
||||
-- DROP COLUMN IF EXISTS "home_label",
|
||||
-- DROP COLUMN IF EXISTS "home_timezone";
|
||||
-- A roll-back drops the environment store wholesale — no other domain depends
|
||||
-- on it.
|
||||
|
||||
-- AlterTable: coarse home location on the user (all nullable).
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lat" DOUBLE PRECISION;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lon" DOUBLE PRECISION;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_label" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_timezone" TEXT;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "environment_location_source" AS ENUM ('HOME', 'TRAVEL');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "environment_contexts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"lat" DOUBLE PRECISION NOT NULL,
|
||||
"lon" DOUBLE PRECISION NOT NULL,
|
||||
"location_label" TEXT NOT NULL,
|
||||
"source" "environment_location_source" NOT NULL,
|
||||
"temp_min" DOUBLE PRECISION,
|
||||
"temp_max" DOUBLE PRECISION,
|
||||
"temp_mean" DOUBLE PRECISION,
|
||||
"apparent_mean" DOUBLE PRECISION,
|
||||
"sunshine_sec" INTEGER,
|
||||
"daylight_sec" INTEGER,
|
||||
"precip_sum" DOUBLE PRECISION,
|
||||
"pressure_mean" DOUBLE PRECISION,
|
||||
"pressure_delta" DOUBLE PRECISION,
|
||||
"humidity_mean" DOUBLE PRECISION,
|
||||
"cloud_mean" DOUBLE PRECISION,
|
||||
"weather_code" INTEGER,
|
||||
"fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "environment_contexts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "environment_contexts_user_id_date_key" ON "environment_contexts"("user_id", "date");
|
||||
CREATE INDEX IF NOT EXISTS "environment_contexts_user_id_date_idx" ON "environment_contexts"("user_id", "date");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "environment_travel_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"end_date" TEXT NOT NULL,
|
||||
"lat" DOUBLE PRECISION NOT NULL,
|
||||
"lon" DOUBLE PRECISION NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "environment_travel_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "environment_travel_locations_user_id_start_date_end_date_idx" ON "environment_travel_locations"("user_id", "start_date", "end_date");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "environment_contexts" ADD CONSTRAINT "environment_contexts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "environment_travel_locations" ADD CONSTRAINT "environment_travel_locations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
+162
-50
@@ -51,6 +51,19 @@ model User {
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe
|
||||
timezone String @default("Europe/Berlin")
|
||||
|
||||
// v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse,
|
||||
// user-set city (rounded lat/lon, ~city granularity — never a GPS track) the
|
||||
// nightly weather fetch resolves against when no travel override covers the
|
||||
// day. City-level granularity is the privacy floor: the weather model only
|
||||
// needs ~10–25 km. All nullable — an account without the module enabled (or
|
||||
// without a home set) simply has no environment rows. `homeTimezone` anchors
|
||||
// the day-key an observation is stored under (it can differ from the display
|
||||
// `timezone` above for an account that tracks weather for another locale).
|
||||
homeLat Float? @map("home_lat")
|
||||
homeLon Float? @map("home_lon")
|
||||
homeLabel String? @map("home_label")
|
||||
homeTimezone String? @map("home_timezone")
|
||||
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||||
@@ -727,6 +740,12 @@ model User {
|
||||
// sensitive free-text fields are encrypted at rest.
|
||||
allergies Allergy[]
|
||||
familyHistory FamilyHistoryEntry[]
|
||||
// v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext`
|
||||
// is the per-day weather/daylight observation keyed to the resolved location;
|
||||
// `EnvironmentTravelLocation` holds the manual date-range travel overrides
|
||||
// that win over the home location for the days they cover.
|
||||
environmentContexts EnvironmentContext[]
|
||||
environmentTravelLocations EnvironmentTravelLocation[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
@@ -782,6 +801,99 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ─── v1.25 (W-ENV) Environmental-context module ──────────────────────────────
|
||||
|
||||
/// How the location for an environment observation was resolved. HOME = the
|
||||
/// user's configured home location; TRAVEL = a manual date-range override.
|
||||
/// (iOS coarse device location is a v2 follow-up and not represented here.)
|
||||
enum EnvironmentLocationSource {
|
||||
HOME
|
||||
TRAVEL
|
||||
}
|
||||
|
||||
/// A per-day environmental observation (daily weather / daylight) for the
|
||||
/// location resolved for that day. Day-keyed exactly like the rollup tier:
|
||||
/// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a
|
||||
/// late-evening reading does not mis-bucket under UTC. One row per (user, day);
|
||||
/// a re-fetch upserts (the archive feed settles over a few days). All weather
|
||||
/// fields are nullable so a partial feed still stores what it has. The location
|
||||
/// stored here is coarse (city granularity) — never a GPS track.
|
||||
model EnvironmentContext {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// YYYY-MM-DD anchored to the resolved location's timezone.
|
||||
date String
|
||||
|
||||
/// Coarse resolved location for the day (rounded lat/lon + label + the source
|
||||
/// that won precedence).
|
||||
lat Float
|
||||
lon Float
|
||||
locationLabel String @map("location_label")
|
||||
source EnvironmentLocationSource
|
||||
|
||||
/// Daily temperature (°C). Min doubles as the overnight-low proxy.
|
||||
tempMin Float? @map("temp_min")
|
||||
tempMax Float? @map("temp_max")
|
||||
tempMean Float? @map("temp_mean")
|
||||
/// Daily apparent ("feels-like") mean (°C).
|
||||
apparentMean Float? @map("apparent_mean")
|
||||
|
||||
/// Sunshine + daylight, stored in seconds (the feed's native unit).
|
||||
sunshineSec Int? @map("sunshine_sec")
|
||||
daylightSec Int? @map("daylight_sec")
|
||||
|
||||
/// Precipitation total (mm) over the local day.
|
||||
precipSum Float? @map("precip_sum")
|
||||
|
||||
/// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday
|
||||
/// delta (max−min, hPa) — the derived feature for the headache/symptom overlay.
|
||||
pressureMean Float? @map("pressure_mean")
|
||||
pressureDelta Float? @map("pressure_delta")
|
||||
|
||||
/// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed.
|
||||
humidityMean Float? @map("humidity_mean")
|
||||
cloudMean Float? @map("cloud_mean")
|
||||
|
||||
/// WMO weather code for the day (descriptive condition lookup).
|
||||
weatherCode Int? @map("weather_code")
|
||||
|
||||
/// When the row was last fetched/refreshed from the upstream feed.
|
||||
fetchedAt DateTime @default(now()) @map("fetched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
@@map("environment_contexts")
|
||||
}
|
||||
|
||||
/// A manual travel override: for any day in `[startDate, endDate]` the weather
|
||||
/// fetch resolves against this location instead of the home location. Declared,
|
||||
/// not inferred — a stated trip dominates the home fallback so a winter week in
|
||||
/// a sunny locale is not mis-attributed to the home city. Coarse location only.
|
||||
model EnvironmentTravelLocation {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the
|
||||
/// day-key the override matches against).
|
||||
startDate String @map("start_date")
|
||||
endDate String @map("end_date")
|
||||
|
||||
lat Float
|
||||
lon Float
|
||||
label String
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId, startDate, endDate])
|
||||
@@map("environment_travel_locations")
|
||||
}
|
||||
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
@@ -1158,37 +1270,37 @@ enum AssessmentInstrument {
|
||||
/// administration (linked by `externalId = assessment:<id>`) so the score rides
|
||||
/// the existing chart / rollup infra — the trend, never the item content.
|
||||
model MentalHealthAssessment {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
instrument AssessmentInstrument
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
instrument AssessmentInstrument
|
||||
/// The validated translation actually presented (officially-validated wording
|
||||
/// per locale, English fallback otherwise — recorded for the audit trail).
|
||||
locale String
|
||||
locale String
|
||||
/// Instrument variant identifier (e.g. "standard"); future-proofs revisions.
|
||||
version String @default("standard")
|
||||
version String @default("standard")
|
||||
/// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }.
|
||||
/// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS +
|
||||
/// the rotation script. The item-9 raw value lives ONLY here.
|
||||
responsesEncrypted Bytes @map("responses_encrypted")
|
||||
responsesEncrypted Bytes @map("responses_encrypted")
|
||||
/// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads.
|
||||
totalScore Int @map("total_score")
|
||||
totalScore Int @map("total_score")
|
||||
/// Derived severity band label (server-authoritative; descriptive, never a diagnosis).
|
||||
severityBand String @map("severity_band")
|
||||
severityBand String @map("severity_band")
|
||||
/// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so
|
||||
/// the history view can re-surface crisis resources without decrypting.
|
||||
item9Flagged Boolean @default(false) @map("item9_flagged")
|
||||
item9Flagged Boolean @default(false) @map("item9_flagged")
|
||||
/// Audit stamp: the supportive crisis-resource signpost was shown to the user.
|
||||
/// Never triggers any third-party alert — private, on-device-only signposting.
|
||||
crisisShownAt DateTime? @map("crisis_shown_at")
|
||||
crisisShownAt DateTime? @map("crisis_shown_at")
|
||||
/// Recall-window anchor (when the screener was taken).
|
||||
takenAt DateTime @map("taken_at")
|
||||
tz String?
|
||||
source String @default("WEB")
|
||||
externalId String? @map("external_id")
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
takenAt DateTime @map("taken_at")
|
||||
tz String?
|
||||
source String @default("WEB")
|
||||
externalId String? @map("external_id")
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -2537,26 +2649,26 @@ model MedicationInventoryItem {
|
||||
/// - (userId, occurredAt DESC) — Coach snapshot aggregation read
|
||||
/// path; correlation across medications is per-user, not per-drug.
|
||||
model MedicationSideEffect {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||
category MedicationSideEffectCategory
|
||||
entry MedicationSideEffectEntry
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||
category MedicationSideEffectCategory
|
||||
entry MedicationSideEffectEntry
|
||||
/// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe.
|
||||
/// Constrained to [1, 5] at the application layer (Zod) and the DB
|
||||
/// (CHECK constraint via migration). Not an enum to keep cardinality
|
||||
/// trivially numeric for chart aggregation.
|
||||
severity Int
|
||||
severity Int
|
||||
/// Optional free-text note, hard-capped to 280 chars at the
|
||||
/// application layer. The DB column is unbounded TEXT for
|
||||
/// consistency with sibling "notes" columns on this schema.
|
||||
notes String?
|
||||
notes String?
|
||||
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
|
||||
/// stays for read-fallback until the backfill reports zero remaining
|
||||
/// rows; the plaintext drop is DEFERRED to a later release.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
@@ -5254,28 +5366,28 @@ enum AllergyStatus {
|
||||
/// and notes are encrypted at rest. Patient-reported; the export carries a
|
||||
/// "not a clinical diagnosis" guard rail.
|
||||
model Allergy {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts".
|
||||
/// Queryable plaintext label (the medication.name / illness.label
|
||||
/// precedent); rides FHIR `code.text`.
|
||||
substance String
|
||||
category AllergyCategory @default(OTHER)
|
||||
type AllergyType @default(ALLERGY)
|
||||
substance String
|
||||
category AllergyCategory @default(OTHER)
|
||||
type AllergyType @default(ALLERGY)
|
||||
/// Worst observed reaction severity. NULL = not assessed.
|
||||
severity AllergySeverity?
|
||||
status AllergyStatus @default(ACTIVE)
|
||||
severity AllergySeverity?
|
||||
status AllergyStatus @default(ACTIVE)
|
||||
/// When the allergy was first noticed (UTC instant). NULL = unknown.
|
||||
onsetAt DateTime? @map("onset_at")
|
||||
onsetAt DateTime? @map("onset_at")
|
||||
/// AES-256-GCM ciphertext of the free-text reaction/manifestation
|
||||
/// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec.
|
||||
reactionEncrypted Bytes? @map("reaction_encrypted")
|
||||
reactionEncrypted Bytes? @map("reaction_encrypted")
|
||||
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -5312,20 +5424,20 @@ enum FamilyRelationship {
|
||||
/// code); `ageAtOnset` is the relative's age when the condition began. The
|
||||
/// optional free-text note is encrypted at rest. Patient-reported.
|
||||
model FamilyHistoryEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
relationship FamilyRelationship
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
relationship FamilyRelationship
|
||||
/// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer".
|
||||
/// Queryable plaintext label; rides FHIR `condition.code.text`.
|
||||
condition String
|
||||
condition String
|
||||
/// The relative's age (years) when the condition began. NULL = unknown.
|
||||
ageAtOnset Int? @map("age_at_onset")
|
||||
ageAtOnset Int? @map("age_at_onset")
|
||||
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* `POST /api/environment/backfill` — fetch + store past days on demand.
|
||||
*
|
||||
* Enqueues a per-user fetch over the requested [startDate, endDate] so the user
|
||||
* can populate history rather than waiting for the rolling nightly lookback.
|
||||
* The span is capped (the worker re-checks the cap too). Requires a home
|
||||
* location (otherwise there is nothing to resolve). Module-gated + rate-limited;
|
||||
* `userId` is narrowed from auth.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { environmentBackfillSchema } from "@/lib/validations/environment";
|
||||
import { ENVIRONMENT_MAX_BACKFILL_DAYS } from "@/lib/environment/service";
|
||||
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests. Please retry shortly.", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = environmentBackfillSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "environment.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = parsed.data;
|
||||
const [sy, sm, sd] = startDate.split("-").map(Number);
|
||||
const [ey, em, ed] = endDate.split("-").map(Number);
|
||||
const spanDays =
|
||||
Math.round(
|
||||
(Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY,
|
||||
) + 1;
|
||||
if (spanDays > ENVIRONMENT_MAX_BACKFILL_DAYS) {
|
||||
return apiError(
|
||||
`Backfill range too large (max ${ENVIRONMENT_MAX_BACKFILL_DAYS} days)`,
|
||||
422,
|
||||
{ errorCode: "environment.range_too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { homeLat: true },
|
||||
});
|
||||
if (profile?.homeLat == null) {
|
||||
return apiError("Set a home location before backfilling.", 409, {
|
||||
errorCode: "environment.no_home",
|
||||
});
|
||||
}
|
||||
|
||||
const enqueued = await enqueueEnvironmentFetch({
|
||||
userId: user.id,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "environment.backfill.enqueue" },
|
||||
meta: { span_days: spanDays, enqueued },
|
||||
});
|
||||
|
||||
return apiSuccess({ enqueued, startDate, endDate, spanDays }, 202);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `GET /api/environment/geocode?q=` — forward-geocode a city to coarse matches.
|
||||
*
|
||||
* Proxies Open-Meteo's keyless geocoding through the server (so the browser
|
||||
* never calls the third-party host and the CSP stays untouched) and returns
|
||||
* coarse rounded coordinates + label + timezone for the home-location picker.
|
||||
* Module-gated + rate-limited; `userId` is narrowed from auth.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { geocodeQuerySchema } from "@/lib/validations/environment";
|
||||
import { geocodeLocation } from "@/lib/environment/open-meteo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Reuse the analytics-read budget: a city search is a cheap outbound call,
|
||||
// but the generous bucket caps a runaway autocomplete loop.
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests. Please retry shortly.", 429);
|
||||
}
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = geocodeQuerySchema.safeParse({ q: params.get("q") ?? "" });
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "environment.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const results = await geocodeLocation(parsed.data.q);
|
||||
|
||||
annotate({
|
||||
action: { name: "environment.geocode.search" },
|
||||
meta: { result_count: results.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ results });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* `PUT /api/environment/home` — set the account's coarse home location.
|
||||
*
|
||||
* The picked city (rounded lat/lon + label + IANA timezone) is stored on the
|
||||
* user. Coordinates are rounded to ~city granularity here as a defence-in-depth
|
||||
* floor even if a client sends finer values. Setting a home enqueues a
|
||||
* lookback fetch so weather appears without waiting for the nightly tick.
|
||||
* Module-gated; `userId` is narrowed from auth.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { homeLocationSchema } from "@/lib/validations/environment";
|
||||
import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = homeLocationSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "environment.home.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "environment.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { lat, lon, label, timezone } = parsed.data;
|
||||
const home = {
|
||||
homeLat: roundCoarse(lat),
|
||||
homeLon: roundCoarse(lon),
|
||||
homeLabel: label,
|
||||
homeTimezone: timezone,
|
||||
};
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: home });
|
||||
|
||||
// Kick a lookback refresh so recent days populate promptly. No-ops cleanly
|
||||
// when no worker is bound; the nightly cron still covers it.
|
||||
await enqueueEnvironmentFetch({ userId: user.id });
|
||||
|
||||
annotate({ action: { name: "environment.home.set" } });
|
||||
|
||||
return apiSuccess({
|
||||
home: {
|
||||
lat: home.homeLat,
|
||||
lon: home.homeLon,
|
||||
label: home.homeLabel,
|
||||
timezone: home.homeTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* `GET /api/environment` — the environmental-context module overview.
|
||||
*
|
||||
* Returns the account's coarse home location, its travel overrides, a small
|
||||
* summary of stored daily observations (count + latest day), and the upstream
|
||||
* attribution string. Module-gated: a 403 `module.disabled` envelope when the
|
||||
* opt-in module is off. `userId` is narrowed from auth, never a body field.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [profile, travel, contextCount, latest] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const home =
|
||||
profile?.homeLat != null && profile?.homeLon != null
|
||||
? {
|
||||
lat: profile.homeLat,
|
||||
lon: profile.homeLon,
|
||||
label: profile.homeLabel,
|
||||
timezone: profile.homeTimezone ?? profile.timezone,
|
||||
}
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "environment.overview.read" },
|
||||
meta: {
|
||||
has_home: home !== null,
|
||||
travel_count: travel.length,
|
||||
context_days: contextCount,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
home,
|
||||
travel,
|
||||
context: {
|
||||
days: contextCount,
|
||||
latestDate: latest?.date ?? null,
|
||||
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
|
||||
},
|
||||
attribution: OPEN_METEO_ATTRIBUTION,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `DELETE /api/environment/travel/[id]` — remove a manual travel override.
|
||||
*
|
||||
* Owner-scoped: the delete `where` carries both the id AND `userId` so one
|
||||
* account can never delete another's override. Module-gated. A missing / already
|
||||
* deleted row returns 404 rather than leaking existence.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const result = await prisma.environmentTravelLocation.deleteMany({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return apiError("Travel override not found", 404, {
|
||||
errorCode: "environment.travel.not_found",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "environment.travel.delete",
|
||||
entity_type: "environment_travel",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* `POST /api/environment/travel` — add a manual travel override.
|
||||
*
|
||||
* For any day in [startDate, endDate] the weather fetch resolves against this
|
||||
* coarse location instead of home (a declared trip dominates the home
|
||||
* fallback). Coordinates are rounded to ~city granularity. Adding an override
|
||||
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
|
||||
* narrowed from auth.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { travelLocationSchema } from "@/lib/validations/environment";
|
||||
import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = travelLocationSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "environment.travel.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "environment.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
const created = await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
label: entry.label,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-resolve the affected window so the override's days pick up its weather.
|
||||
await enqueueEnvironmentFetch({
|
||||
userId: user.id,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "environment.travel.create",
|
||||
entity_type: "environment_travel",
|
||||
entity_id: created.id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(created, 201);
|
||||
});
|
||||
@@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.22 — labs ↔ outcome pass fetches lab draws; default empty.
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.25 (W-ENV) — the route folds the environmental-exposure behaviour
|
||||
// channels in (read from EnvironmentContext); default empty so no env
|
||||
// channel produces a point.
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -113,6 +117,9 @@ beforeEach(() => {
|
||||
);
|
||||
// v1.22 — labs ↔ outcome pass reads lab draws; default empty.
|
||||
(prisma.labResult.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(
|
||||
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
fetchComplianceSeries,
|
||||
fetchEnvironmentSeries,
|
||||
fetchLabDraws,
|
||||
fetchSymptomSeries,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
@@ -152,11 +153,16 @@ export const GET = apiHandler(async () => {
|
||||
// their own sources. Each degrades to an empty series when the user has no
|
||||
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
|
||||
const [complianceSeries, symptomSeries, labDraws] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
]);
|
||||
const [complianceSeries, symptomSeries, labDraws, environmentSeries] =
|
||||
await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
key === "MOOD"
|
||||
@@ -180,6 +186,12 @@ export const GET = apiHandler(async () => {
|
||||
series.push({ key, role: "outcome", points: points(key) });
|
||||
}
|
||||
}
|
||||
// v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They
|
||||
// pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect-
|
||||
// size gates apply unchanged, so a thin weather series degrades to absent.
|
||||
for (const envSeries of environmentSeries) {
|
||||
series.push(envSeries);
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
|
||||
@@ -213,6 +225,13 @@ export const GET = apiHandler(async () => {
|
||||
emerging_window_days: emerging.windowDays,
|
||||
lab_draws: labDraws.length,
|
||||
lab_correlations: labCorrelations.discovered.length,
|
||||
// v1.25 (W-ENV) — env channel reach (sum of stored daily points across
|
||||
// the exposure channels) so a dashboard can see whether weather was
|
||||
// available for the scan.
|
||||
environment_days: environmentSeries.reduce(
|
||||
(sum, s) => sum + s.points.length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { CoachSection } from "@/components/settings/coach-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { EnvironmentSection } from "@/components/settings/environment-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
|
||||
import { IllnessSection } from "@/components/settings/illness-section";
|
||||
@@ -75,6 +76,7 @@ const SECTION_COMPONENTS: Record<
|
||||
mood: MoodSection,
|
||||
labs: LabsSection,
|
||||
illness: IllnessSection,
|
||||
environment: EnvironmentSection,
|
||||
anamnesis: AnamnesisSection,
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
|
||||
@@ -52,6 +52,15 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
|
||||
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
|
||||
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
// v1.25 (W-ENV) — environmental-exposure channels, so a "daylight → mood"
|
||||
// relation reads with its descriptive label instead of the raw ENV_ key.
|
||||
ENV_TEMP_MEAN: "environment.fields.tempMean",
|
||||
ENV_TEMP_MIN: "environment.fields.tempMin",
|
||||
ENV_SUNSHINE: "environment.fields.sunshine",
|
||||
ENV_DAYLIGHT: "environment.fields.daylight",
|
||||
ENV_PRECIP: "environment.fields.precip",
|
||||
ENV_PRESSURE_MEAN: "environment.fields.pressureMean",
|
||||
ENV_PRESSURE_DELTA: "environment.fields.pressureDelta",
|
||||
};
|
||||
|
||||
/** Keep only the discovered pairs that involve the mood channel. */
|
||||
|
||||
@@ -112,6 +112,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-ENV) — Environment (home location, travel overrides, backfill),
|
||||
// module-gated on the opt-in `environment` module.
|
||||
"environment",
|
||||
// v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown.
|
||||
"anamnesis",
|
||||
"vorsorge",
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<EnvironmentSection>` — Settings → Environment (v1.25, W-ENV).
|
||||
*
|
||||
* The one surface for the opt-in environmental-context module: pick a coarse
|
||||
* home location (city search → rounded lat/lon, never a GPS track), declare
|
||||
* travel overrides that win over home for their date range, and trigger a
|
||||
* backfill of past days. The nightly job keeps recent days current; the
|
||||
* correlation engine then surfaces e.g. "daylight ↔ mood" on its own.
|
||||
*
|
||||
* State source for the enabled flag is `useAuth().user.modules.environment`
|
||||
* (the resolved /auth/me map). When the module is off the section nudges the
|
||||
* user to the Modules hub rather than rendering dead controls. All reads unwrap
|
||||
* `(await res.json()).data` via the api-fetch helpers; every key routes through
|
||||
* the centralized factory.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { CloudSun, MapPin, Plane, Trash2 } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface GeocodeResult {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface TravelDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EnvironmentOverview {
|
||||
home: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string | null;
|
||||
timezone: string;
|
||||
} | null;
|
||||
travel: TravelDTO[];
|
||||
context: {
|
||||
days: number;
|
||||
latestDate: string | null;
|
||||
latestFetchedAt: string | null;
|
||||
};
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function EnvironmentSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const enabled = user?.modules?.environment === true;
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: queryKeys.environment(),
|
||||
enabled,
|
||||
queryFn: () => apiGet<EnvironmentOverview>("/api/environment"),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.environment() });
|
||||
|
||||
// ── geocoding search (shared by home + travel pickers) ──────────────────
|
||||
const [homeQuery, setHomeQuery] = useState("");
|
||||
const [homeResults, setHomeResults] = useState<GeocodeResult[]>([]);
|
||||
const [travelQuery, setTravelQuery] = useState("");
|
||||
const [travelResults, setTravelResults] = useState<GeocodeResult[]>([]);
|
||||
const [travelLoc, setTravelLoc] = useState<GeocodeResult | null>(null);
|
||||
const [travelStart, setTravelStart] = useState(todayKey());
|
||||
const [travelEnd, setTravelEnd] = useState(todayKey());
|
||||
const [backfillStart, setBackfillStart] = useState("");
|
||||
const [backfillEnd, setBackfillEnd] = useState(todayKey());
|
||||
|
||||
const geocode = (query: string) =>
|
||||
apiGet<{ results: GeocodeResult[] }>(
|
||||
`/api/environment/geocode?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
const searchHome = useMutation({
|
||||
mutationKey: queryKeys.environmentGeocode("home"),
|
||||
mutationFn: () => geocode(homeQuery),
|
||||
onSuccess: (data) => setHomeResults(data.results),
|
||||
onError: () => toast.error(t("settings.sections.environment.searchError")),
|
||||
});
|
||||
|
||||
const searchTravel = useMutation({
|
||||
mutationKey: queryKeys.environmentGeocode("travel"),
|
||||
mutationFn: () => geocode(travelQuery),
|
||||
onSuccess: (data) => setTravelResults(data.results),
|
||||
onError: () => toast.error(t("settings.sections.environment.searchError")),
|
||||
});
|
||||
|
||||
const setHome = useMutation({
|
||||
mutationKey: queryKeys.environment(),
|
||||
mutationFn: (loc: GeocodeResult) =>
|
||||
apiPut("/api/environment/home", {
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
label: loc.label,
|
||||
timezone: loc.timezone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setHomeResults([]);
|
||||
setHomeQuery("");
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.homeSaved"));
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.environment.saveError")),
|
||||
});
|
||||
|
||||
const addTravel = useMutation({
|
||||
mutationKey: queryKeys.environment(),
|
||||
mutationFn: () =>
|
||||
apiPost("/api/environment/travel", {
|
||||
startDate: travelStart,
|
||||
endDate: travelEnd,
|
||||
lat: travelLoc!.lat,
|
||||
lon: travelLoc!.lon,
|
||||
label: travelLoc!.label,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setTravelLoc(null);
|
||||
setTravelResults([]);
|
||||
setTravelQuery("");
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.travelAdded"));
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.environment.saveError")),
|
||||
});
|
||||
|
||||
const removeTravel = useMutation({
|
||||
mutationKey: queryKeys.environment(),
|
||||
mutationFn: (id: string) => apiDelete(`/api/environment/travel/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.travelRemoved"));
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.environment.saveError")),
|
||||
});
|
||||
|
||||
const backfill = useMutation({
|
||||
mutationKey: queryKeys.environment(),
|
||||
mutationFn: () =>
|
||||
apiPost("/api/environment/backfill", {
|
||||
startDate: backfillStart,
|
||||
endDate: backfillEnd,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.backfillQueued"));
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(t("settings.sections.environment.backfillError")),
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.disabledHint")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const data = overview.data;
|
||||
const home = data?.home ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Home location */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={MapPin}
|
||||
title={t("settings.sections.environment.home.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.home.description")}
|
||||
</p>
|
||||
<div className="space-y-3 pl-7">
|
||||
{home ? (
|
||||
<p className="text-sm" data-slot="environment-home-current">
|
||||
{t("settings.sections.environment.home.current", {
|
||||
label: home.label ?? `${home.lat}, ${home.lon}`,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.environment.home.none")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={homeQuery}
|
||||
onChange={(e) => setHomeQuery(e.target.value)}
|
||||
placeholder={t("settings.sections.environment.searchPlaceholder")}
|
||||
aria-label={t("settings.sections.environment.searchPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
|
||||
onClick={() => searchHome.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.search")}
|
||||
</Button>
|
||||
</div>
|
||||
{homeResults.length > 0 && (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{homeResults.map((r) => (
|
||||
<li
|
||||
key={`${r.lat},${r.lon},${r.label}`}
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">{r.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={setHome.isPending}
|
||||
onClick={() => setHome.mutate(r)}
|
||||
>
|
||||
{t("settings.sections.environment.use")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Travel overrides */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Plane}
|
||||
title={t("settings.sections.environment.travel.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.travel.description")}
|
||||
</p>
|
||||
<div className="space-y-3 pl-7">
|
||||
{data && data.travel.length > 0 ? (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{data.travel.map((tr) => (
|
||||
<li
|
||||
key={tr.id}
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{tr.label} · {tr.startDate} – {tr.endDate}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t(
|
||||
"settings.sections.environment.travel.remove",
|
||||
)}
|
||||
disabled={removeTravel.isPending}
|
||||
onClick={() => removeTravel.mutate(tr.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.environment.travel.none")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={travelQuery}
|
||||
onChange={(e) => setTravelQuery(e.target.value)}
|
||||
placeholder={t("settings.sections.environment.searchPlaceholder")}
|
||||
aria-label={t("settings.sections.environment.searchPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
travelQuery.trim().length === 0 || searchTravel.isPending
|
||||
}
|
||||
onClick={() => searchTravel.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.search")}
|
||||
</Button>
|
||||
</div>
|
||||
{travelResults.length > 0 && (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{travelResults.map((r) => (
|
||||
<li
|
||||
key={`${r.lat},${r.lon},${r.label}`}
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">{r.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
travelLoc?.label === r.label ? "default" : "secondary"
|
||||
}
|
||||
onClick={() => setTravelLoc(r)}
|
||||
>
|
||||
{t("settings.sections.environment.select")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{travelLoc && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-travel-start">
|
||||
{t("settings.sections.environment.startDate")}
|
||||
</Label>
|
||||
<Input
|
||||
id="env-travel-start"
|
||||
type="date"
|
||||
value={travelStart}
|
||||
onChange={(e) => setTravelStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-travel-end">
|
||||
{t("settings.sections.environment.endDate")}
|
||||
</Label>
|
||||
<Input
|
||||
id="env-travel-end"
|
||||
type="date"
|
||||
value={travelEnd}
|
||||
onChange={(e) => setTravelEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={travelStart > travelEnd || addTravel.isPending}
|
||||
onClick={() => addTravel.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.travel.add", {
|
||||
label: travelLoc.label,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Backfill */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.backfill.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.backfill.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2 pl-7">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-backfill-start">
|
||||
{t("settings.sections.environment.startDate")}
|
||||
</Label>
|
||||
<Input
|
||||
id="env-backfill-start"
|
||||
type="date"
|
||||
value={backfillStart}
|
||||
onChange={(e) => setBackfillStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-backfill-end">
|
||||
{t("settings.sections.environment.endDate")}
|
||||
</Label>
|
||||
<Input
|
||||
id="env-backfill-end"
|
||||
type="date"
|
||||
value={backfillEnd}
|
||||
onChange={(e) => setBackfillEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!home ||
|
||||
backfillStart.length === 0 ||
|
||||
backfillStart > backfillEnd ||
|
||||
backfill.isPending
|
||||
}
|
||||
onClick={() => backfill.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.backfill.run")}
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground mt-3 pl-7 text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Attribution (CC BY 4.0, required) */}
|
||||
<p className="text-muted-foreground pl-1 text-xs">
|
||||
{data?.attribution ?? "Weather data by Open-Meteo.com"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Blocks,
|
||||
CalendarHeart,
|
||||
Droplet,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
FileText,
|
||||
MessageCircleHeart,
|
||||
@@ -72,6 +73,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
medications: Pill,
|
||||
doctorReport: FileText,
|
||||
// v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
|
||||
environment: CloudSun,
|
||||
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
|
||||
mcp: Plug,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Blocks,
|
||||
Bot,
|
||||
CalendarCheck,
|
||||
CloudSun,
|
||||
Download,
|
||||
ClipboardList,
|
||||
FileHeart,
|
||||
@@ -60,6 +61,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
mood: Smile,
|
||||
labs: FlaskConical,
|
||||
illness: Thermometer,
|
||||
environment: CloudSun,
|
||||
anamnesis: ClipboardList,
|
||||
vorsorge: CalendarCheck,
|
||||
thresholds: SlidersHorizontal,
|
||||
|
||||
@@ -69,6 +69,10 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel
|
||||
// overrides, and the weather/daylight backfill. Module-gated on the opt-in
|
||||
// `environment` module (the nav entry only shows when it is on).
|
||||
"environment",
|
||||
// v1.25 (W-RECORDS) — "Anamnese" (medical history): the structured-record
|
||||
// home for allergies + family history. Always shown (not module-gated, like
|
||||
// `vorsorge`); the records are foundational health-profile data.
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Bot,
|
||||
CalendarCheck,
|
||||
ClipboardList,
|
||||
CloudSun,
|
||||
Download,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
@@ -192,6 +193,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
icon: Thermometer,
|
||||
moduleGate: "illness",
|
||||
},
|
||||
// v1.25 (W-ENV) — Environmental context: home location, travel overrides, and
|
||||
// the weather/daylight backfill. Module-gated on the opt-in `environment`
|
||||
// module, so the entry only appears once the user turns it on.
|
||||
{
|
||||
slug: "environment",
|
||||
titleKey: "settings.sections.environment.title",
|
||||
icon: CloudSun,
|
||||
moduleGate: "environment",
|
||||
},
|
||||
// v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home
|
||||
// for allergies + family history. NOT module-gated (like Vorsorge) — these
|
||||
// are foundational health-profile records, always available. Sits between
|
||||
|
||||
@@ -83,6 +83,7 @@ function moduleMap(
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.25 (W-ENV) — the canonical environmental-exposure field vocabulary.
|
||||
*
|
||||
* One declarative table that names each daily weather/daylight field HealthLog
|
||||
* stores AND correlates. It is the single source the two surfaces speak:
|
||||
*
|
||||
* - the SIGNAL REGISTRY (`src/lib/signals/registry.ts`) registers one
|
||||
* `kind:"environment"` signal per field here, so each gets a stable signal
|
||||
* key + display metadata for free; and
|
||||
* - the CORRELATION ENGINE reads {@link ENVIRONMENT_CHANNEL_KEYS} to fold the
|
||||
* same fields in as lagged BEHAVIOUR (exposure) channels against mood /
|
||||
* sleep / vitals.
|
||||
*
|
||||
* `key` doubles as the registry signal key AND the correlation channel key —
|
||||
* the `ENV_` prefix keeps it from colliding with a `MeasurementType` and lets
|
||||
* `metricFamily()` collapse all env channels into one `ENVIRONMENT` family (so
|
||||
* the engine never lag-correlates two same-day weather fields against each
|
||||
* other — that is a near-tautology, not an insight).
|
||||
*
|
||||
* `column` is the `EnvironmentContext` numeric column the field reads. Leaf
|
||||
* module (no imports) so both the registry and the pure correlation engine can
|
||||
* depend on it without a cycle.
|
||||
*/
|
||||
|
||||
/** The `EnvironmentContext` numeric columns a correlation field can read. */
|
||||
export type EnvironmentContextNumericColumn =
|
||||
| "tempMean"
|
||||
| "tempMin"
|
||||
| "tempMax"
|
||||
| "apparentMean"
|
||||
| "sunshineSec"
|
||||
| "daylightSec"
|
||||
| "precipSum"
|
||||
| "pressureMean"
|
||||
| "pressureDelta"
|
||||
| "humidityMean"
|
||||
| "cloudMean";
|
||||
|
||||
export interface EnvironmentField {
|
||||
/** Stable key — the registry signal key AND the correlation channel key. */
|
||||
key: string;
|
||||
/** The `EnvironmentContext` column this field's daily value comes from. */
|
||||
column: EnvironmentContextNumericColumn;
|
||||
/** Canonical unit (English, for prose + registry metadata). */
|
||||
unit: string;
|
||||
/** Stable English display name (the UI localises via `i18nLabelKey`). */
|
||||
displayName: string;
|
||||
/** Lower-cased phrase the correlation narration ("humanise") reads. */
|
||||
narrationLabel: string;
|
||||
/** i18n label key resolved by the correlation / settings surfaces. */
|
||||
i18nLabelKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exposure fields, ranked by evidence in the W-ENV research review:
|
||||
* temperature (sleep / BP / recovery), sunshine + daylight (mood), precipitation
|
||||
* (activity), pressure mean + intraday swing (headache/symptom — honestly
|
||||
* caveated, optional-strength). All are BEHAVIOUR (lag-source) channels.
|
||||
*/
|
||||
export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [
|
||||
{
|
||||
key: "ENV_TEMP_MEAN",
|
||||
column: "tempMean",
|
||||
unit: "°C",
|
||||
displayName: "Daily temperature",
|
||||
narrationLabel: "daily temperature",
|
||||
i18nLabelKey: "environment.fields.tempMean",
|
||||
},
|
||||
{
|
||||
key: "ENV_TEMP_MIN",
|
||||
column: "tempMin",
|
||||
unit: "°C",
|
||||
displayName: "Overnight low temperature",
|
||||
narrationLabel: "overnight low temperature",
|
||||
i18nLabelKey: "environment.fields.tempMin",
|
||||
},
|
||||
{
|
||||
key: "ENV_SUNSHINE",
|
||||
column: "sunshineSec",
|
||||
unit: "h",
|
||||
displayName: "Sunshine duration",
|
||||
narrationLabel: "sunshine",
|
||||
i18nLabelKey: "environment.fields.sunshine",
|
||||
},
|
||||
{
|
||||
key: "ENV_DAYLIGHT",
|
||||
column: "daylightSec",
|
||||
unit: "h",
|
||||
displayName: "Daylight length",
|
||||
narrationLabel: "daylight",
|
||||
i18nLabelKey: "environment.fields.daylight",
|
||||
},
|
||||
{
|
||||
key: "ENV_PRECIP",
|
||||
column: "precipSum",
|
||||
unit: "mm",
|
||||
displayName: "Precipitation",
|
||||
narrationLabel: "precipitation",
|
||||
i18nLabelKey: "environment.fields.precip",
|
||||
},
|
||||
{
|
||||
key: "ENV_PRESSURE_MEAN",
|
||||
column: "pressureMean",
|
||||
unit: "hPa",
|
||||
displayName: "Barometric pressure",
|
||||
narrationLabel: "barometric pressure",
|
||||
i18nLabelKey: "environment.fields.pressureMean",
|
||||
},
|
||||
{
|
||||
key: "ENV_PRESSURE_DELTA",
|
||||
column: "pressureDelta",
|
||||
unit: "hPa",
|
||||
displayName: "Pressure swing",
|
||||
narrationLabel: "intraday pressure swing",
|
||||
i18nLabelKey: "environment.fields.pressureDelta",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** The correlation channel keys, in declaration order. */
|
||||
export const ENVIRONMENT_CHANNEL_KEYS: readonly string[] =
|
||||
ENVIRONMENT_FIELDS.map((f) => f.key);
|
||||
|
||||
/** True for a registered environmental channel key. */
|
||||
export function isEnvironmentChannelKey(key: string): boolean {
|
||||
return ENVIRONMENT_CHANNEL_KEYS.includes(key);
|
||||
}
|
||||
|
||||
/** Resolve a field by its key, or null when unregistered. */
|
||||
export function getEnvironmentField(key: string): EnvironmentField | null {
|
||||
return ENVIRONMENT_FIELDS.find((f) => f.key === key) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* v1.25 (W-ENV) — Open-Meteo client (keyless historical weather + geocoding).
|
||||
*
|
||||
* The single outbound egress for the environmental-context module. Open-Meteo's
|
||||
* Historical (Archive) API is keyless, free for non-commercial use, and offers
|
||||
* a clean per-date global lat/lon endpoint — the right fit for a self-host-
|
||||
* friendly, privacy-first exposure feed. Data is CC BY 4.0 ("Weather data by
|
||||
* Open-Meteo.com" — credited on the settings surface).
|
||||
*
|
||||
* Egress goes through `safeFetch` (the documented wrapper: manual redirects +
|
||||
* a request timeout). The base URL is operator config, not arbitrary user
|
||||
* input, so `requirePublicHost` is deliberately NOT forced — a privacy-
|
||||
* maximalist operator can point `OPENMETEO_BASE_URL` at a self-hosted Open-Meteo
|
||||
* instance on a private/LAN host without it being blocked. The hosted default is
|
||||
* public.
|
||||
*
|
||||
* Server-side only. The archive feed lags real time by a few days (reanalysis
|
||||
* settling), so callers fetch with a lookback window rather than "yesterday
|
||||
* only" — see the nightly job and the backfill route.
|
||||
*/
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
|
||||
const ARCHIVE_BASE_URL =
|
||||
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
|
||||
"https://archive-api.open-meteo.com";
|
||||
|
||||
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
|
||||
const GEOCODING_BASE_URL =
|
||||
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
|
||||
"https://geocoding-api.open-meteo.com";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** CC BY 4.0 attribution required by Open-Meteo; surfaced on the settings UI. */
|
||||
export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com";
|
||||
|
||||
/** One geocoding match — coarse city placement for the home-location picker. */
|
||||
export interface GeocodeResult {
|
||||
/** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */
|
||||
lat: number;
|
||||
/** Rounded coarse longitude (2 dp). */
|
||||
lon: number;
|
||||
/** Human label, e.g. "Bochum, North Rhine-Westphalia, Germany". */
|
||||
label: string;
|
||||
/** IANA timezone for the place (anchors the day-key of stored rows). */
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** A single day's resolved weather observation, mapped to our column shape. */
|
||||
export interface DailyEnvironmentObservation {
|
||||
/** YYYY-MM-DD in the requested timezone. */
|
||||
date: string;
|
||||
tempMin: number | null;
|
||||
tempMax: number | null;
|
||||
tempMean: number | null;
|
||||
apparentMean: number | null;
|
||||
sunshineSec: number | null;
|
||||
daylightSec: number | null;
|
||||
precipSum: number | null;
|
||||
pressureMean: number | null;
|
||||
pressureDelta: number | null;
|
||||
humidityMean: number | null;
|
||||
cloudMean: number | null;
|
||||
weatherCode: number | null;
|
||||
}
|
||||
|
||||
/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */
|
||||
export function roundCoarse(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function firstNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Mean of the finite numbers in `xs`, or null when none. */
|
||||
function meanOf(xs: number[]): number | null {
|
||||
if (xs.length === 0) return null;
|
||||
const sum = xs.reduce((a, b) => a + b, 0);
|
||||
return sum / xs.length;
|
||||
}
|
||||
|
||||
interface ArchiveDaily {
|
||||
time?: string[];
|
||||
temperature_2m_min?: (number | null)[];
|
||||
temperature_2m_max?: (number | null)[];
|
||||
temperature_2m_mean?: (number | null)[];
|
||||
apparent_temperature_mean?: (number | null)[];
|
||||
sunshine_duration?: (number | null)[];
|
||||
daylight_duration?: (number | null)[];
|
||||
precipitation_sum?: (number | null)[];
|
||||
weather_code?: (number | null)[];
|
||||
}
|
||||
|
||||
interface ArchiveHourly {
|
||||
time?: string[];
|
||||
surface_pressure?: (number | null)[];
|
||||
relative_humidity_2m?: (number | null)[];
|
||||
cloud_cover?: (number | null)[];
|
||||
}
|
||||
|
||||
interface ArchiveResponse {
|
||||
daily?: ArchiveDaily;
|
||||
hourly?: ArchiveHourly;
|
||||
}
|
||||
|
||||
const DAILY_FIELDS = [
|
||||
"temperature_2m_min",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_mean",
|
||||
"apparent_temperature_mean",
|
||||
"sunshine_duration",
|
||||
"daylight_duration",
|
||||
"precipitation_sum",
|
||||
"weather_code",
|
||||
].join(",");
|
||||
|
||||
const HOURLY_FIELDS = [
|
||||
"surface_pressure",
|
||||
"relative_humidity_2m",
|
||||
"cloud_cover",
|
||||
].join(",");
|
||||
|
||||
/** Per-day aggregation accumulator for the hourly-only fields. */
|
||||
interface HourlyDayAgg {
|
||||
pressures: number[];
|
||||
humidities: number[];
|
||||
clouds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the hourly feed (pressure / humidity / cloud — not available as a
|
||||
* daily aggregate from the archive) into per-day values: mean for each, plus the
|
||||
* intraday pressure delta (max − min), the derived headache/symptom feature.
|
||||
*/
|
||||
function aggregateHourly(hourly: ArchiveHourly): Map<string, HourlyDayAgg> {
|
||||
const byDay = new Map<string, HourlyDayAgg>();
|
||||
const times = hourly.time ?? [];
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const day = times[i]?.slice(0, 10);
|
||||
if (!day) continue;
|
||||
const agg = byDay.get(day) ?? { pressures: [], humidities: [], clouds: [] };
|
||||
const p = firstNumber(hourly.surface_pressure?.[i]);
|
||||
const h = firstNumber(hourly.relative_humidity_2m?.[i]);
|
||||
const c = firstNumber(hourly.cloud_cover?.[i]);
|
||||
if (p !== null) agg.pressures.push(p);
|
||||
if (h !== null) agg.humidities.push(h);
|
||||
if (c !== null) agg.clouds.push(c);
|
||||
byDay.set(day, agg);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-geocode a free-text place to coarse matches. Keyless. Returns at most
|
||||
* `count` results; an empty array when nothing matched or the feed errored
|
||||
* (the caller surfaces "no match" rather than failing the request).
|
||||
*/
|
||||
export async function geocodeLocation(
|
||||
query: string,
|
||||
count = 5,
|
||||
): Promise<GeocodeResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
|
||||
const url = new URL(`${GEOCODING_BASE_URL}/v1/search`);
|
||||
url.searchParams.set("name", trimmed);
|
||||
url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10)));
|
||||
url.searchParams.set("format", "json");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await safeFetch(
|
||||
url,
|
||||
{ headers: { accept: "application/json" } },
|
||||
{
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof SafeFetchError) return [];
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) return [];
|
||||
|
||||
const body = (await res.json()) as {
|
||||
results?: Array<{
|
||||
name?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
country?: string;
|
||||
admin1?: string;
|
||||
timezone?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const out: GeocodeResult[] = [];
|
||||
for (const r of body.results ?? []) {
|
||||
if (typeof r.latitude !== "number" || typeof r.longitude !== "number") {
|
||||
continue;
|
||||
}
|
||||
const parts = [r.name, r.admin1, r.country].filter(
|
||||
(p): p is string => typeof p === "string" && p.length > 0,
|
||||
);
|
||||
out.push({
|
||||
lat: roundCoarse(r.latitude),
|
||||
lon: roundCoarse(r.longitude),
|
||||
label: parts.join(", "),
|
||||
timezone:
|
||||
typeof r.timezone === "string" && r.timezone.length > 0
|
||||
? r.timezone
|
||||
: "auto",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the daily environment observations for a coarse location over a date
|
||||
* range (inclusive YYYY-MM-DD). The hourly-only fields (pressure / humidity /
|
||||
* cloud) are requested alongside and aggregated to per-day values here. Throws
|
||||
* a {@link SafeFetchError} on egress failure so the caller can classify it.
|
||||
*/
|
||||
export async function fetchDailyEnvironment(args: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
timezone: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<DailyEnvironmentObservation[]> {
|
||||
const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`);
|
||||
url.searchParams.set("latitude", String(args.lat));
|
||||
url.searchParams.set("longitude", String(args.lon));
|
||||
url.searchParams.set("start_date", args.startDate);
|
||||
url.searchParams.set("end_date", args.endDate);
|
||||
url.searchParams.set("daily", DAILY_FIELDS);
|
||||
url.searchParams.set("hourly", HOURLY_FIELDS);
|
||||
url.searchParams.set("timezone", args.timezone);
|
||||
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{ headers: { accept: "application/json" } },
|
||||
{
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new SafeFetchError(
|
||||
`open-meteo archive returned HTTP ${res.status}`,
|
||||
"network",
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as ArchiveResponse;
|
||||
const daily = body.daily ?? {};
|
||||
const days = daily.time ?? [];
|
||||
const hourlyByDay = aggregateHourly(body.hourly ?? {});
|
||||
|
||||
const observations: DailyEnvironmentObservation[] = [];
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const date = days[i];
|
||||
if (!date) continue;
|
||||
const agg = hourlyByDay.get(date);
|
||||
const pressures = agg?.pressures ?? [];
|
||||
const pressureMean = meanOf(pressures);
|
||||
const pressureDelta =
|
||||
pressures.length > 0
|
||||
? Math.max(...pressures) - Math.min(...pressures)
|
||||
: null;
|
||||
|
||||
observations.push({
|
||||
date,
|
||||
tempMin: firstNumber(daily.temperature_2m_min?.[i]),
|
||||
tempMax: firstNumber(daily.temperature_2m_max?.[i]),
|
||||
tempMean: firstNumber(daily.temperature_2m_mean?.[i]),
|
||||
apparentMean: firstNumber(daily.apparent_temperature_mean?.[i]),
|
||||
sunshineSec: firstNumber(daily.sunshine_duration?.[i]),
|
||||
daylightSec: firstNumber(daily.daylight_duration?.[i]),
|
||||
precipSum: firstNumber(daily.precipitation_sum?.[i]),
|
||||
pressureMean,
|
||||
pressureDelta:
|
||||
pressureDelta !== null ? Math.round(pressureDelta * 10) / 10 : null,
|
||||
humidityMean: meanOf(agg?.humidities ?? []),
|
||||
cloudMean: meanOf(agg?.clouds ?? []),
|
||||
weatherCode: firstNumber(daily.weather_code?.[i]),
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* v1.25 (W-ENV) — environmental-context resolution + fetch/upsert service.
|
||||
*
|
||||
* Owns the location-precedence logic and the per-day upsert into
|
||||
* `EnvironmentContext`. Both the nightly job and the on-demand backfill route
|
||||
* call {@link fetchAndStoreEnvironment} so the precedence, the day-keying, and
|
||||
* the upsert live in exactly one place.
|
||||
*
|
||||
* Location precedence per day (first hit wins):
|
||||
* 1. a manual TRAVEL override whose [startDate, endDate] covers the day;
|
||||
* 2. the user's HOME location.
|
||||
* No home + no covering override ⇒ the day is skipped (the module degrades
|
||||
* silently, matching the rollup "coverage miss → absent" posture).
|
||||
*
|
||||
* Coarse location only — the resolver never sees finer than the rounded city
|
||||
* coordinates stored on the user / override.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
fetchDailyEnvironment,
|
||||
type DailyEnvironmentObservation,
|
||||
} from "@/lib/environment/open-meteo";
|
||||
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Default lookback window (days) for the nightly fetch — absorbs the archive
|
||||
* settling lag and any days missed across worker reboots. */
|
||||
export const ENVIRONMENT_LOOKBACK_DAYS = 7;
|
||||
/** Hard cap on a single backfill span so one request can never fan out forever. */
|
||||
export const ENVIRONMENT_MAX_BACKFILL_DAYS = 730;
|
||||
|
||||
/** A coarse resolved location for a given day. */
|
||||
interface ResolvedLocation {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
source: EnvironmentLocationSource;
|
||||
}
|
||||
|
||||
interface HomeLocation {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface TravelOverride {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */
|
||||
export function utcDayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */
|
||||
export function enumerateDays(start: string, end: string): string[] {
|
||||
const out: string[] = [];
|
||||
const [sy, sm, sd] = start.split("-").map(Number);
|
||||
const [ey, em, ed] = end.split("-").map(Number);
|
||||
let cur = Date.UTC(sy, sm - 1, sd);
|
||||
const last = Date.UTC(ey, em - 1, ed);
|
||||
while (cur <= last) {
|
||||
out.push(new Date(cur).toISOString().slice(0, 10));
|
||||
cur += MS_PER_DAY;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the location for one day: a covering travel override wins, else home,
|
||||
* else null. Pure — exported for unit tests.
|
||||
*/
|
||||
export function resolveLocationForDay(
|
||||
day: string,
|
||||
home: HomeLocation | null,
|
||||
travels: readonly TravelOverride[],
|
||||
): ResolvedLocation | null {
|
||||
for (const t of travels) {
|
||||
if (day >= t.startDate && day <= t.endDate) {
|
||||
return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" };
|
||||
}
|
||||
}
|
||||
if (home) {
|
||||
return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A location-keyed group of days (one upstream fetch per group). */
|
||||
function groupKey(loc: ResolvedLocation): string {
|
||||
return `${loc.lat},${loc.lon},${loc.source},${loc.label}`;
|
||||
}
|
||||
|
||||
export interface FetchAndStoreResult {
|
||||
/** Days that resolved to a location and were upserted. */
|
||||
stored: number;
|
||||
/** Days skipped because no location resolved (no home, no override). */
|
||||
skipped: number;
|
||||
/** Distinct upstream fetches made. */
|
||||
fetches: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve + fetch + upsert the environment rows for a user across a date range.
|
||||
* Groups days by resolved location so each location is fetched once over its
|
||||
* contiguous span. A day whose observation is absent from the feed is left
|
||||
* un-stored (no fabricated row). Idempotent — re-running upserts the same rows.
|
||||
*/
|
||||
export async function fetchAndStoreEnvironment(args: {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<FetchAndStoreResult> {
|
||||
const { userId, startDate, endDate } = args;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
timezone: true,
|
||||
},
|
||||
});
|
||||
if (!user) return { stored: 0, skipped: 0, fetches: 0 };
|
||||
|
||||
const home: HomeLocation | null =
|
||||
user.homeLat != null && user.homeLon != null
|
||||
? {
|
||||
lat: user.homeLat,
|
||||
lon: user.homeLon,
|
||||
label: user.homeLabel ?? "Home",
|
||||
timezone: user.homeTimezone ?? user.timezone,
|
||||
}
|
||||
: null;
|
||||
|
||||
const travelRows = await prisma.environmentTravelLocation.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The timezone used to enumerate AND fetch days — one tz keeps the stored
|
||||
// `date` consistent with the user's day-keys elsewhere.
|
||||
const timezone = home?.timezone ?? user.timezone;
|
||||
|
||||
const days = enumerateDays(startDate, endDate);
|
||||
const resolvedByDay = new Map<string, ResolvedLocation>();
|
||||
const groups = new Map<string, { loc: ResolvedLocation; days: string[] }>();
|
||||
let skipped = 0;
|
||||
for (const day of days) {
|
||||
const loc = resolveLocationForDay(day, home, travelRows);
|
||||
if (!loc) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
resolvedByDay.set(day, loc);
|
||||
const gk = groupKey(loc);
|
||||
const group = groups.get(gk) ?? { loc, days: [] };
|
||||
group.days.push(day);
|
||||
groups.set(gk, group);
|
||||
}
|
||||
|
||||
let stored = 0;
|
||||
let fetches = 0;
|
||||
for (const { loc, days: groupDays } of groups.values()) {
|
||||
const min = groupDays[0];
|
||||
const max = groupDays[groupDays.length - 1];
|
||||
let observations: DailyEnvironmentObservation[];
|
||||
try {
|
||||
observations = await fetchDailyEnvironment({
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
timezone,
|
||||
startDate: min,
|
||||
endDate: max,
|
||||
});
|
||||
} catch {
|
||||
// A failed fetch for one location leaves its days un-stored; the nightly
|
||||
// lookback re-attempts them. Other locations still get their rows.
|
||||
continue;
|
||||
}
|
||||
fetches += 1;
|
||||
|
||||
const obsByDate = new Map(observations.map((o) => [o.date, o]));
|
||||
const wanted = new Set(groupDays);
|
||||
for (const obs of observations) {
|
||||
if (!wanted.has(obs.date)) continue;
|
||||
await upsertObservation(userId, loc, obs);
|
||||
stored += 1;
|
||||
}
|
||||
// Days the feed did not return (e.g. beyond the settling lag) stay absent.
|
||||
void obsByDate;
|
||||
}
|
||||
|
||||
return { stored, skipped, fetches };
|
||||
}
|
||||
|
||||
async function upsertObservation(
|
||||
userId: string,
|
||||
loc: ResolvedLocation,
|
||||
obs: DailyEnvironmentObservation,
|
||||
): Promise<void> {
|
||||
const data = {
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
locationLabel: loc.label,
|
||||
source: loc.source,
|
||||
tempMin: obs.tempMin,
|
||||
tempMax: obs.tempMax,
|
||||
tempMean: obs.tempMean,
|
||||
apparentMean: obs.apparentMean,
|
||||
sunshineSec: obs.sunshineSec != null ? Math.round(obs.sunshineSec) : null,
|
||||
daylightSec: obs.daylightSec != null ? Math.round(obs.daylightSec) : null,
|
||||
precipSum: obs.precipSum,
|
||||
pressureMean: obs.pressureMean,
|
||||
pressureDelta: obs.pressureDelta,
|
||||
humidityMean: obs.humidityMean,
|
||||
cloudMean: obs.cloudMean,
|
||||
weatherCode: obs.weatherCode,
|
||||
fetchedAt: new Date(),
|
||||
};
|
||||
await prisma.environmentContext.upsert({
|
||||
where: { userId_date: { userId, date: obs.date } },
|
||||
create: { userId, date: obs.date, ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
import {
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type DailySeriesPoint,
|
||||
type LabDrawPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
|
||||
import {
|
||||
buildComplianceDailySeries,
|
||||
buildSymptomSeverityDailySeries,
|
||||
@@ -183,6 +185,59 @@ export async function fetchSymptomSeries(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-ENV) — build the user's environmental-exposure BEHAVIOUR channels.
|
||||
*
|
||||
* One {@link NamedSeries} per registered env field (temperature, daylight,
|
||||
* sunshine, precipitation, pressure mean + intraday swing), read from the daily
|
||||
* `EnvironmentContext` rows the nightly job stores. Each row's `date` is already
|
||||
* a YYYY-MM-DD key, so points need no re-keying. Sunshine / daylight are stored
|
||||
* in seconds and surfaced as hours (correlation r is scale-invariant; hours just
|
||||
* keep the series readable). A field with no non-null values yields an empty
|
||||
* series that degrades to absent — never a fabricated constant. The whole set is
|
||||
* empty when the user has no environment rows (module off / no home set).
|
||||
*/
|
||||
export async function fetchEnvironmentSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<NamedSeries[]> {
|
||||
const sinceKey = since.toISOString().slice(0, 10);
|
||||
const rows = await prisma.environmentContext.findMany({
|
||||
where: { userId, date: { gte: sinceKey } },
|
||||
orderBy: { date: "asc" },
|
||||
take: 1000,
|
||||
select: {
|
||||
date: true,
|
||||
tempMean: true,
|
||||
tempMin: true,
|
||||
tempMax: true,
|
||||
apparentMean: true,
|
||||
sunshineSec: true,
|
||||
daylightSec: true,
|
||||
precipSum: true,
|
||||
pressureMean: true,
|
||||
pressureDelta: true,
|
||||
humidityMean: true,
|
||||
cloudMean: true,
|
||||
},
|
||||
});
|
||||
|
||||
return ENVIRONMENT_FIELDS.map((field) => {
|
||||
const points: DailySeriesPoint[] = [];
|
||||
for (const row of rows) {
|
||||
const raw = row[field.column];
|
||||
if (raw == null || !Number.isFinite(raw)) continue;
|
||||
// Seconds → hours for the duration fields; pass through otherwise.
|
||||
const value =
|
||||
field.column === "sunshineSec" || field.column === "daylightSec"
|
||||
? raw / 3600
|
||||
: raw;
|
||||
points.push({ day: row.date, value });
|
||||
}
|
||||
return { key: field.key, role: "behaviour" as const, points };
|
||||
});
|
||||
}
|
||||
|
||||
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = wallClockInTz(at, tz);
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
* link. The series builders live in `correlation-series-builders.ts`.
|
||||
*/
|
||||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
getEnvironmentField,
|
||||
isEnvironmentChannelKey,
|
||||
} from "@/lib/environment/fields";
|
||||
|
||||
/** Default Benjamini-Hochberg target false-discovery rate. */
|
||||
export const FDR_Q = 0.1;
|
||||
@@ -102,6 +106,12 @@ export function metricFamily(key: string): string {
|
||||
// tautology as MOOD→MOOD and is excluded too.
|
||||
if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD";
|
||||
if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE";
|
||||
// v1.25 (W-ENV) — every environmental-exposure channel (ENV_TEMP_MEAN,
|
||||
// ENV_DAYLIGHT, …) shares ONE family so the same-family guard never
|
||||
// lag-correlates two same-day weather fields against each other (a
|
||||
// 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";
|
||||
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
|
||||
// their OWN single-channel family, so the loop's same-family guard collapses
|
||||
// only the self-lag (compliance→compliance, symptom→symptom). The returned
|
||||
@@ -312,6 +322,11 @@ function humanise(key: string): string {
|
||||
if (key.startsWith("FACTOR:")) {
|
||||
return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`;
|
||||
}
|
||||
// v1.25 (W-ENV) — env channels read with their descriptive label ("daily
|
||||
// temperature", "daylight") rather than the raw "env temp mean" key.
|
||||
if (isEnvironmentChannelKey(key)) {
|
||||
return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase();
|
||||
}
|
||||
return key.replace(/_/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* v1.25 (W-ENV) — nightly environmental-context fetch.
|
||||
*
|
||||
* For every account with the environmental-context module enabled AND a home
|
||||
* location set, fetch the daily weather/daylight for the recent uncovered days
|
||||
* and upsert into `EnvironmentContext`. A daily discovery tick (empty payload)
|
||||
* fans out one per-user job; the per-user job resolves each day's location
|
||||
* (travel override → home) and fetches a lookback window so the archive feed's
|
||||
* settling lag (a few days) is absorbed and any day missed across worker
|
||||
* reboots is re-attempted. The same handler also serves the on-demand backfill
|
||||
* (an explicit `{ userId, startDate, endDate }` payload from the settings
|
||||
* surface).
|
||||
*
|
||||
* Opt-in + privacy: the discovery filter requires both the module flag and a
|
||||
* home location, so a disabled account never triggers an outbound fetch. Egress
|
||||
* runs through `safeFetch` inside the Open-Meteo client. Recurring → pg-boss
|
||||
* (never a CLI script; the prod image strips `tsx`).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { PgBoss } from "pg-boss";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
ENVIRONMENT_LOOKBACK_DAYS,
|
||||
ENVIRONMENT_MAX_BACKFILL_DAYS,
|
||||
fetchAndStoreEnvironment,
|
||||
utcDayKey,
|
||||
} from "@/lib/environment/service";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { workerLog } from "./reminder/shared";
|
||||
|
||||
export const ENVIRONMENT_FETCH_QUEUE = "environment-fetch";
|
||||
|
||||
/** Daily 02:10 Europe/Berlin — early, before the 02:30+ maintenance window so
|
||||
* the night's weather rows exist before any briefing/insight pre-gen reads. */
|
||||
export const ENVIRONMENT_FETCH_CRON = "10 2 * * *";
|
||||
|
||||
export const ENVIRONMENT_FETCH_CONCURRENCY = 1;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Per-user fetch payload. An empty payload (no `userId`) is the daily discovery
|
||||
* tick. `startDate`/`endDate` (YYYY-MM-DD) drive the on-demand backfill; omitted
|
||||
* ⇒ the default lookback window ending today.
|
||||
*/
|
||||
export interface EnvironmentFetchPayload {
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/** Resolve the date range for a per-user run (explicit range, else lookback). */
|
||||
function resolveRange(payload: EnvironmentFetchPayload): {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
} {
|
||||
const today = new Date();
|
||||
const endDate = payload.endDate ?? utcDayKey(today);
|
||||
const startDate =
|
||||
payload.startDate ??
|
||||
utcDayKey(
|
||||
new Date(today.getTime() - ENVIRONMENT_LOOKBACK_DAYS * MS_PER_DAY),
|
||||
);
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-side enqueue for a single account: a default-lookback refresh (after
|
||||
* the user sets/changes their home, so weather appears without waiting for the
|
||||
* nightly tick) or an explicit-range backfill. No-ops cleanly when no global
|
||||
* boss is bound (e.g. a web-only deployment) — the nightly cron still covers it.
|
||||
* The lookback variant is singleton-coalesced per user; an explicit backfill
|
||||
* range is NOT coalesced so it always runs.
|
||||
*/
|
||||
export async function enqueueEnvironmentFetch(
|
||||
payload: EnvironmentFetchPayload & { userId: string },
|
||||
): Promise<boolean> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return false;
|
||||
const options =
|
||||
payload.startDate || payload.endDate
|
||||
? undefined
|
||||
: { singletonKey: `environment-fetch:${payload.userId}` };
|
||||
await boss.send(ENVIRONMENT_FETCH_QUEUE, payload, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery fan-out: enqueue one per-user fetch job for every account with the
|
||||
* module on and a home location set. Idempotent (singletonKey-coalesced per
|
||||
* user). Returns counts for the worker log.
|
||||
*/
|
||||
export async function enqueueEnvironmentFetchDiscovery(
|
||||
boss: PgBoss,
|
||||
): Promise<{ enqueued: number; skipped: number }> {
|
||||
// Candidate set: a home location is required for any non-override day, so an
|
||||
// account without one cannot produce a row. The per-candidate module check
|
||||
// then enforces the opt-in flag + operator availability.
|
||||
const candidates = await prisma.user.findMany({
|
||||
where: { homeLat: { not: null }, homeLon: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of candidates) {
|
||||
if (!(await isModuleEnabled(id, "environment"))) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await boss.send(
|
||||
ENVIRONMENT_FETCH_QUEUE,
|
||||
{ userId: id } satisfies EnvironmentFetchPayload,
|
||||
{ singletonKey: `environment-fetch:${id}` },
|
||||
);
|
||||
enqueued += 1;
|
||||
}
|
||||
return { enqueued, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue handler. Empty payload ⇒ discovery fan-out; a `userId` payload ⇒
|
||||
* fetch + upsert for that user across the resolved range. Re-checks the module
|
||||
* gate on the per-user path so a backfill enqueued just before the user
|
||||
* disabled the module is a no-op.
|
||||
*/
|
||||
export async function handleEnvironmentFetch(
|
||||
boss: PgBoss,
|
||||
payload: EnvironmentFetchPayload,
|
||||
): Promise<void> {
|
||||
if (!payload.userId) {
|
||||
const { enqueued, skipped } = await enqueueEnvironmentFetchDiscovery(boss);
|
||||
workerLog(
|
||||
"info",
|
||||
`[environment-fetch] discovery enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
if (!(await isModuleEnabled(userId, "environment"))) {
|
||||
workerLog(
|
||||
"info",
|
||||
`[environment-fetch] user=${userId} module disabled — skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startDate, endDate } = resolveRange(payload);
|
||||
// Clamp the span so a crafted backfill can never fan out an unbounded range.
|
||||
const span = enumerateSpanDays(startDate, endDate);
|
||||
if (span > ENVIRONMENT_MAX_BACKFILL_DAYS) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[environment-fetch] user=${userId} range ${startDate}..${endDate} (${span}d) exceeds cap — skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchAndStoreEnvironment({
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
workerLog(
|
||||
"info",
|
||||
`[environment-fetch] user=${userId} ${startDate}..${endDate} stored=${result.stored} skipped=${result.skipped} fetches=${result.fetches}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", `[environment-fetch] user=${userId} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function enumerateSpanDays(start: string, end: string): number {
|
||||
const [sy, sm, sd] = start.split("-").map(Number);
|
||||
const [ey, em, ed] = end.split("-").map(Number);
|
||||
const days = Math.round(
|
||||
(Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY,
|
||||
);
|
||||
return days + 1;
|
||||
}
|
||||
@@ -75,6 +75,13 @@ import {
|
||||
handleEncryptionKeyRotate,
|
||||
type EncryptionKeyRotatePayload,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import {
|
||||
ENVIRONMENT_FETCH_QUEUE,
|
||||
ENVIRONMENT_FETCH_CRON,
|
||||
ENVIRONMENT_FETCH_CONCURRENCY,
|
||||
handleEnvironmentFetch,
|
||||
type EnvironmentFetchPayload,
|
||||
} from "@/lib/jobs/environment-fetch";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
@@ -216,6 +223,12 @@ const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *";
|
||||
const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *";
|
||||
|
||||
const allQueues = [
|
||||
// v1.25 (W-ENV) — nightly environmental-context fetch. A daily discovery tick
|
||||
// fans out one per-user job per opted-in account with a home location; the
|
||||
// queue also serves the on-demand backfill from the Environment settings
|
||||
// surface. Without this entry pg-boss never provisions it and both the cron
|
||||
// and the backfill button silently no-op.
|
||||
ENVIRONMENT_FETCH_QUEUE,
|
||||
DATA_BACKUP_QUEUE,
|
||||
RATE_LIMIT_CLEANUP_QUEUE,
|
||||
IDEMPOTENCY_CLEANUP_QUEUE,
|
||||
@@ -277,6 +290,9 @@ const allQueues = [
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
// v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload)
|
||||
// that fans out one per-user environment fetch per opted-in account.
|
||||
[ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON],
|
||||
[DATA_BACKUP_QUEUE, DATA_BACKUP_CRON],
|
||||
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON],
|
||||
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
|
||||
@@ -601,6 +617,20 @@ export async function registerMaintenanceQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.25 (W-ENV) — nightly environment fetch. The daily discovery tick (empty
|
||||
// payload) fans out one per-user job per opted-in account; the queue also
|
||||
// serves the on-demand backfill payloads from the settings surface. Serial
|
||||
// concurrency so the staggered outbound fetches never crowd the request pool.
|
||||
await boss.work<EnvironmentFetchPayload>(
|
||||
ENVIRONMENT_FETCH_QUEUE,
|
||||
{ localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
await handleEnvironmentFetch(boss, job.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
|
||||
@@ -352,10 +352,15 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
|
||||
});
|
||||
|
||||
describe("registry — opt-in marker", () => {
|
||||
it("marks only the mcp module opt-in (every other module is default-on)", () => {
|
||||
it("marks the mcp + environment modules opt-in (every other module is default-on)", () => {
|
||||
expect(isOptInModule("mcp")).toBe(true);
|
||||
// v1.25 (W-ENV) — the environmental-context module is opt-in too: it
|
||||
// performs an outbound weather fetch tied to a coarse location, so it ships
|
||||
// dark like the remote MCP endpoint.
|
||||
expect(isOptInModule("environment")).toBe(true);
|
||||
const optIn = new Set(["mcp", "environment"]);
|
||||
for (const key of MODULE_KEYS) {
|
||||
if (key === "mcp") continue;
|
||||
if (optIn.has(key)) continue;
|
||||
expect(isOptInModule(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,6 +115,12 @@ export const MODULE_KEYS = [
|
||||
// re-enabling finds the rows intact.
|
||||
"medications",
|
||||
"doctorReport",
|
||||
// v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
|
||||
// (off by default): it performs an outbound weather fetch tied to where the
|
||||
// user physically is (a coarse home / travel location), so opt-in is the
|
||||
// right privacy default. With it off no job runs, no row is written, and no
|
||||
// egress happens. The `optIn` marker inverts the per-user default.
|
||||
"environment",
|
||||
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
|
||||
// every other module this is OPT-IN (off by default): it exposes a new
|
||||
// external-assistant attack surface, so it ships dark and the operator /
|
||||
@@ -256,6 +262,17 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.doctorReport.description",
|
||||
category: "export",
|
||||
},
|
||||
environment: {
|
||||
key: "environment",
|
||||
labelKey: "modules.environment.label",
|
||||
descriptionKey: "modules.environment.description",
|
||||
category: "device",
|
||||
// Off by default: performs an outbound weather fetch tied to a coarse
|
||||
// location, so it ships dark and the user turns it on deliberately. The
|
||||
// home location + travel overrides + backfill live on the dedicated
|
||||
// Environment settings surface (rendered when the module is on).
|
||||
optIn: true,
|
||||
},
|
||||
mcp: {
|
||||
key: "mcp",
|
||||
labelKey: "modules.mcp.label",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Query keys — environmental-context module (v1.25, W-ENV). The overview
|
||||
* (home + travel overrides + stored-day summary) and the geocoding search.
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const environmentKeys = {
|
||||
/**
|
||||
* `environment()` is the root prefix every environment write (set home, add /
|
||||
* remove a travel override, trigger a backfill) invalidates through, so the
|
||||
* settings surface repaints in lockstep.
|
||||
*/
|
||||
environment: () => ["environment"] as const,
|
||||
/** Geocoding search results for a query string (cached per query). */
|
||||
environmentGeocode: (query: string) =>
|
||||
["environment", "geocode", query] as const,
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { coachKeys } from "./coach";
|
||||
import { familyHistoryKeys } from "./family-history";
|
||||
import { cycleKeys } from "./cycle";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { environmentKeys } from "./environment";
|
||||
import { illnessKeys } from "./illness";
|
||||
import { insightsKeys } from "./insights";
|
||||
import { integrationKeys } from "./integrations";
|
||||
@@ -52,6 +53,7 @@ export const queryKeys = {
|
||||
...mentalHealthKeys,
|
||||
...allergyKeys,
|
||||
...familyHistoryKeys,
|
||||
...environmentKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,8 @@ describe("signal registry — structural sanity", () => {
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") {
|
||||
expect(signal.source.biomarkerKey.length).toBeGreaterThan(0);
|
||||
} else if (signal.kind === "environment") {
|
||||
expect(signal.source.environmentChannelKey.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(signal.source.measurementType.length).toBeGreaterThan(0);
|
||||
}
|
||||
@@ -57,7 +59,7 @@ describe("signal registry — structural sanity", () => {
|
||||
|
||||
it("signalForMeasurementType round-trips every measurement/score signal", () => {
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") continue;
|
||||
if (signal.kind !== "measurement" && signal.kind !== "score") continue;
|
||||
expect(signalForMeasurementType(signal.source.measurementType)).toBe(
|
||||
signal,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,10 @@ import { allSignals } from "@/lib/signals/registry";
|
||||
export function deriveBucketedTypes(): MeasurementType[] {
|
||||
const types: MeasurementType[] = [];
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") continue;
|
||||
// biomarker (labs path) + environment (W-ENV exposure channels) are not
|
||||
// `MeasurementType`-backed — env feeds the engine through its own series
|
||||
// builder, not the measurement-keyed bucketed types.
|
||||
if (signal.kind === "biomarker" || signal.kind === "environment") continue;
|
||||
if (!signal.surfaces.correlationEligible) continue;
|
||||
types.push(signal.source.measurementType);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { allSignals } from "@/lib/signals/registry";
|
||||
export function deriveMeasurementLoinc(): Record<string, LoincMapping> {
|
||||
const entries: Array<[string, LoincMapping]> = [];
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") continue;
|
||||
// Only `MeasurementType`-backed signals key `MEASUREMENT_LOINC`. biomarker
|
||||
// (labs) + environment (W-ENV) signals are never Measurement rows.
|
||||
if (signal.kind === "biomarker" || signal.kind === "environment") continue;
|
||||
if (!signal.fhir) continue;
|
||||
entries.push([
|
||||
signal.source.measurementType,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getSignal } from "@/lib/signals/registry";
|
||||
/** Build the `MetricStatusMeta` for one registered metric-status id. */
|
||||
function metaFor(id: MetricStatusMetricId): MetricStatusMeta {
|
||||
const signal = getSignal(id);
|
||||
if (!signal || signal.kind === "biomarker") {
|
||||
if (!signal || signal.kind === "biomarker" || signal.kind === "environment") {
|
||||
throw new Error(`signal registry: no measurement signal for "${id}"`);
|
||||
}
|
||||
// Build field-by-field, omitting the optional keys when absent so the
|
||||
|
||||
+144
-3
@@ -30,8 +30,15 @@ import type {
|
||||
import type { FhirObservationCategory } from "@/lib/fhir/loinc-map";
|
||||
import { FEVER_BAND_C } from "@/lib/clinical-floors";
|
||||
|
||||
/** Discriminator: a device/manual reading, a lab biomarker, or a computed score. */
|
||||
export type SignalKind = "measurement" | "biomarker" | "score";
|
||||
/**
|
||||
* Discriminator: a device/manual reading, a lab biomarker, a computed score, or
|
||||
* an environmental exposure. `environment` (v1.25 W-ENV) is NOT backed by a
|
||||
* `MeasurementType` — its daily value lives in `EnvironmentContext` and feeds
|
||||
* the correlation engine through the env channel series builder, not the
|
||||
* measurement-keyed `BUCKETED_TYPES`. It registers here so the env signals get
|
||||
* a stable key + display metadata in the one cross-surface table.
|
||||
*/
|
||||
export type SignalKind = "measurement" | "biomarker" | "score" | "environment";
|
||||
|
||||
/** Reuse the assessment registry's direction vocabulary verbatim. */
|
||||
export type SignalDirection = MetricDirection;
|
||||
@@ -114,6 +121,17 @@ export interface BiomarkerSignalSource {
|
||||
biomarkerKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How an `environment` signal is sourced (v1.25 W-ENV). The value is a daily
|
||||
* `EnvironmentContext` column, surfaced to the correlation engine under
|
||||
* `environmentChannelKey` (the `ENV_*` channel key from
|
||||
* `@/lib/environment/fields`). No `MeasurementType` — env data is never a
|
||||
* Measurement row.
|
||||
*/
|
||||
export interface EnvironmentSignalSource {
|
||||
environmentChannelKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical cross-surface signal definition, discriminated on `kind`.
|
||||
* `measurement` and `score` both resolve to a `MeasurementType`; `biomarker`
|
||||
@@ -122,7 +140,8 @@ export interface BiomarkerSignalSource {
|
||||
export type SignalDefinition =
|
||||
| (SignalCommon & { kind: "measurement"; source: MeasurementSignalSource })
|
||||
| (SignalCommon & { kind: "score"; source: MeasurementSignalSource })
|
||||
| (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource });
|
||||
| (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource })
|
||||
| (SignalCommon & { kind: "environment"; source: EnvironmentSignalSource });
|
||||
|
||||
/** Convenience: build a Coach-snapshot facet from a scope token. */
|
||||
function coachScope(scope: string): { scope: string } {
|
||||
@@ -1504,6 +1523,128 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
// ── v1.25 (W-ENV) environmental-context module — exposure signals ─────────
|
||||
// Daily weather/daylight, fetched server-side from Open-Meteo for the user's
|
||||
// coarse home (or a travel override) and stored in `EnvironmentContext`. NOT
|
||||
// `MeasurementType`-backed: `correlationEligible:true` declares intent, but
|
||||
// the wiring runs through the env channel series builder, not the
|
||||
// measurement-keyed `BUCKETED_TYPES` (the correlation adapter skips
|
||||
// `kind:"environment"`). Off the Coach snapshot + MCP surfaces in v1
|
||||
// (`coachSnapshot:false ⇒ mcp:false`); no FHIR facet (env observations are
|
||||
// not Measurement rows, so they never enter `MEASUREMENT_LOINC`). Direction is
|
||||
// `target-band` — a neutral exposure, not a "better/worse" vital.
|
||||
ENV_TEMP_MEAN: {
|
||||
key: "ENV_TEMP_MEAN",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_TEMP_MEAN" },
|
||||
i18nPrefix: "environment.fields.tempMean",
|
||||
displayName: "Daily temperature",
|
||||
unit: "°C",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_TEMP_MIN: {
|
||||
key: "ENV_TEMP_MIN",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_TEMP_MIN" },
|
||||
i18nPrefix: "environment.fields.tempMin",
|
||||
displayName: "Overnight low temperature",
|
||||
unit: "°C",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_SUNSHINE: {
|
||||
key: "ENV_SUNSHINE",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_SUNSHINE" },
|
||||
i18nPrefix: "environment.fields.sunshine",
|
||||
displayName: "Sunshine duration",
|
||||
unit: "h",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_DAYLIGHT: {
|
||||
key: "ENV_DAYLIGHT",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_DAYLIGHT" },
|
||||
i18nPrefix: "environment.fields.daylight",
|
||||
displayName: "Daylight length",
|
||||
unit: "h",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_PRECIP: {
|
||||
key: "ENV_PRECIP",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_PRECIP" },
|
||||
i18nPrefix: "environment.fields.precip",
|
||||
displayName: "Precipitation",
|
||||
unit: "mm",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_PRESSURE_MEAN: {
|
||||
key: "ENV_PRESSURE_MEAN",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_PRESSURE_MEAN" },
|
||||
i18nPrefix: "environment.fields.pressureMean",
|
||||
displayName: "Barometric pressure",
|
||||
unit: "hPa",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_PRESSURE_DELTA: {
|
||||
key: "ENV_PRESSURE_DELTA",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_PRESSURE_DELTA" },
|
||||
i18nPrefix: "environment.fields.pressureDelta",
|
||||
displayName: "Pressure swing",
|
||||
unit: "hPa",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Type guard: narrow an arbitrary string to a registered signal key. */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* v1.25 (W-ENV) — environmental-context request validation.
|
||||
*
|
||||
* The home location, the travel overrides, and the backfill span. Coordinates
|
||||
* are bounded to valid lat/lon; the labels are length-capped plaintext (a city
|
||||
* name, never a quasi-identifier). Dates are strict YYYY-MM-DD. The shared
|
||||
* shapes are reused by the OpenAPI registry so the wire contract stays
|
||||
* single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Strict YYYY-MM-DD calendar day. */
|
||||
export const dayStringSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
|
||||
|
||||
const latSchema = z.number().gte(-90).lte(90);
|
||||
const lonSchema = z.number().gte(-180).lte(180);
|
||||
const labelSchema = z.string().trim().min(1).max(200);
|
||||
|
||||
/** A coarse home location set in settings (city-level granularity). */
|
||||
export const homeLocationSchema = z
|
||||
.object({
|
||||
lat: latSchema,
|
||||
lon: lonSchema,
|
||||
label: labelSchema,
|
||||
/** IANA timezone (anchors the stored day-key). */
|
||||
timezone: z.string().trim().min(1).max(64),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type HomeLocationInput = z.infer<typeof homeLocationSchema>;
|
||||
|
||||
/** A manual travel override covering an inclusive date range. */
|
||||
export const travelLocationSchema = z
|
||||
.object({
|
||||
startDate: dayStringSchema,
|
||||
endDate: dayStringSchema,
|
||||
lat: latSchema,
|
||||
lon: lonSchema,
|
||||
label: labelSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.startDate <= v.endDate, {
|
||||
message: "startDate must be on or before endDate",
|
||||
path: ["endDate"],
|
||||
});
|
||||
|
||||
export type TravelLocationInput = z.infer<typeof travelLocationSchema>;
|
||||
|
||||
/** A backfill request: fetch + store the environment rows over a date range. */
|
||||
export const environmentBackfillSchema = z
|
||||
.object({
|
||||
startDate: dayStringSchema,
|
||||
endDate: dayStringSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.startDate <= v.endDate, {
|
||||
message: "startDate must be on or before endDate",
|
||||
path: ["endDate"],
|
||||
});
|
||||
|
||||
export type EnvironmentBackfillInput = z.infer<
|
||||
typeof environmentBackfillSchema
|
||||
>;
|
||||
|
||||
/** Geocoding search query (`?q=`). */
|
||||
export const geocodeQuerySchema = z
|
||||
.object({ q: z.string().trim().min(1).max(120) })
|
||||
.strict();
|
||||
@@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in new issue
Block a user