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"], });