mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'feat/v125-env-location-conservative' into release/v1.25.0
This commit is contained in:
commit
4f39058629
16 files changed
+353
-100
No files matched your search
+10
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user