Merge feat/v125-environmental into release/v1.25.0

Environmental context module (migration 0217).
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:02:36 +02:00
commit a1b4f0d00e
45 files changed
+2725 -64

No files matched your search

@@ -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,
+4
View File
@@ -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