From e9a0b3d9599902b1da1559956a152d7a4ef309d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:28:51 +0200 Subject: [PATCH] fix(dawarich): keep a boundary fragment from overwriting a complete day Dawarich groups the timeline by the day in its own account zone and sums only what lies inside the window. Reach a whole day past both ends of each request and keep only the chunk's own dates, so the cut edge days are discarded and chunks never overwrite each other. --- src/lib/dawarich/sync.ts | 69 ++++++++++++++++---- tests/integration/dawarich-context.test.ts | 76 ++++++++++++++++++++++ 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/lib/dawarich/sync.ts b/src/lib/dawarich/sync.ts index b1d6712ef..98f4e458e 100644 --- a/src/lib/dawarich/sync.ts +++ b/src/lib/dawarich/sync.ts @@ -46,8 +46,57 @@ const HOME_COUNTRY_WINDOW_DAYS = 365; /** How far before the range the visits request reaches. */ const VISIT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000; -/** Days per timeline request, inside Dawarich's 31-day limit. */ -const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 1; +/** + * Days kept per timeline request. Each request reaches one whole day past + * both ends of its chunk (see `timelineChunks`), so it spans the chunk plus + * two days, and a fall-back day is 25 hours long: chunk + 2 must stay under + * Dawarich's 31-day limit by more than an hour. + */ +const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 3; + +/** + * The timeline requests for `days`, each with the dates it is allowed to + * fill. + * + * Dawarich groups a timeline by the day in ITS user's zone (the timezone + * setting of the Dawarich account, `TIME_ZONE` by default), not by the zone + * the request names, and it only sums what lies inside the requested window. + * A window cut at the account's local midnight therefore answers the edge day + * in the instance's zone with a fragment of it whenever the two zones differ. + * Two rules keep a fragment out of the stored values: + * + * - each request reaches a whole day before and after its chunk, so every + * date of the chunk is covered end to end in any zone within a day of the + * account's; + * - only the chunk's own dates are kept from the answer, so the padded edge + * days (the fragments) are thrown away and no chunk can overwrite a date + * another chunk owns. + * + * The API offers no zone parameter and no per-track detail to regroup by, so + * the stored value for a date is the instance's day of that date. When the + * two zones agree, as on a typical self-host, that is exactly the account's + * local day. + */ +function timelineChunks( + days: readonly string[], + tz: string, +): Array<{ startAt: Date; endAt: Date; dates: Set }> { + const out: Array<{ startAt: Date; endAt: Date; dates: Set }> = []; + for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) { + const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS); + const { dayStart } = localDayWindow(shiftDateKey(chunk[0], -1), tz); + const { dayEnd } = localDayWindow( + shiftDateKey(chunk[chunk.length - 1], 1), + tz, + ); + out.push({ + startAt: dayStart, + endAt: new Date(dayEnd.getTime() - 1000), + dates: new Set(chunk), + }); + } + return out; +} export interface DawarichSyncResult { /** Days written this run (new or refreshed). */ @@ -137,16 +186,14 @@ export async function syncDawarichRange( let visits: Awaited> = []; let countries: DailyCountries | null = null; try { - for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) { - const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS); - const { dayStart } = localDayWindow(chunk[0], tz); - const { dayEnd } = localDayWindow(chunk[chunk.length - 1], tz); + for (const chunk of timelineChunks(days, tz)) { + const answered = await fetchTimeline( + connection, + chunk.startAt, + chunk.endAt, + ); timeline = timeline.concat( - await fetchTimeline( - connection, - dayStart, - new Date(dayEnd.getTime() - 1000), - ), + answered.filter((day) => chunk.dates.has(day.date)), ); } const rangeStart = localDayWindow(fromDate, tz).dayStart; diff --git a/tests/integration/dawarich-context.test.ts b/tests/integration/dawarich-context.test.ts index f91aba072..04a1e1855 100644 --- a/tests/integration/dawarich-context.test.ts +++ b/tests/integration/dawarich-context.test.ts @@ -134,6 +134,10 @@ function residency(year: number) { }; } +/** A timeline that answers from the window, set by the test that needs it. */ +let timelineAnswer: ((url: URL) => { status: number; body: unknown }) | null = + null; + function answer(req: IncomingMessage): { status: number; body: unknown } { const url = new URL(req.url ?? "/", "http://stub"); requests.push(url.pathname); @@ -144,6 +148,7 @@ function answer(req: IncomingMessage): { status: number; body: unknown } { case "/api/v1/users/me": return { status: 200, body: { email: "someone@example.test" } }; case "/api/v1/timeline": + if (timelineAnswer) return timelineAnswer(url); return { status: 200, body: { days: TIMELINE_DAYS } }; case "/api/v1/visits": return { status: 200, body: VISITS }; @@ -188,6 +193,7 @@ beforeEach(async () => { await truncateAllTables(prisma); cookieJar.clear(); requests.length = 0; + timelineAnswer = null; process.env.DAWARICH_PRIVATE_ORIGINS = origin; await prisma.user.create({ data: { @@ -516,3 +522,73 @@ describe("Disconnect", () => { expect(await storedDays()).toHaveLength(0); }); }); + +describe("Timeline days across a request boundary", () => { + /** + * The instance as Dawarich builds a timeline (`Timeline::DayAssembler`): + * days in the Dawarich account's own zone, here New York while the + * HealthLog account is in Berlin, and only what lies inside the requested + * window is summed, so a window cut at Berlin midnight answers the New + * York day it cuts with a fragment. Every New York day holds a 1 km trip + * at 02:00 and a 10 km trip at 20:00 (02:00 the next morning in Berlin), + * so a complete day is 11 km and a fragment 1 or 10. A window longer than + * 31 days is refused as the instance refuses it. + */ + const INSTANCE_TZ = "America/New_York"; + const nyDate = (ms: number) => + new Date(ms).toLocaleDateString("en-CA", { timeZone: INSTANCE_TZ }); + const nyInstant = (date: string, hour: number) => { + // New York is UTC-4 for the whole summer range this test reads. + return Date.parse(`${date}T${String(hour).padStart(2, "0")}:00:00-04:00`); + }; + function instanceTimeline(url: URL) { + const startMs = Date.parse(url.searchParams.get("start_at") ?? ""); + const endMs = Date.parse(url.searchParams.get("end_at") ?? ""); + if (endMs - startMs > 31 * 24 * 3_600_000) { + return { status: 400, body: { error: "range too large" } }; + } + const totals = new Map(); + const from = new Date(startMs - 2 * 86_400_000); + for (let d = from.getTime(); d <= endMs + 86_400_000; d += 86_400_000) { + const date = nyDate(d); + for (const [hour, km] of [ + [2, 1], + [20, 10], + ] as const) { + const at = nyInstant(date, hour); + if (at < startMs || at > endMs) continue; + totals.set(date, (totals.get(date) ?? 0) + km); + } + } + return { + status: 200, + body: { + days: [...totals].map(([date, km]) => ({ + date, + summary: { + total_distance: km, + places_visited: 0, + mode_distances: {}, + }, + })), + }, + }; + } + + it("never lets a boundary fragment overwrite a complete day", async () => { + timelineAnswer = instanceTimeline; + expect((await connect()).status).toBe(200); + await getPrismaClient().dawarichDay.deleteMany({}); + + const { syncDawarichRange } = await import("@/lib/dawarich/sync"); + // Sixty days: three timeline requests, two boundaries between them. + await syncDawarichRange(USER_ID, "2026-06-01", "2026-07-30"); + + const days = await storedDays(); + expect(days).toHaveLength(60); + const wrong = days + .filter((d) => d.totalDistanceM !== 11_000) + .map((d) => `${d.date}=${d.totalDistanceM}`); + expect(wrong).toEqual([]); + }); +});