Merge branch 'feat/v125-env-location-conservative' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:47:35 +02:00
commit 4f39058629
16 files changed
+353 -100

No files matched your search

+10 -9
View File
@@ -4292,8 +4292,8 @@
"select": "Auswählen",
"saveError": "Änderung konnte nicht gespeichert werden.",
"homeSaved": "Heimatort gespeichert.",
"travelAdded": "Reise-Ausnahme hinzugefügt.",
"travelRemoved": "Reise-Ausnahme entfernt.",
"travelAdded": "Ortszeitraum hinzugefügt.",
"travelRemoved": "Ortszeitraum entfernt.",
"backfillQueued": "Nacherfassung eingeplant.",
"backfillError": "Nacherfassung konnte nicht gestartet werden.",
"startDate": "Startdatum",
@@ -4301,20 +4301,21 @@
"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.",
"description": "Ein grober Standort auf Stadtebene – niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
"current": "Aktuell: {label}",
"none": "Noch kein Heimatort festgelegt."
"none": "Noch kein Heimatort festgelegt.",
"since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst."
},
"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.",
"title": "Ortszeiträume",
"description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
"none": "Noch keine Ortszeiträume.",
"add": "{label} hinzufügen",
"remove": "Reise-Ausnahme entfernen"
"remove": "Ortszeitraum entfernen"
},
"backfill": {
"title": "Vergangene Tage nacherfassen",
"description": "Wetter für einen vergangenen Zeitraum abrufen und speichern, damit ältere Tage in die Korrelationen einfließen.",
"description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.",
"run": "Nacherfassen"
}
}
+10 -9
View File
@@ -4292,8 +4292,8 @@
"select": "Select",
"saveError": "Could not save your change.",
"homeSaved": "Home location saved.",
"travelAdded": "Travel override added.",
"travelRemoved": "Travel override removed.",
"travelAdded": "Location period added.",
"travelRemoved": "Location period removed.",
"backfillQueued": "Backfill queued.",
"backfillError": "Could not start the backfill.",
"startDate": "Start date",
@@ -4301,20 +4301,21 @@
"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.",
"description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.",
"current": "Current: {label}",
"none": "No home location set yet."
"none": "No home location set yet.",
"since": "Weather tracked from {date} onward. Earlier days stay empty until you add a location period."
},
"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.",
"title": "Location periods",
"description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
"none": "No location periods yet.",
"add": "Add {label}",
"remove": "Remove travel override"
"remove": "Remove location period"
},
"backfill": {
"title": "Backfill past days",
"description": "Fetch and store weather for a past date range so older days can join the correlations.",
"description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.",
"run": "Backfill"
}
}
+10 -9
View File
@@ -4292,8 +4292,8 @@
"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.",
"travelAdded": "Periodo de ubicación añadido.",
"travelRemoved": "Periodo de ubicación eliminado.",
"backfillQueued": "Relleno programado.",
"backfillError": "No se pudo iniciar el relleno.",
"startDate": "Fecha de inicio",
@@ -4301,20 +4301,21 @@
"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.",
"description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y la luz diurna se obtienen para este lugar en los días sin un periodo de ubicación, desde la fecha en que lo configuras.",
"current": "Actual: {label}",
"none": "Aún no hay ubicación principal."
"none": "Aún no hay ubicación principal.",
"since": "Tiempo registrado desde {date}. Los días anteriores quedan vacíos hasta que añadas un periodo de ubicación."
},
"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.",
"title": "Periodos de ubicación",
"description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.",
"none": "Aún no hay periodos de ubicación.",
"add": "Añadir {label}",
"remove": "Eliminar excepción de viaje"
"remove": "Eliminar periodo de ubicación"
},
"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.",
"description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.",
"run": "Rellenar"
}
}
+10 -9
View File
@@ -4292,8 +4292,8 @@
"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.",
"travelAdded": "Période de localisation ajoutée.",
"travelRemoved": "Période de localisation supprimée.",
"backfillQueued": "Récupération planifiée.",
"backfillError": "Impossible de lancer la récupération.",
"startDate": "Date de début",
@@ -4301,20 +4301,21 @@
"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.",
"description": "Une localisation approximative à l'échelle de la ville, jamais un tracé précis. La météo et la lumière du jour sont récupérées pour ce lieu les jours sans période de localisation, à partir de la date où vous le définissez.",
"current": "Actuel : {label}",
"none": "Aucun lieu de référence défini."
"none": "Aucun lieu de référence défini.",
"since": "Météo enregistrée à partir du {date}. Les jours antérieurs restent vides jusqu'à ce que vous ajoutiez une période de localisation."
},
"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.",
"title": "Périodes de localisation",
"description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.",
"none": "Aucune période de localisation pour l'instant.",
"add": "Ajouter {label}",
"remove": "Supprimer l'exception de voyage"
"remove": "Supprimer la période de localisation"
},
"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.",
"description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.",
"run": "Récupérer"
}
}
+10 -9
View File
@@ -4292,8 +4292,8 @@
"select": "Seleziona",
"saveError": "Impossibile salvare la modifica.",
"homeSaved": "Posizione principale salvata.",
"travelAdded": "Eccezione di viaggio aggiunta.",
"travelRemoved": "Eccezione di viaggio rimossa.",
"travelAdded": "Periodo di posizione aggiunto.",
"travelRemoved": "Periodo di posizione rimosso.",
"backfillQueued": "Recupero pianificato.",
"backfillError": "Impossibile avviare il recupero.",
"startDate": "Data di inizio",
@@ -4301,20 +4301,21 @@
"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.",
"description": "Una posizione approssimativa a livello di città, mai un tracciato preciso. Meteo e luce diurna vengono recuperati per questo luogo nei giorni senza un periodo di posizione, a partire dalla data in cui lo imposti.",
"current": "Attuale: {label}",
"none": "Nessuna posizione principale impostata."
"none": "Nessuna posizione principale impostata.",
"since": "Meteo registrato dal {date}. I giorni precedenti restano vuoti finché non aggiungi un periodo di posizione."
},
"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.",
"title": "Periodi di posizione",
"description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.",
"none": "Nessun periodo di posizione.",
"add": "Aggiungi {label}",
"remove": "Rimuovi eccezione di viaggio"
"remove": "Rimuovi periodo di posizione"
},
"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.",
"description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.",
"run": "Recupera"
}
}
+10 -9
View File
@@ -4292,8 +4292,8 @@
"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.",
"travelAdded": "Dodano okres lokalizacji.",
"travelRemoved": "Usunięto okres lokalizacji.",
"backfillQueued": "Zaplanowano uzupełnianie.",
"backfillError": "Nie udało się rozpocząć uzupełniania.",
"startDate": "Data początkowa",
@@ -4301,20 +4301,21 @@
"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.",
"description": "Przybliżona lokalizacja na poziomie miasta – nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.",
"current": "Obecnie: {label}",
"none": "Nie ustawiono jeszcze lokalizacji domowej."
"none": "Nie ustawiono jeszcze lokalizacji domowej.",
"since": "Pogoda zapisywana od {date}. Wcześniejsze dni pozostają puste, dopóki nie dodasz okresu lokalizacji."
},
"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.",
"title": "Okresy lokalizacji",
"description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.",
"none": "Brak okresów lokalizacji.",
"add": "Dodaj {label}",
"remove": "Usuń wyjątek podróży"
"remove": "Usuń okres lokalizacji"
},
"backfill": {
"title": "Uzupełnij minione dni",
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji.",
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.",
"run": "Uzupełnij"
}
}
@@ -0,0 +1,36 @@
-- v1.25 (W-ENV) — conservative past-location handling for the environmental
-- context module.
--
-- Adds `users.home_since` (effective-from instant for the home location) and a
-- `DEVICE` value to the location-source enum. Day resolution attributes a past
-- day to the home location ONLY when the day is on/after `home_since`; earlier
-- days are skipped rather than fabricated from the current home. The deep past
-- is filled by explicit dated location periods instead.
--
-- `DEVICE` is the reserved forward path for a future client-supplied coarse
-- per-day location (iOS v2). The value exists so the resolver and the wire
-- contract already accept it; nothing writes it server-side yet.
--
-- Backfill of existing accounts: any row that already has a home location gets
-- `home_since` stamped to the migration instant. This is conservative — the
-- exact day a pre-upgrade account moved into its home is unknown, so history
-- before the upgrade is left to explicit location periods rather than guessed.
--
-- Additive; no existing weather row touched. Idempotent guards so reruns are
-- safe on prod.
--
-- Reversibility (down):
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "home_since";
-- -- (the `DEVICE` enum value cannot be dropped in place; it is inert until a
-- -- future client writes it, so a roll-back leaves it harmless.)
-- AlterTable: effective-from instant for the home location (nullable).
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_since" TIMESTAMP(3);
-- Backfill: stamp existing home rows so they resolve from the upgrade forward.
UPDATE "users"
SET "home_since" = CURRENT_TIMESTAMP
WHERE "home_lat" IS NOT NULL AND "home_since" IS NULL;
-- AlterEnum: reserve the DEVICE source for the iOS v2 forward path.
ALTER TYPE "environment_location_source" ADD VALUE IF NOT EXISTS 'DEVICE';
+20 -6
View File
@@ -63,6 +63,12 @@ model User {
homeLon Float? @map("home_lon")
homeLabel String? @map("home_label")
homeTimezone String? @map("home_timezone")
// v1.25 — effective-from instant for the home location. Stamped the moment a
// home is set/updated. Day resolution attributes a past day to home ONLY when
// the day is on/after this instant; earlier days are skipped (never fabricated
// from the current home). To fill the deep past the user adds an explicit
// dated location period instead. Backfill defaults its start to this instant.
homeSince DateTime? @map("home_since")
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
@@ -811,11 +817,16 @@ model User {
// ─── 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.)
/// user's configured home location; TRAVEL = an explicit dated location period
/// (a past trip OR a corrected stretch of history) that overrides home for the
/// days it covers; DEVICE = a future client-supplied coarse per-day location.
/// DEVICE is the priority forward path for the iOS v2 client: the value exists
/// so the resolver and the wire contract already accept it, but nothing writes
/// it server-side yet.
enum EnvironmentLocationSource {
HOME
TRAVEL
DEVICE
}
/// A per-day environmental observation (daily weather / daylight) for the
@@ -876,10 +887,13 @@ model EnvironmentContext {
@@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.
/// An explicit dated LOCATION PERIOD: for any day in `[startDate, endDate]` the
/// weather fetch resolves against this location instead of the home location.
/// Declared, not inferred — it dominates the home fallback so a stretch spent
/// elsewhere is not mis-attributed to the home city. Two uses share this one
/// model: a discrete trip away from home, AND the mechanism to correct history
/// (e.g. a whole-history home city for the years before the current home became
/// effective). Coarse location only. (Stored under `source = TRAVEL`.)
model EnvironmentTravelLocation {
id String @id @default(cuid())
userId String @map("user_id")
+32 -12
View File
@@ -21,7 +21,11 @@ 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 {
ENVIRONMENT_MAX_BACKFILL_DAYS,
defaultBackfillRange,
utcDayKey,
} from "@/lib/environment/service";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -49,7 +53,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { startDate, endDate } = parsed.data;
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { homeLat: true, homeSince: true },
});
if (profile?.homeLat == null) {
return apiError("Set a home location before backfilling.", 409, {
errorCode: "environment.no_home",
});
}
// Resolve the effective span. An omitted bound defaults to the conservative
// [homeSince .. today] range — never a fixed reach into the past onto the
// current home. Days before homeSince still resolve to SKIP in the worker, so
// even an explicit wider start cannot fabricate weather for the pre-home past.
const today = utcDayKey(new Date());
const fallback = defaultBackfillRange(profile.homeSince) ?? {
startDate: today,
endDate: today,
};
const startDate = parsed.data.startDate ?? fallback.startDate;
const endDate = parsed.data.endDate ?? fallback.endDate;
if (startDate > endDate) {
return apiError("startDate must be on or before endDate", 422, {
errorCode: "environment.invalid",
});
}
const [sy, sm, sd] = startDate.split("-").map(Number);
const [ey, em, ed] = endDate.split("-").map(Number);
const spanDays =
@@ -64,16 +94,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
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,
+6
View File
@@ -41,11 +41,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const { lat, lon, label, timezone } = parsed.data;
// Stamp the effective-from instant: from now on this home resolves days; days
// before it stay un-attributed (filled via explicit location periods). Every
// set/update re-stamps — the home is "effective from the moment it is set".
const homeSince = new Date();
const home = {
homeLat: roundCoarse(lat),
homeLon: roundCoarse(lon),
homeLabel: label,
homeTimezone: timezone,
homeSince,
};
await prisma.user.update({ where: { id: user.id }, data: home });
@@ -62,6 +67,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
lon: home.homeLon,
label: home.homeLabel,
timezone: home.homeTimezone,
since: homeSince.toISOString(),
},
});
});
+4
View File
@@ -29,6 +29,7 @@ export const GET = apiHandler(async () => {
homeLon: true,
homeLabel: true,
homeTimezone: true,
homeSince: true,
timezone: true,
},
}),
@@ -59,6 +60,9 @@ export const GET = apiHandler(async () => {
lon: profile.homeLon,
label: profile.homeLabel,
timezone: profile.homeTimezone ?? profile.timezone,
// Effective-from instant: the settings surface uses it to label the
// home and to prefill the backfill start (conservative default range).
since: profile.homeSince?.toISOString() ?? null,
}
: null;
+28 -11
View File
@@ -52,6 +52,9 @@ interface EnvironmentOverview {
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: {
@@ -162,9 +165,9 @@ export function EnvironmentSection() {
const backfill = useMutation({
mutationKey: queryKeys.environment(),
mutationFn: () =>
mutationFn: (startDate: string) =>
apiPost("/api/environment/backfill", {
startDate: backfillStart,
startDate,
endDate: backfillEnd,
}),
onSuccess: () => {
@@ -192,6 +195,11 @@ export function EnvironmentSection() {
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 (
<div className="space-y-6">
@@ -207,11 +215,20 @@ export function EnvironmentSection() {
</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>
<div className="space-y-1">
<p className="text-sm" data-slot="environment-home-current">
{t("settings.sections.environment.home.current", {
label: home.label ?? `${home.lat}, ${home.lon}`,
})}
</p>
{home.since && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.environment.home.since", {
date: home.since.slice(0, 10),
})}
</p>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.home.none")}
@@ -394,7 +411,7 @@ export function EnvironmentSection() {
<Input
id="env-backfill-start"
type="date"
value={backfillStart}
value={effectiveBackfillStart}
onChange={(e) => setBackfillStart(e.target.value)}
/>
</div>
@@ -413,11 +430,11 @@ export function EnvironmentSection() {
type="button"
disabled={
!home ||
backfillStart.length === 0 ||
backfillStart > backfillEnd ||
effectiveBackfillStart.length === 0 ||
effectiveBackfillStart > backfillEnd ||
backfill.isPending
}
onClick={() => backfill.mutate()}
onClick={() => backfill.mutate(effectiveBackfillStart)}
>
{t("settings.sections.environment.backfill.run")}
</Button>
@@ -0,0 +1,107 @@
/**
* v1.25 (W-ENV) — conservative location resolution.
*
* Pins the precedence (explicit period → [DEVICE, reserved] → home-on/after-
* effective-from → SKIP) and the conservative default backfill range. The whole
* point is that a past day is NEVER attributed to the current home unless the
* home was already effective on that day; otherwise it is skipped and left to an
* explicit location period. `@/lib/db` + the Open-Meteo client are stubbed so
* the pure functions import without touching Postgres or the network.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/environment/open-meteo", () => ({
fetchDailyEnvironment: vi.fn(),
}));
import {
resolveLocationForDay,
defaultBackfillRange,
utcDayKey,
} from "../service";
const HOME = {
lat: 52.5,
lon: 13.4,
label: "Home City",
timezone: "Europe/Berlin",
since: "2026-03-01",
};
const PERIOD = {
startDate: "2026-01-10",
endDate: "2026-01-20",
lat: 28.1,
lon: -15.4,
label: "Past City",
};
describe("resolveLocationForDay (conservative)", () => {
it("uses an explicit location period covering the day", () => {
const r = resolveLocationForDay("2026-01-15", HOME, [PERIOD]);
expect(r).toEqual({
lat: PERIOD.lat,
lon: PERIOD.lon,
label: PERIOD.label,
source: "TRAVEL",
});
});
it("honours an explicit period even before the home was effective", () => {
// The period (Jan) predates homeSince (Mar) — it must still win, since it is
// the mechanism for correcting history.
const r = resolveLocationForDay("2026-01-12", HOME, [PERIOD]);
expect(r?.source).toBe("TRAVEL");
});
it("uses home for a day on/after homeSince when no period covers it", () => {
const r = resolveLocationForDay("2026-03-02", HOME, [PERIOD]);
expect(r).toEqual({
lat: HOME.lat,
lon: HOME.lon,
label: HOME.label,
source: "HOME",
});
});
it("uses home on the exact homeSince day (inclusive boundary)", () => {
expect(resolveLocationForDay("2026-03-01", HOME, [])?.source).toBe("HOME");
});
it("SKIPS a day before homeSince rather than fabricating from current home", () => {
// Feb is before homeSince (Mar) and uncovered by any period → no row.
expect(resolveLocationForDay("2026-02-15", HOME, [])).toBeNull();
});
it("skips when no home and no covering period", () => {
expect(resolveLocationForDay("2026-05-01", null, [])).toBeNull();
});
it("skips when home has no effective-from date", () => {
const homeNoSince = { ...HOME, since: null };
expect(resolveLocationForDay("2026-05-01", homeNoSince, [])).toBeNull();
});
});
describe("defaultBackfillRange (conservative)", () => {
it("spans [homeSince .. today]", () => {
const since = new Date("2026-03-01T10:00:00Z");
const today = new Date("2026-06-28T00:00:00Z");
expect(defaultBackfillRange(since, today)).toEqual({
startDate: "2026-03-01",
endDate: "2026-06-28",
});
});
it("returns null without a home effective-from", () => {
expect(defaultBackfillRange(null)).toBeNull();
});
it("derives the start day-key from the homeSince instant", () => {
const since = new Date("2026-03-01T23:30:00Z");
expect(
defaultBackfillRange(since, new Date("2026-03-05T00:00:00Z")),
).toEqual({ startDate: utcDayKey(since), endDate: "2026-03-05" });
});
});
+45 -8
View File
@@ -6,11 +6,17 @@
* 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).
* Location precedence per day (first hit wins) — CONSERVATIVE:
* 1. an explicit dated LOCATION PERIOD (override) whose [startDate, endDate]
* covers the day (a trip, OR a corrected stretch of history);
* 2. DEVICE — reserved slot for a future client-supplied coarse per-day
* location (iOS v2); not produced server-side yet (see resolver comment);
* 3. the user's HOME location, but ONLY for days on/after `homeSince` (the
* instant the home became effective).
* A day before `homeSince`, or with no home and no covering period, is SKIPPED.
* The resolver never attributes a past day to the *current* home — that would
* fabricate weather for anyone who has moved/travelled. The deep past is filled
* by adding explicit dated location periods.
*
* Coarse location only — the resolver never sees finer than the rounded city
* coordinates stored on the user / override.
@@ -42,6 +48,8 @@ interface HomeLocation {
lon: number;
label: string;
timezone: string;
/** YYYY-MM-DD the home became effective; null ⇒ home resolves no day. */
since: string | null;
}
interface TravelOverride {
@@ -72,25 +80,51 @@ export function enumerateDays(start: string, end: string): string[] {
}
/**
* Resolve the location for one day: a covering travel override wins, else home,
* else null. Pure — exported for unit tests.
* Resolve the location for one day (CONSERVATIVE, first hit wins). Pure —
* exported for unit tests.
*/
export function resolveLocationForDay(
day: string,
home: HomeLocation | null,
travels: readonly TravelOverride[],
): ResolvedLocation | null {
// 1. An explicit dated location period covering the day wins. This is also
// the mechanism a user/operator uses to correct history: an explicit
// period for a past range is honoured even before the home was effective.
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) {
// 2. DEVICE — reserved priority forward path (iOS v2). A future client supplies
// a coarse per-day device location that would rank HERE, between the explicit
// period and the home fallback. Nothing produces it server-side yet, but the
// resolver signature and the EnvironmentLocationSource enum already accept a
// "DEVICE" source, so wiring it in later needs no rework of this precedence.
//
// 3. Home — ONLY on/after the day it became effective (`homeSince`). Never
// fabricate past weather from the *current* home for days before it was set;
// those days are skipped and left to an explicit period above.
if (home && home.since != null && day >= home.since) {
return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" };
}
return null;
}
/**
* The conservative default backfill span for an account: from the day its home
* became effective (`homeSince`) through `today`. Days before `homeSince` are
* deliberately excluded — they belong to explicit location periods, not to the
* current home. Returns null when no home has been set.
*/
export function defaultBackfillRange(
homeSince: Date | null,
today: Date = new Date(),
): { startDate: string; endDate: string } | null {
if (!homeSince) return null;
return { startDate: utcDayKey(homeSince), endDate: utcDayKey(today) };
}
/** 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}`;
@@ -125,6 +159,7 @@ export async function fetchAndStoreEnvironment(args: {
homeLon: true,
homeLabel: true,
homeTimezone: true,
homeSince: true,
timezone: true,
},
});
@@ -137,6 +172,8 @@ export async function fetchAndStoreEnvironment(args: {
lon: user.homeLon,
label: user.homeLabel ?? "Home",
timezone: user.homeTimezone ?? user.timezone,
// Effective-from day-key; null ⇒ home resolves no day (conservative).
since: user.homeSince ? utcDayKey(user.homeSince) : null,
}
: null;
+6 -5
View File
@@ -5,11 +5,12 @@
* 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).
* conservatively (explicit location period → home only on/after its effective
* date → else SKIP — never the current home for a pre-home past day) 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
+9 -4
View File
@@ -48,14 +48,19 @@ export const travelLocationSchema = z
export type TravelLocationInput = z.infer<typeof travelLocationSchema>;
/** A backfill request: fetch + store the environment rows over a date range. */
/**
* A backfill request: fetch + store the environment rows over a date range.
* Both bounds are optional — an omitted span defaults to the conservative
* `[homeSince .. today]` range on the server (never a fixed reach into the past
* onto the current home).
*/
export const environmentBackfillSchema = z
.object({
startDate: dayStringSchema,
endDate: dayStringSchema,
startDate: dayStringSchema.optional(),
endDate: dayStringSchema.optional(),
})
.strict()
.refine((v) => v.startDate <= v.endDate, {
.refine((v) => !v.startDate || !v.endDate || v.startDate <= v.endDate, {
message: "startDate must be on or before endDate",
path: ["endDate"],
});