mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge work/today-day-arithmetic
This commit is contained in:
commit
3b9504a441
7 files changed
+395
-32
No files matched your search
@@ -37,6 +37,7 @@ vi.mock("@/lib/medication-category", () => ({
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
@@ -69,6 +70,13 @@ const prismaMock = prisma as unknown as {
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Its own account id, because `resolveUserTimezone` memoises per user for a
|
||||
* minute — reusing "user-1" would either read a zone a neighbouring test set
|
||||
* or leave one behind for the next.
|
||||
*/
|
||||
const TZ_USER = "user-tz";
|
||||
|
||||
function rollupRow(daysAgo: number, mean: number, count: number) {
|
||||
return {
|
||||
bucketStart: new Date(Date.now() - daysAgo * dayMs),
|
||||
@@ -404,6 +412,44 @@ describe("extractFeatures — v1.22 preventive-care block", () => {
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.preventiveCare).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* The day counting itself is pinned in `preventive-care-days.test.ts`; what
|
||||
* this pins is the wire between it and the account — that the zone the block
|
||||
* counts on is the one on the person's PROFILE, and that it survives the trip
|
||||
* through `extractFeatures`. Both ends were right once before while the
|
||||
* assembly between them handed down something else.
|
||||
*
|
||||
* One instant, three answers. At 05:00 UTC on the 18th it is still the 17th
|
||||
* in Los Angeles, so a checkup at 20:00 UTC on the 18th is tomorrow's there —
|
||||
* while UTC and the Berlin server default both call it today's. Only the
|
||||
* profile zone gives 1, so nothing but the profile zone can produce it.
|
||||
*/
|
||||
it("counts the days on the profile's calendar, not UTC's or the server's", async () => {
|
||||
const NOW = Date.parse("2026-07-18T05:00:00Z");
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(NOW);
|
||||
invalidateUserTimezone(TZ_USER);
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1980-01-01"),
|
||||
gender: "MALE",
|
||||
timezone: "America/Los_Angeles",
|
||||
});
|
||||
prismaMock.measurementReminder.findMany.mockResolvedValue([
|
||||
{ label: "Augenarzt", nextDueAt: new Date("2026-07-18T20:00:00Z") },
|
||||
]);
|
||||
|
||||
try {
|
||||
const f = await extractFeatures(TZ_USER, false);
|
||||
expect(f.preventiveCare?.due).toEqual([
|
||||
{ label: "Augenarzt", daysUntil: 1 },
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
invalidateUserTimezone(TZ_USER);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 workouts block", () => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The preventive-care block hands the briefing model two bare numbers per
|
||||
* checkup — "due in N days", "overdue by N days" — and the model writes them
|
||||
* into prose the person reads next to the checkup screens. So the number has
|
||||
* to answer the same question the screens answer: how many dates away is it on
|
||||
* the person's own calendar, not how many hours away divided by twenty-four.
|
||||
*
|
||||
* Every case below is one where those two answers differ. Each instant carries
|
||||
* an explicit offset so the suite says the same thing whatever zone the machine
|
||||
* running it is set to.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurementReminder: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readPreventiveCareBlock } from "../feature-blocks";
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
/**
|
||||
* The profile zone under test. July is deliberate: no IANA zone shifts its
|
||||
* clocks mid-July, so a fixed offset stands for the whole month and building
|
||||
* 00:01 or 23:59 can never land on a skipped or repeated hour.
|
||||
*/
|
||||
const ZONE = "Europe/Berlin";
|
||||
const ZONE_OFFSET = "+02:00";
|
||||
|
||||
// Fri 17th, Sat 18th, Sun 19th of July 2026.
|
||||
const FRIDAY = 17;
|
||||
const SATURDAY = 18;
|
||||
const SUNDAY = 19;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** A wall-clock time in {@link ZONE}, as the instant the column stores. */
|
||||
function zoned(day: number, hour: number, minute = 0): Date {
|
||||
return new Date(
|
||||
`2026-07-${pad(day)}T${pad(hour)}:${pad(minute)}:00${ZONE_OFFSET}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The same wall clock as the epoch millis the caller passes for `now`. */
|
||||
function at(day: number, hour: number, minute = 0): number {
|
||||
return zoned(day, hour, minute).getTime();
|
||||
}
|
||||
|
||||
/** One reminder due at the given instant, as the only row the read returns. */
|
||||
function onlyReminder(nextDueAt: Date) {
|
||||
prismaMock.measurementReminder.findMany.mockResolvedValue([
|
||||
{ label: "Augenarzt", nextDueAt },
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock.measurementReminder.findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("readPreventiveCareBlock counts calendar days, not hours", () => {
|
||||
it("keeps a checkup booked for this morning on today's list all day", async () => {
|
||||
// Eleven hours in the past, and still the checkup for today. An hour count
|
||||
// moved it to the overdue list and then rounded the count to nothing, so
|
||||
// the payload said "overdue by 0 days" about something not yet missed.
|
||||
onlyReminder(zoned(FRIDAY, 9));
|
||||
const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 20), ZONE);
|
||||
|
||||
expect(block?.overdue).toEqual([]);
|
||||
expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]);
|
||||
});
|
||||
|
||||
it("calls a Sunday 01:00 checkup two days out when read late on Friday", async () => {
|
||||
// Twenty-six hours apart, so an hour count rounds to one and the briefing
|
||||
// says tomorrow. Two dates separate them on the calendar.
|
||||
onlyReminder(zoned(SUNDAY, 1));
|
||||
const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 23), ZONE);
|
||||
|
||||
expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 2 }]);
|
||||
});
|
||||
|
||||
it("calls a Saturday 23:59 checkup one day out when read just after Friday midnight", async () => {
|
||||
// Just under forty-eight hours apart, so an hour count rounds to two. It is
|
||||
// the very next date on the calendar.
|
||||
onlyReminder(zoned(SATURDAY, 23, 59));
|
||||
const block = await readPreventiveCareBlock(
|
||||
"user-1",
|
||||
at(FRIDAY, 0, 1),
|
||||
ZONE,
|
||||
);
|
||||
|
||||
expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]);
|
||||
});
|
||||
|
||||
it("counts a checkup missed yesterday as one day overdue, however recent", async () => {
|
||||
// Forty-five minutes in the past. It was still yesterday's checkup, and an
|
||||
// hour count called it overdue by nothing at all.
|
||||
onlyReminder(zoned(FRIDAY, 23, 30));
|
||||
const block = await readPreventiveCareBlock(
|
||||
"user-1",
|
||||
at(SATURDAY, 0, 15),
|
||||
ZONE,
|
||||
);
|
||||
|
||||
expect(block?.due).toEqual([]);
|
||||
expect(block?.overdue).toEqual([{ label: "Augenarzt", daysOverdue: 1 }]);
|
||||
});
|
||||
|
||||
it("keeps a whole clock-change day as one day", async () => {
|
||||
// The Berlin day the clocks go back is twenty-five hours long, so an hour
|
||||
// count rounds a same-day evening checkup up to tomorrow.
|
||||
onlyReminder(new Date("2026-10-25T22:00:00Z")); // 23:00 Berlin, after
|
||||
const block = await readPreventiveCareBlock(
|
||||
"user-1",
|
||||
Date.parse("2026-10-25T00:00:00Z"), // 02:00 Berlin, before
|
||||
ZONE,
|
||||
);
|
||||
|
||||
expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPreventiveCareBlock counts on the profile's calendar", () => {
|
||||
// 22:30 UTC on the 17th is already 00:30 on the 18th in Berlin, and the
|
||||
// checkup at 21:00 UTC on the 18th is 23:00 Berlin on that same 18th. So
|
||||
// Berlin says today and UTC says tomorrow about one identical pair of
|
||||
// instants. Asserting BOTH is what makes this bite: an implementation that
|
||||
// ignores the zone it is given returns the same number twice.
|
||||
const NOW = Date.parse("2026-07-17T22:30:00Z");
|
||||
const DUE = new Date("2026-07-18T21:00:00Z");
|
||||
|
||||
it("gives two profiles either side of midnight two different counts", async () => {
|
||||
onlyReminder(DUE);
|
||||
const berlin = await readPreventiveCareBlock(
|
||||
"user-1",
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
onlyReminder(DUE);
|
||||
const utc = await readPreventiveCareBlock("user-1", NOW, "UTC");
|
||||
|
||||
expect(berlin?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]);
|
||||
expect(utc?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The machine's own clock, moved under the function's feet. `vitest.config`
|
||||
* pins the suite to UTC, and a `TZ=` prefix on the command line does not
|
||||
* override that — the only way into another host zone is to assign
|
||||
* `process.env.TZ`, which is what this does. Whatever the host is set to,
|
||||
* the answer must not move: that is the difference between reading the
|
||||
* profile and reading the server the code happens to run on.
|
||||
*/
|
||||
const HOST_ZONES = ["UTC", "Pacific/Auckland", "America/Los_Angeles"];
|
||||
|
||||
it("ignores the zone the host machine itself is set to", async () => {
|
||||
const original = process.env.TZ;
|
||||
const answers: Array<number | undefined> = [];
|
||||
try {
|
||||
for (const hostZone of HOST_ZONES) {
|
||||
process.env.TZ = hostZone;
|
||||
onlyReminder(DUE);
|
||||
const block = await readPreventiveCareBlock("user-1", NOW, ZONE);
|
||||
answers.push(block?.due[0]?.daysUntil);
|
||||
}
|
||||
} finally {
|
||||
process.env.TZ = original;
|
||||
}
|
||||
|
||||
expect(answers).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
|
||||
@@ -251,10 +252,23 @@ export async function readLabsBriefingBlock(
|
||||
* v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the
|
||||
* user's enabled, live reminders and buckets by the server-authoritative
|
||||
* `nextDueAt`. Returns `undefined` when nothing is due or overdue.
|
||||
*
|
||||
* `daysUntil` / `daysOverdue` are CALENDAR days in `timeZone`, counted by the
|
||||
* same `calendarDaysUntil` the checkup screens phrase their due line with.
|
||||
* They used to be an hour gap divided by 24, which is a different question:
|
||||
* a checkup at 09:00 read that evening came out "overdue by 0 days", and one
|
||||
* due tomorrow morning read late tonight came out due "in 0 days" — today's.
|
||||
* The model narrates these numbers, so it repeated whichever the arithmetic
|
||||
* had drifted to while the screen next to it said something else.
|
||||
*
|
||||
* `timeZone` is the user's profile zone; the horizon that bounds the read
|
||||
* stays an hours-based cutoff on purpose — it is a query bound, not a
|
||||
* statement to anyone about which day something falls on.
|
||||
*/
|
||||
export async function readPreventiveCareBlock(
|
||||
userId: string,
|
||||
now: number,
|
||||
timeZone: string,
|
||||
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
|
||||
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
|
||||
const rows = await prisma.measurementReminder.findMany({
|
||||
@@ -273,15 +287,18 @@ export async function readPreventiveCareBlock(
|
||||
const overdue: NonNullable<AggregatedFeatures["preventiveCare"]>["overdue"] =
|
||||
[];
|
||||
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["due"] = [];
|
||||
const nowDate = new Date(now);
|
||||
for (const r of rows) {
|
||||
if (!r.nextDueAt) continue;
|
||||
const label = sanitizeLabel(r.label);
|
||||
if (!label) continue;
|
||||
const diffMs = r.nextDueAt.getTime() - now;
|
||||
if (diffMs < 0) {
|
||||
overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) });
|
||||
const days = calendarDaysUntil(r.nextDueAt, nowDate, timeZone);
|
||||
// Today's checkup is due, not overdue — the hour it was booked for may
|
||||
// have passed, but the day it belongs to has not.
|
||||
if (days < 0) {
|
||||
overdue.push({ label, daysOverdue: -days });
|
||||
} else {
|
||||
due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) });
|
||||
due.push({ label, daysUntil: days });
|
||||
}
|
||||
}
|
||||
if (overdue.length === 0 && due.length === 0) return undefined;
|
||||
|
||||
@@ -1354,9 +1354,15 @@ export async function extractFeatures(
|
||||
(typeof sinceDays === "number" &&
|
||||
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
|
||||
if (includeIntegrations) {
|
||||
// The preventive-care block states a distance in whole days, so it needs
|
||||
// to know whose calendar it is counting on. Resolved here rather than
|
||||
// inside the block because the resolver reads the database and this is
|
||||
// the layer that already talks to it; the resolver's own 60 s cache makes
|
||||
// the second call in this function free.
|
||||
const preventiveTz = await resolveUserTimezone(userId);
|
||||
const [labs, preventiveCare, workouts, ecg] = await Promise.all([
|
||||
readLabsBriefingBlock(userId, now),
|
||||
readPreventiveCareBlock(userId, now),
|
||||
readPreventiveCareBlock(userId, now, preventiveTz),
|
||||
readWorkoutsBlock(userId, now),
|
||||
// S10 — ECG device-verdict descriptor (never the waveform). Weaves the
|
||||
// ECG silo into the narrative; the model may reference it, attributed to
|
||||
|
||||
@@ -687,6 +687,95 @@ describe("get_preventive_care", () => {
|
||||
};
|
||||
expect(result.present).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Overdue is a question about dates, and the answer has to hold for the
|
||||
* whole of the date. Comparing the two instants flipped the flag at the
|
||||
* hour the checkup was booked for, so from ten past nine the tool told the
|
||||
* model a checkup was overdue while the screen beside it still read
|
||||
* "today" — one reminder, two verdicts.
|
||||
*
|
||||
* Read in the profile zone the mocked account carries (UTC), so the
|
||||
* instants below say the same thing whatever the host machine is set to.
|
||||
*/
|
||||
async function overdueFlagFor(
|
||||
nextDueAt: string,
|
||||
now: string,
|
||||
timezone = "UTC",
|
||||
) {
|
||||
vi.setSystemTime(Date.parse(now));
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone } as never);
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([
|
||||
{ id: "r-1" },
|
||||
] as never);
|
||||
vi.mocked(toMeasurementReminderDto).mockReturnValue({
|
||||
id: "r-1",
|
||||
label: "Blood pressure check",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 30,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const result = (await tool("get_preventive_care").run(CTX, {})) as {
|
||||
checkups: Array<{ overdue: boolean }>;
|
||||
};
|
||||
return result.checkups[0]?.overdue;
|
||||
}
|
||||
|
||||
it("holds the overdue flag for the whole of the day a checkup is due", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// The booked hour has passed by eleven hours, and it is still the day
|
||||
// the checkup belongs to.
|
||||
expect(
|
||||
await overdueFlagFor(
|
||||
"2026-07-17T09:00:00.000Z",
|
||||
"2026-07-17T20:00:00.000Z",
|
||||
),
|
||||
).toBe(false);
|
||||
// Forty-five minutes later on the clock, and a date later on the
|
||||
// calendar. Now it is missed.
|
||||
expect(
|
||||
await overdueFlagFor(
|
||||
"2026-07-17T23:30:00.000Z",
|
||||
"2026-07-18T00:15:00.000Z",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the dates off the profile's calendar, not the server's", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// The same pair of instants as the first case above, which UTC reads as
|
||||
// one date and answers "not overdue". Twelve hours east it is already
|
||||
// the next morning, so that same checkup was yesterday's and is missed.
|
||||
// Only the profile zone can produce the second answer.
|
||||
expect(
|
||||
await overdueFlagFor(
|
||||
"2026-07-17T09:00:00.000Z",
|
||||
"2026-07-17T20:00:00.000Z",
|
||||
"Pacific/Auckland",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("search — cursor pagination", () => {
|
||||
|
||||
+12
-2
@@ -67,6 +67,7 @@ import {
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
@@ -1566,7 +1567,16 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
return { present: false };
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// Overdue is a CALENDAR-day verdict, the same one the checkups screen
|
||||
// renders. Comparing the instants said a checkup booked for nine this
|
||||
// morning was overdue by ten past, so the tool called something overdue
|
||||
// on the very day it is due while the screen next to it read "today".
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: ctx.userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const checkups = reminders.map((r) => {
|
||||
const dto = toMeasurementReminderDto(r);
|
||||
return {
|
||||
@@ -1575,7 +1585,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
nextDueAt: dto.nextDueAt,
|
||||
overdue:
|
||||
dto.nextDueAt !== null
|
||||
? new Date(dto.nextDueAt).getTime() < nowMs
|
||||
? calendarDaysUntil(new Date(dto.nextDueAt), now, timezone) < 0
|
||||
: false,
|
||||
location: dto.location,
|
||||
lastSatisfiedAt: dto.lastSatisfiedAt,
|
||||
|
||||
@@ -14,11 +14,16 @@
|
||||
* `nextDueAt`) because this is the read side of the same value. Nothing in
|
||||
* this file touches the database or the network, so a client component can
|
||||
* import it directly.
|
||||
*
|
||||
* The words are only half of it. The AI features payload states the same
|
||||
* distance as a bare number ("due in N days", "overdue by N days") for the
|
||||
* briefing to narrate, and it counted hours the way the dashboard card used
|
||||
* to. So the counting itself is {@link calendarDaysUntil} and both the words
|
||||
* and the number come off it — the screens and the prose the model writes
|
||||
* about them cannot say different things about the same reminder.
|
||||
*/
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
/** A fully-qualified i18n key plus the day count it interpolates. */
|
||||
export interface RelativeDue {
|
||||
@@ -26,6 +31,39 @@ export interface RelativeDue {
|
||||
days: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many CALENDAR days forward `target` sits from `now`, both read on the
|
||||
* wall clock of `timeZone`. Today is 0, tomorrow is 1, yesterday is -1.
|
||||
*
|
||||
* Not an hour count divided by 24. A checkup due at 09:00 is still due today
|
||||
* at 20:00; one due tomorrow at 01:00 is tomorrow's whether it is read at
|
||||
* 23:00 tonight or at 06:00 this morning. Dividing the gap by 24 h answers a
|
||||
* different question and gets both of those wrong, and it also admits the
|
||||
* 23 h / 25 h length of a clock-change day. This differences two integer day
|
||||
* indices instead, so the answer is exact by construction rather than by
|
||||
* rounding.
|
||||
*
|
||||
* `timeZone` is the person's PROFILE zone — the same clock the date printed
|
||||
* beside the phrase is rendered in. It is required on purpose: it used to be
|
||||
* implicit, so the day floor silently took the DEVICE clock while the date
|
||||
* next to it took the profile clock, and on a laptop set elsewhere the card
|
||||
* printed a date and a phrase that contradicted each other. A zone that
|
||||
* cannot be read falls back exactly as `relativeCalendarDate` and `fmt.date`
|
||||
* do (issue #490) — to Berlin, never to the host clock, because landing on
|
||||
* the host is what let the two halves drift apart in the first place.
|
||||
*/
|
||||
export function calendarDaysUntil(
|
||||
target: Date,
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): number {
|
||||
const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE;
|
||||
return (
|
||||
localDayIndex(wallClockInTz(target, zone)) -
|
||||
localDayIndex(wallClockInTz(now, zone))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site
|
||||
* that interpolates the tail onto the namespace root anchors the
|
||||
@@ -33,14 +71,10 @@ export interface RelativeDue {
|
||||
* it reachable and turns the guard into a no-op for the whole namespace. The
|
||||
* guard now refuses that shape outright, so the key is built whole here.
|
||||
*
|
||||
* `timeZone` is required on purpose. It used to be implicit — the day floor
|
||||
* silently took the DEVICE clock while the date printed next to it took the
|
||||
* profile clock, so on a laptop set to another zone the card could print a
|
||||
* date and a phrase that contradicted each other. Naming the zone is how a
|
||||
* caller says which of those two clocks it means, and there is no default
|
||||
* because the missing answer was the whole bug: a silent caller got something
|
||||
* plausible-looking rather than an error. Pass what the neighbouring date
|
||||
* label renders in — `useDisplayTimezone()` on the client.
|
||||
* Pass what the neighbouring date label renders in — `useDisplayTimezone()`
|
||||
* on the client. The zone argument carries straight through to
|
||||
* {@link calendarDaysUntil}, which is where the day counting happens and
|
||||
* where the reasoning for both the requirement and the fallback lives.
|
||||
*/
|
||||
export function relativeDueKey(
|
||||
nextDueAt: string | null,
|
||||
@@ -51,20 +85,7 @@ export function relativeDueKey(
|
||||
const due = new Date(nextDueAt);
|
||||
if (Number.isNaN(due.getTime()))
|
||||
return { key: "measurementReminders.nextDue.none", days: 0 };
|
||||
// Resolve exactly as `relativeCalendarDate` and `fmt.date` do (issue #490):
|
||||
// a usable zone stands, anything else lands on Berlin. Note where it does
|
||||
// NOT land — the device. Falling back to the host clock is what let the two
|
||||
// halves of this card drift apart, and a zone the profile mirror could not
|
||||
// make sense of has to end up wherever the printed date ends up.
|
||||
const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE;
|
||||
// v1.18.9 (recon) — compare CALENDAR-day deltas in the user's zone, not a
|
||||
// rolling-24h delta. A reminder due at 09:00 viewed the same evening at
|
||||
// 20:00 must still read "heute", and one whose due calendar-day is before
|
||||
// today must read "überfällig" — a raw `(due - now) / DAY_MS` round would
|
||||
// mis-bucket both. Floor each instant to its local day start first.
|
||||
const dueDay = startOfLocalDayInTz(due, zone).getTime();
|
||||
const nowDay = startOfLocalDayInTz(new Date(now), zone).getTime();
|
||||
const deltaDays = Math.round((dueDay - nowDay) / DAY_MS);
|
||||
const deltaDays = calendarDaysUntil(due, new Date(now), timeZone);
|
||||
if (deltaDays < 0)
|
||||
return {
|
||||
key: "measurementReminders.overdueByDays",
|
||||
|
||||
Reference in new issue
Block a user