mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The nightly briefing warm completed every night and read "all-failed", because one run event held every account's annotations and the last account won. Each candidate now runs in its own wide event and the run reports every outcome through `reportJobRun`, so ok, dead sign-in, withheld and failed accounts show side by side. A dead Codex refresh token threw a bare error with no status, so the chain read it as a network failure: the health ledger recorded hard_failed, no "reconnect" hint appeared, and every night tried the same token. The refresh now throws `CodexRefreshError` with a 401 for a dead grant (invalid_grant / invalid_refresh_token on a 400 included), as do a missing or undecryptable stored credential. The ledger records auth_failed, the chain marks the primary credential expired, and the nightly pass reports the account as auth_failed without queuing a retry. A briefing the outbound screen withheld (a causal claim) failed outright, and the 45-minute provider retry then asked the same question eight times a night. The generator now makes one bounded repair that names the broken rule and keeps the repaired payload only if it passes the same parse, grounding and screen; a withheld outcome queues no retry. A provider that keeps failing is paused: from five consecutive hard failures the skip window starts at 30 minutes and doubles per further failure, capped at a day. A paused provider is walked last and tried again when the window lifts; one success resumes it. The operator card shows how many accounts it is paused for and until when. After a 5xx from a non-canonical OpenAI-compatible endpoint the client asks the free model listing, and a configured model the endpoint no longer offers is named in the error, on the hop line and on the card. Also: - The tiered context's live day band read `measured_at AT TIME ZONE tz`, which treats the stored UTC as local time; a far-from-UTC reader's evening readings landed on the next day. It converts from UTC first now. - Google Health intraday heart rate re-reads two hours per sync instead of a day; daily summaries and sleep keep the day (#1023). - The first filtered daily read that answers 400 and triggers the snake_case retry is recorded as a note, not an error (#1023). - The single derived-score read goes through the derived cache with stale-while-revalidate, like the batch beside it.
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
/**
|
||
* v1.42 — the tiered context's live day band buckets on the reader's own
|
||
* calendar day, including on a daylight-saving changeover.
|
||
*
|
||
* For a reader far from UTC the 14–30-day band is read live, bucketed by
|
||
* `date_trunc('day', <local wall clock>)`. `measured_at` is a `timestamp`
|
||
* without time zone holding UTC, and the band read it with a single
|
||
* `AT TIME ZONE tz`, which interprets the value as local time and converts
|
||
* the wrong way: in Los Angeles every reading moved sixteen hours, and an
|
||
* evening reading was summarised as the next day. The Coach and the briefing
|
||
* then quoted a day's mean built from two different days.
|
||
*
|
||
* 2026-03-08 is the US changeover (02:00 PST becomes 03:00 PDT). Two weight
|
||
* readings on that local day, 08:00 PDT (15:00Z) and 23:30 PDT (06:30Z on
|
||
* the 9th), must form one bucket for the 8th.
|
||
*/
|
||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||
|
||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||
|
||
vi.mock("@/lib/db-compat", () => ({
|
||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
|
||
|
||
const prisma = getPrismaClient();
|
||
const USER = "tiered-context-local-day";
|
||
const TZ = "America/Los_Angeles";
|
||
/** "Now" for the band arithmetic: the 8th sits inside the 14–30-day band. */
|
||
const NOW = new Date("2026-03-25T12:00:00Z").getTime();
|
||
|
||
beforeAll(async () => {
|
||
await truncateAllTables(prisma);
|
||
await prisma.user.create({
|
||
data: { id: USER, username: USER, timezone: TZ },
|
||
});
|
||
await prisma.measurement.createMany({
|
||
data: [
|
||
{ at: "2026-03-08T15:00:00Z", value: 70 },
|
||
{ at: "2026-03-09T06:30:00Z", value: 80 },
|
||
// A reading on the local 7th, late evening PST, for contrast.
|
||
{ at: "2026-03-08T07:30:00Z", value: 90 },
|
||
].map(({ at, value }) => ({
|
||
userId: USER,
|
||
type: "WEIGHT" as const,
|
||
value,
|
||
unit: "kg",
|
||
measuredAt: new Date(at),
|
||
source: "MANUAL" as const,
|
||
})),
|
||
});
|
||
});
|
||
|
||
describe("tiered context — live day band on the reader's calendar", () => {
|
||
it("puts an evening reading on its own local day across the DST changeover", async () => {
|
||
const series = await buildTieredSeries(USER, "WEIGHT", {
|
||
tz: TZ,
|
||
now: NOW,
|
||
skipEnsureFresh: true,
|
||
});
|
||
const byDay = Object.fromEntries(
|
||
series.dayBand.map((b) => [b.bucketStart.slice(0, 10), b]),
|
||
);
|
||
expect(Object.keys(byDay).sort()).toEqual(["2026-03-07", "2026-03-08"]);
|
||
expect(byDay["2026-03-08"]).toMatchObject({ count: 2, mean: 75 });
|
||
expect(byDay["2026-03-07"]).toMatchObject({ count: 1, mean: 90 });
|
||
});
|
||
});
|