"use client"; /** * `` — 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 { showErrorToast } from "@/lib/errors/error-toast"; import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react"; import { TravelSuggestions } from "@/components/settings/environment-travel-suggestions"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useAuth } from "@/hooks/use-auth"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { formatDateTime } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { apiDelete, apiGet, apiPatch, apiPost, apiPut, } from "@/lib/api/api-fetch"; import { pollenMax, type PollenByKind } from "@/lib/environment/day-flags"; import { queryKeys } from "@/lib/query-keys"; import { DEFAULT_TIMEZONE, detectBrowserTimezone, userDayKey, validTimezoneOr, } from "@/lib/tz/format"; import { EmptyState } from "@/components/ui/empty-state"; 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; /** ISO instant the home became effective (drives the conservative backfill * default + the "tracked since" label). */ since: string | null; } | null; travel: TravelDTO[]; context: { days: number; latestDate: string | null; latestFetchedAt: string | null; }; /** Set when the background weather fetch for this account recently gave up. * A zero day count alone cannot distinguish "nothing to store yet" from * "every run failed", so the count is never rendered on its own. */ lastFetchFailure: { lastFailedAt: string; failures: number } | null; attribution: string; /** v1.42 — the air-quality part: switches and coverage. */ airQuality: { enabled: boolean; operatorDisabled: boolean; days: number; latestDate: string | null; domain: string | null; /** The history backfill's progress; null before its first run. */ history: { total: number; done: number; complete: boolean; checkedAt: string; } | null; }; /** v1.42 — the newest stored day; `airQuality` null while off/unfetched. */ latestDay: { date: string; tempMin: number | null; tempMax: number | null; apparentMax: number | null; airQuality: { pm25Mean: number | null; pm10Mean: number | null; no2Mean: number | null; o3Max8h: number | null; eaqiMax: number | null; uvIndexMax: number | null; dustMax: number | null; pollen: PollenByKind; } | null; } | null; /** v1.42 — every attribution line, weather first. */ attributions: string[]; } /** One value of the newest day, or a dash when the feed did not cover it. */ function envValue(value: number | null, unit: string, digits = 0): string { if (value == null) return "–"; const rounded = Number(value.toFixed(digits)); return unit ? `${rounded} ${unit}` : String(rounded); } export function EnvironmentSection() { const { t, tCount } = useTranslations(); const fmt = useFormatters(); const { user } = useAuth(); const queryClient = useQueryClient(); // Date defaults are the user's own days. The UTC day was tomorrow for // anyone east of UTC in the small hours and yesterday west of it in the // evening. const tz = validTimezoneOr( user?.timezone ?? detectBrowserTimezone(), DEFAULT_TIMEZONE, ); const todayKey = () => userDayKey(new Date(), tz); const enabled = user?.modules?.environment === true; const overview = useQuery({ queryKey: queryKeys.environment(), enabled, queryFn: () => apiGet("/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([]); // Tracks whether a geocode has completed for the current query so a // zero-result search can surface a "no matches" line instead of silence. const [homeSearched, setHomeSearched] = useState(false); const [travelQuery, setTravelQuery] = useState(""); const [travelResults, setTravelResults] = useState([]); const [travelSearched, setTravelSearched] = useState(false); const [travelLoc, setTravelLoc] = useState(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); setHomeSearched(true); }, onError: (failure) => showErrorToast(t("settings.sections.environment.searchError"), { cause: failure, }), }); const searchTravel = useMutation({ mutationKey: queryKeys.environmentGeocode("travel"), mutationFn: () => geocode(travelQuery), onSuccess: (data) => { setTravelResults(data.results); setTravelSearched(true); }, onError: (failure) => showErrorToast(t("settings.sections.environment.searchError"), { cause: failure, }), }); 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(""); setHomeSearched(false); invalidate(); toast.success(t("settings.sections.environment.homeSaved")); }, onError: (failure) => showErrorToast(t("settings.sections.environment.saveError"), { cause: failure, }), }); 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(""); setTravelSearched(false); invalidate(); toast.success(t("settings.sections.environment.travelAdded")); }, onError: (failure) => showErrorToast(t("settings.sections.environment.saveError"), { cause: failure, }), }); const removeTravel = useMutation({ mutationKey: queryKeys.environment(), mutationFn: (id: string) => apiDelete(`/api/environment/travel/${id}`), onSuccess: () => { invalidate(); toast.success(t("settings.sections.environment.travelRemoved")); }, onError: (failure) => showErrorToast(t("settings.sections.environment.saveError"), { cause: failure, }), }); const setAirQuality = useMutation({ mutationKey: queryKeys.environment(), mutationFn: (airQualityEnabled: boolean) => apiPatch("/api/environment/preferences", { airQualityEnabled }), // The switch itself shows the new state; no toast on success. onSuccess: () => invalidate(), onError: (failure) => showErrorToast(t("settings.sections.environment.airQuality.saveError"), { cause: failure, }), }); const backfill = useMutation({ mutationKey: queryKeys.environment(), mutationFn: (startDate: string) => apiPost("/api/environment/backfill", { startDate, endDate: backfillEnd, }), onSuccess: () => { invalidate(); toast.success(t("settings.sections.environment.backfillQueued")); }, onError: (failure) => showErrorToast(t("settings.sections.environment.backfillError"), { cause: failure, }), }); if (!enabled) { return (

{t("settings.sections.environment.disabledHint")}

); } const data = overview.data; const home = data?.home ?? null; // Conservative default: backfill starts at the home's effective-from date. The // user can still type an earlier start, but pre-home days resolve to SKIP on // the server — to fill the deep past they add an explicit location period. const effectiveBackfillStart = // `since` stays on the server's day for the home (a UTC day key in the // environment module), so the default start matches what the worker // compares against. backfillStart || home?.since?.slice(0, 10) || ""; return (
{/* Home location */}

