From 6aad4a465e57a52ffe688b12945fe9d8cdfecfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 17:35:45 +0200 Subject: [PATCH 1/3] feat(environment): add home effective-from and reserve a DEVICE location source Add `User.homeSince` (nullable effective-from instant) and a `DEVICE` value to the location-source enum (migration 0219). `homeSince` records when the home location became effective so day resolution can attribute a past day to the home only on/after that instant, rather than to the current home for every past day. Existing accounts with a home get `homeSince` stamped to the migration instant; their deep past is filled by explicit location periods instead. `DEVICE` is reserved for a future client-supplied coarse per-day location: the enum value and the wire contract accept it now so the resolver can rank it later without rework. Nothing writes it server-side yet. --- .../0219_v125_env_home_since/migration.sql | 36 +++++++++++++++++++ prisma/schema.prisma | 26 ++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/0219_v125_env_home_since/migration.sql diff --git a/prisma/migrations/0219_v125_env_home_since/migration.sql b/prisma/migrations/0219_v125_env_home_since/migration.sql new file mode 100644 index 000000000..16c2453d2 --- /dev/null +++ b/prisma/migrations/0219_v125_env_home_since/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 073e17c88..3f82e6ac2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") From c2541354061dad2a7d932f2a779124b73a8650a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 17:36:14 +0200 Subject: [PATCH 2/3] feat(environment): resolve past days conservatively, never guessing from home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the per-day location resolution so it can no longer fabricate weather for days before the home was set. Precedence, first hit wins: 1. an explicit dated location period (a trip, or a corrected stretch of history) covering the day; 2. DEVICE — a reserved slot for a future client-supplied coarse per-day location (ranks between the period and home; not produced server-side yet); 3. the home location, but only for days on/after its effective-from date; 4. otherwise the day is skipped. The home route stamps `homeSince` whenever the home is set/updated and returns it. The overview exposes it. Backfill bounds are now optional and default to the conservative `[homeSince .. today]` span instead of a fixed reach onto the current home; days before `homeSince` still resolve to a skip in the worker, so even a wider explicit start cannot back-fill weather for the pre-home past. To reach further back a user adds an explicit location period for that range. Add unit coverage for the precedence (including the effective-from boundary and the skip-not-guess rule) and the default backfill range. --- src/app/api/environment/backfill/route.ts | 44 +++++-- src/app/api/environment/home/route.ts | 6 + src/app/api/environment/route.ts | 4 + src/lib/environment/__tests__/service.test.ts | 107 ++++++++++++++++++ src/lib/environment/service.ts | 53 +++++++-- src/lib/jobs/environment-fetch.ts | 11 +- src/lib/validations/environment.ts | 13 ++- 7 files changed, 209 insertions(+), 29 deletions(-) create mode 100644 src/lib/environment/__tests__/service.test.ts diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts index 61e6b117b..1a02e93aa 100644 --- a/src/app/api/environment/backfill/route.ts +++ b/src/app/api/environment/backfill/route.ts @@ -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, diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index 46bd6443e..ded1c12e5 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -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(), }, }); }); diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts index 3d393384c..2d6e1ceb2 100644 --- a/src/app/api/environment/route.ts +++ b/src/app/api/environment/route.ts @@ -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; diff --git a/src/lib/environment/__tests__/service.test.ts b/src/lib/environment/__tests__/service.test.ts new file mode 100644 index 000000000..f9fb1c86d --- /dev/null +++ b/src/lib/environment/__tests__/service.test.ts @@ -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" }); + }); +}); diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index 59962bf73..2208e547d 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -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; diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts index 2f5948bbb..97e865ecb 100644 --- a/src/lib/jobs/environment-fetch.ts +++ b/src/lib/jobs/environment-fetch.ts @@ -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 diff --git a/src/lib/validations/environment.ts b/src/lib/validations/environment.ts index ee2c65ee3..20a8db9ff 100644 --- a/src/lib/validations/environment.ts +++ b/src/lib/validations/environment.ts @@ -48,14 +48,19 @@ export const travelLocationSchema = z export type TravelLocationInput = z.infer; -/** 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"], }); From bacd591dee928af928f5a501d5aa1c0850377340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 17:36:21 +0200 Subject: [PATCH 3/3] feat(environment): frame overrides as location periods and surface effective-from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Environment now reads the override list as "location periods": a place plus a date range that covers a trip OR corrects a stretch of earlier history, overriding the home for the days it spans. Show the home's effective-from date and prefill the backfill start with it, so the default span matches what the server will actually store. Strings updated across all six locales. --- messages/de.json | 19 ++++----- messages/en.json | 19 ++++----- messages/es.json | 19 ++++----- messages/fr.json | 19 ++++----- messages/it.json | 19 ++++----- messages/pl.json | 19 ++++----- .../settings/environment-section.tsx | 39 +++++++++++++------ 7 files changed, 88 insertions(+), 65 deletions(-) diff --git a/messages/de.json b/messages/de.json index d971d07b8..c20c079b3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } diff --git a/messages/en.json b/messages/en.json index 5c88e09b7..f894cbae5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/messages/es.json b/messages/es.json index 82610e32b..3a1e009ab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" } } diff --git a/messages/fr.json b/messages/fr.json index a7a2052d7..c190687f2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" } } diff --git a/messages/it.json b/messages/it.json index 94885be49..dbed40ed6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" } } diff --git a/messages/pl.json b/messages/pl.json index 70a7fea64..b8e0e201e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" } } diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 68cc87b37..5f66bc2fb 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -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 (
@@ -207,11 +215,20 @@ export function EnvironmentSection() {

{home ? ( -

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

+
+

+ {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")} @@ -394,7 +411,7 @@ export function EnvironmentSection() { setBackfillStart(e.target.value)} />

@@ -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")}