diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf7b6363..442630c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/environment/__tests__/air-quality-history.test.ts b/src/lib/environment/__tests__/air-quality-history.test.ts index 23d62f31a..4b58cccb4 100644 --- a/src/lib/environment/__tests__/air-quality-history.test.ts +++ b/src/lib/environment/__tests__/air-quality-history.test.ts @@ -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(); }); diff --git a/src/lib/environment/air-quality-history.ts b/src/lib/environment/air-quality-history.ts index 5b515a674..7d2ea80a2 100644 --- a/src/lib/environment/air-quality-history.ts +++ b/src/lib/environment/air-quality-history.ts @@ -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; diff --git a/tests/integration/environment-air-quality-history.test.ts b/tests/integration/environment-air-quality-history.test.ts index ef377f916..b71bf55ae 100644 --- a/tests/integration/environment-air-quality-history.test.ts +++ b/tests/integration/environment-air-quality-history.test.ts @@ -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, });