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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:28:51 +02:00
1 parent c777819b33
commit e9a0b3d959
2 files changed
+134 -11

No files matched your search

+58 -11
View File
@@ -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<string> }> {
const out: Array<{ startAt: Date; endAt: Date; dates: Set<string> }> = [];
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<ReturnType<typeof fetchVisits>> = [];
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;
@@ -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<string, number>();
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([]);
});
});