fix(environment): fill a day before the home only inside a location period

The past-days backfill placed every day without a row at the current home,
also before the home was set. Weather and air of a place the person was not
at would flow into correlations, the Coach and the day view as fact. A day
before homeSince is now filled only when a location period covers it; with
no homeSince stored the home still covers every day. Days without a place
are not counted in the progress.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:02:38 +02:00
1 parent d81a6ddf25
commit 36dbfb8af7
4 files changed
+79 -28

No files matched your search

+7 -5
View File
@@ -45,11 +45,13 @@ an earlier fold computed from part of a day.
Location & weather (on by default), attributions beside the values, and a
dashboard chip on a day with high pollen, a warm night or very poor air.
- **Air quality for the past.** A background job fills air quality, pollen
and UV for every earlier local day with an entry: after the update, when
the home is set and when air quality is switched on. A trip day is fetched
at the trip's place, other days at the home, also before it was set. The
source reaches back to 2013 in Europe and to August 2022 elsewhere, pollen
from 2021 in Europe only; older days stay empty. Nearby days share one
and UV for earlier local days with an entry: after the update, when the
home is set and when air quality is switched on. A day inside a location
period is fetched at that place, a day from the home's effective date on
at the home; a day before it with no period stays empty rather than
borrowing a place the person was not at. The source reaches back to 2013
in Europe and to August 2022 elsewhere, pollen from 2021 in Europe only;
older days stay empty. Nearby days share one
request of up to 90 days, a run holds itself to 300 of the account's 400
daily calls and half of each instance window, and a long history spreads
over several nights. Location & weather shows the progress (migration
@@ -135,13 +135,27 @@ describe("resolveHistoryLocation", () => {
});
});
it("places a day before the home was set at the home", () => {
expect(resolveHistoryLocation("2020-03-01", home, [trip])).toMatchObject({
it("places a day from the home's effective date on at the home", () => {
expect(resolveHistoryLocation("2026-09-01", home, [trip])).toMatchObject({
lat: 51.5,
source: "HOME",
});
});
it("leaves a day before the home was set without a place, unless a period covers it", () => {
expect(resolveHistoryLocation("2026-08-31", home, [trip])).toBeNull();
expect(resolveHistoryLocation("2020-03-01", home, [trip])).toBeNull();
expect(resolveHistoryLocation("2024-07-14", home, [trip])).toMatchObject({
source: "TRAVEL",
});
});
it("places every day at a home stored without an effective date", () => {
expect(
resolveHistoryLocation("2015-01-01", { ...home, since: null }, []),
).toMatchObject({ lat: 51.5, source: "HOME" });
});
it("places nothing without a home or a period", () => {
expect(resolveHistoryLocation("2020-03-01", null, [trip])).toBeNull();
});
+13 -7
View File
@@ -15,13 +15,17 @@
* (only the air-quality part is fetched then, as the nightly gap fill
* does);
* - otherwise in the dated location period that covers it, else at the
* home. Unlike the nightly resolver this also places days before
* `homeSince` at the home: those are exactly the days the history is for,
* and a period entered for a trip still wins. Such a day gets its weather
* row with the air quality, because the air quality is stored on it.
* home, but the home only from `homeSince` on (or on every day when no
* `homeSince` is stored). A day before the home was set is never placed
* there: the weather and air of a place the person was not at would be
* wrong data in the correlations, the Coach and the day view, and an
* empty day is honest where a wrong one is not. Such a day is filled
* only when a period covers it. A day without a row gets its weather row
* with the air quality, because the air quality is stored on it.
*
* A day inside a period whose sealed location does not open is skipped (fail
* closed, as in the service), and so is a day with no place at all. A day the
* closed, as in the service), and so is a day with no place at all; neither
* counts towards the progress, which counts only days that can be filled. A day the
* source does not reach at its location (before 2013 in Europe, before August
* 2022 elsewhere, `earliestAirQualityDay`) is neither fetched nor counted: it
* stays without a value, which says nothing about the air, rather than
@@ -189,7 +193,9 @@ export function historyRequestFits(
/**
* Where a day without a row is placed: the period that covers it, else the
* home, also before `homeSince` (see the header). Pure; exported for tests.
* home from `homeSince` on, or on every day when no `homeSince` is stored
* (see the header). Null for a day before the home with no period. Pure;
* exported for tests.
*/
export function resolveHistoryLocation(
day: string,
@@ -201,7 +207,7 @@ export function resolveHistoryLocation(
return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" };
}
}
if (home) {
if (home && (home.since === null || day >= home.since)) {
return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" };
}
return null;
@@ -4,7 +4,10 @@
*
* - every past local day with an entry is filled: a day with a weather row
* gets its air quality, a day without one gets both, a day of a trip is
* fetched at the trip's place, a day before the home was set at the home;
* fetched at the trip's place, a day from the home's effective date at
* the home;
* - a day before the home was set is filled only inside a location period;
* without one it stays empty and is not counted;
* - a day before the source reaches stays without a row and is not counted,
* and the last week is left to the nightly fetch;
* - nearby days share one request, the coordinates sent are the coarse
@@ -32,6 +35,7 @@ const OWNER = "aq-history-owner";
const NOW = new Date("2026-10-09T03:00:00Z");
const BOCHUM = { lat: 51.5, lon: 7.2, label: "Bochum" };
const LISBON = { lat: 38.7, lon: -9.1, label: "Lisbon" };
const ESSEN = { lat: 51.5, lon: 7.0, label: "Essen" };
function daysBetween(start: string, end: string): string[] {
const out: string[] = [];
@@ -108,8 +112,8 @@ beforeEach(async () => {
timezone: "Europe/Berlin",
homeLocationEncrypted: sealLocation(BOCHUM),
homeTimezone: "Europe/Berlin",
// Set recently: every day below lies before it.
homeSince: new Date("2026-09-01T00:00:00Z"),
// Days before it are filled only inside a location period.
homeSince: new Date("2025-01-01T00:00:00Z"),
modulePreferencesJson: { environment: true },
},
});
@@ -121,16 +125,27 @@ beforeEach(async () => {
locationEncrypted: sealLocation(LISBON),
},
});
// Entries: one before the source reaches, two nearby days in 2020, one on
// a trip, one on a day with a weather row lacking air quality, one on a
// day already done, a mood entry, and one inside the last week.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER,
startDate: "2020-03-01",
endDate: "2020-03-10",
locationEncrypted: sealLocation(ESSEN),
},
});
// Entries: one before the source reaches, two nearby days on a 2020 trip,
// one before the home with no trip, one on a 2024 trip, one on a day with a
// weather row lacking air quality, one on a day already done, one at home
// without a row, a mood entry, and one inside the last week.
for (const day of [
"2012-06-01",
"2020-03-01",
"2020-03-05",
"2020-06-01",
"2024-07-05",
"2025-05-01",
"2025-06-01",
"2025-09-10",
"2026-10-06",
]) {
await measurementOn(day);
@@ -185,8 +200,8 @@ describe("air-quality history backfill", () => {
const result = await runAirQualityHistory(OWNER, { now: NOW });
expect(result).toMatchObject({
status: "complete",
total: 5,
done: 5,
total: 6,
done: 6,
remaining: 0,
});
@@ -200,16 +215,29 @@ describe("air-quality history backfill", () => {
"2024-07-05",
"2025-05-01",
"2025-06-01",
"2025-09-10",
]);
// Before the home and outside every period: no row, not counted.
expect(rows.find((r) => r.date === "2020-06-01")).toBeUndefined();
for (const row of rows) expect(row.aqFetchedAt).not.toBeNull();
const byDate = new Map(rows.map((r) => [r.date, r]));
// A day before the home was set: placed at the home, weather and air.
// A day before the home was set, inside a period: at the period's place.
expect(byDate.get("2020-03-01")).toMatchObject({
source: "HOME",
source: "TRAVEL",
pm25Mean: 12,
tempMax: 5,
lat: null,
});
expect(
openLocation(byDate.get("2020-03-01")!.locationEncrypted!),
).toMatchObject({ lat: 51.5, lon: 7.0 });
// A day after the home was set, without a row: at the home.
const home = byDate.get("2025-09-10")!;
expect(home).toMatchObject({ source: "HOME", pm25Mean: 12, tempMax: 5 });
expect(openLocation(home.locationEncrypted!)).toMatchObject({
lat: 51.5,
lon: 7.2,
});
// Pollen is not asked for before 2021, so it stays null, not zero.
expect(byDate.get("2020-03-01")!.pollenBirchMax).toBeNull();
// A trip day: at the trip's place.
@@ -238,6 +266,7 @@ describe("air-quality history backfill", () => {
u.searchParams.get("end_date"),
]),
).toEqual([
["2025-09-10", "2025-09-10"],
["2025-05-01", "2025-05-01"],
["2024-07-05", "2024-07-05"],
["2020-03-01", "2020-03-05"],
@@ -254,12 +283,12 @@ describe("air-quality history backfill", () => {
sent
.filter((u) => u.pathname === "/v1/archive")
.map((u) => u.searchParams.get("start_date")),
).toEqual(["2024-07-05", "2020-03-01"]);
).toEqual(["2025-09-10", "2024-07-05", "2020-03-01"]);
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect(user.environmentAqHistoryJson).toMatchObject({
total: 5,
done: 5,
total: 6,
done: 6,
complete: true,
});
@@ -284,7 +313,7 @@ describe("air-quality history backfill", () => {
expect(fetchSpy).not.toHaveBeenCalled();
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect(user.environmentAqHistoryJson).toMatchObject({
total: 5,
total: 6,
done: 1,
complete: false,
});