mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(environment): resolve past days conservatively, never guessing from home
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.
This commit is contained in:
1 parent
6aad4a465e
commit
c254135406
7 files changed
+209
-29
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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