{t("settings.sections.environment.home.description")}

{home ? (

{t("settings.sections.environment.home.current", { label: home.label ?? `${home.lat}, ${home.lon}`, })}

{home.since && (

{t("settings.sections.environment.home.since", { date: fmt.dateShortSmartCalendar(home.since.slice(0, 10)), })}

)}
) : ( )}
{ e.preventDefault(); if (homeQuery.trim().length > 0 && !searchHome.isPending) { searchHome.mutate(); } }} > { setHomeQuery(e.target.value); setHomeSearched(false); }} placeholder={t("settings.sections.environment.searchPlaceholder")} aria-label={t("settings.sections.environment.searchPlaceholder")} />
{homeSearched && !searchHome.isPending && homeResults.length === 0 && ( )} {homeResults.length > 0 && (
    {homeResults.map((r) => (
  • {r.label}
  • ))}
)}
{/* Travel overrides */}

{t("settings.sections.environment.travel.description")}

{ setTravelStart(startDate); setTravelEnd(endDate); setTravelQuery(place); setTravelLoc(null); setTravelSearched(false); }} />
{data && data.travel.length > 0 ? (
    {data.travel.map((tr) => (
  • {tr.label} {t("settings.sections.environment.travel.range", { start: fmt.dateShortSmartCalendar(tr.startDate), end: fmt.dateShortSmartCalendar(tr.endDate), })} } pending={removeTravel.isPending} title={t( "settings.sections.environment.travel.removeConfirmTitle", )} body={t( "settings.sections.environment.travel.removeConfirmBody", )} confirmLabel={t( "settings.sections.environment.travel.remove", )} onConfirm={() => removeTravel.mutate(tr.id)} />
  • ))}
) : ( )}
{ e.preventDefault(); if (travelQuery.trim().length > 0 && !searchTravel.isPending) { searchTravel.mutate(); } }} > { setTravelQuery(e.target.value); setTravelSearched(false); }} placeholder={t("settings.sections.environment.searchPlaceholder")} aria-label={t("settings.sections.environment.searchPlaceholder")} />
{travelSearched && !searchTravel.isPending && travelResults.length === 0 && ( )} {travelResults.length > 0 && (
    {travelResults.map((r) => (
  • {r.label}
  • ))}
)} {travelLoc && (
)}
{/* Backfill */}

{t("settings.sections.environment.backfill.description")}

{data && (

{t("settings.sections.environment.storedDays", { count: data.context.days, })}

)} {data?.lastFetchFailure && (

{tCount( "settings.sections.environment.fetchFailed", data.lastFetchFailure.failures, { count: data.lastFetchFailure.failures, when: formatDateTime(data.lastFetchFailure.lastFailedAt), }, )}

)} {/* The action row closes the card; the status lines sit above it. */}
{/* Air quality, pollen and UV (v1.42) */} {data && ( setAirQuality.mutate(next)} disabled={ data.airQuality.operatorDisabled || setAirQuality.isPending } aria-label={t( "settings.sections.environment.airQuality.toggle", )} /> } />

{t("settings.sections.environment.airQuality.description")}

{data.airQuality.operatorDisabled ? (

{t("settings.sections.environment.airQuality.operatorDisabled")}

) : data.airQuality.enabled ? (

{t("settings.sections.environment.airQuality.coverage", { count: data.airQuality.days, })} {data.airQuality.domain === "cams_global" ? ` ${t("settings.sections.environment.airQuality.pollenEuropeOnly")}` : ""}

{data.airQuality.history && data.airQuality.history.total > 0 && (

{data.airQuality.history.complete ? t( "settings.sections.environment.airQuality.historyComplete", { total: data.airQuality.history.total }, ) : t( "settings.sections.environment.airQuality.historyProgress", { done: data.airQuality.history.done, total: data.airQuality.history.total, }, )}

)} {data.latestDay?.airQuality && (

{t("settings.sections.environment.airQuality.latest", { date: fmt.dateShortSmartCalendar(data.latestDay.date), })}

{( [ [ "environment.fields.pm25", envValue(data.latestDay.airQuality.pm25Mean, "µg/m³"), ], [ "environment.fields.pm10", envValue(data.latestDay.airQuality.pm10Mean, "µg/m³"), ], [ "environment.fields.no2", envValue(data.latestDay.airQuality.no2Mean, "µg/m³"), ], [ "environment.fields.ozone8h", envValue(data.latestDay.airQuality.o3Max8h, "µg/m³"), ], [ "environment.fields.europeanAqi", envValue(data.latestDay.airQuality.eaqiMax, ""), ], [ "environment.fields.uvIndexMax", envValue(data.latestDay.airQuality.uvIndexMax, "", 1), ], [ "environment.fields.dustMax", envValue(data.latestDay.airQuality.dustMax, "µg/m³"), ], [ "environment.fields.pollenMax", envValue( pollenMax(data.latestDay.airQuality.pollen), "/m³", ), ], ] as const ).map(([labelKey, value]) => (
{t(labelKey)}
{value}
))}

{t("settings.sections.environment.airQuality.modelNote")}

)}
) : null}
)} {/* Attribution (CC BY 4.0 for Open-Meteo; Copernicus notice for the air-quality values), one line per source. */}
    {( data?.attributions ?? [ data?.attribution ?? "Weather data by Open-Meteo.com", ] ).map((line) => (
  • {line}
  • ))}
); }