"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 { 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; /** 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; }; 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("/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: () => toast.error(t("settings.sections.environment.searchError")), }); const searchTravel = useMutation({ mutationKey: queryKeys.environmentGeocode("travel"), mutationFn: () => geocode(travelQuery), onSuccess: (data) => { setTravelResults(data.results); setTravelSearched(true); }, 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(""); setHomeSearched(false); 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(""); setTravelSearched(false); 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: (startDate: string) => apiPost("/api/environment/backfill", { startDate, endDate: backfillEnd, }), onSuccess: () => { invalidate(); toast.success(t("settings.sections.environment.backfillQueued")); }, onError: () => toast.error(t("settings.sections.environment.backfillError")), }); 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 = 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: home.since.slice(0, 10), })}

)}
) : (

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

)}
{ 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 && (

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

)} {homeResults.length > 0 && (
    {homeResults.map((r) => (
  • {r.label}
  • ))}
)}
{/* Travel overrides */}

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

{data && data.travel.length > 0 ? (
    {data.travel.map((tr) => (
  • {tr.label} · {tr.startDate} – {tr.endDate}
  • ))}
) : (

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

)}
{ 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 && (

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

)} {travelResults.length > 0 && (
    {travelResults.map((r) => (
  • {r.label}
  • ))}
)} {travelLoc && (
setTravelStart(e.target.value)} />
setTravelEnd(e.target.value)} />
)}
{/* Backfill */}

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

setBackfillStart(e.target.value)} />
setBackfillEnd(e.target.value)} />
{data && (

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

)}
{/* Attribution (CC BY 4.0, required) */}

{data?.attribution ?? "Weather data by Open-Meteo.com"}

); }