fix(analytics): restore device-priority sleep selection

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 16:08:41 +02:00
1 parent fb02cf89b1
commit 5b695f43ea
13 files changed
+235 -73

No files matched your search

@@ -168,6 +168,11 @@ interface ApiSuccessBranch {
resolveCalls: number;
}
interface ApiSuccessSpread {
fields: string[];
resolveCalls: number;
}
function apiSuccessBranches(
path: string,
writtenOutcomeFields: readonly string[],
@@ -185,6 +190,60 @@ function apiSuccessBranches(
);
}
function inspectObjectLiteralSpread(
object: ts.ObjectLiteralExpression,
): ApiSuccessSpread {
const fields: string[] = [];
for (const property of object.properties) {
expect(
ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property),
`${path} has unsupported conditional spread syntax: ${property.getText(file)}`,
).toBe(true);
if (
!ts.isPropertyAssignment(property) &&
!ts.isShorthandPropertyAssignment(property)
) {
continue;
}
const name = propertyName(property.name, file);
expect(name, `${path} has a computed conditional spread field`).not.toBe(
null,
);
if (name !== null) fields.push(name);
}
return { fields, resolveCalls: 0 };
}
function inspectSpread(expression: ts.Expression): ApiSuccessSpread | null {
if (isWrittenOutcomeCall(expression)) {
return { fields: [...writtenOutcomeFields], resolveCalls: 1 };
}
if (ts.isParenthesizedExpression(expression)) {
return inspectSpread(expression.expression);
}
if (ts.isObjectLiteralExpression(expression)) {
return inspectObjectLiteralSpread(expression);
}
if (ts.isConditionalExpression(expression)) {
const whenTrue = inspectSpread(expression.whenTrue);
const whenFalse = inspectSpread(expression.whenFalse);
if (
whenTrue === null ||
whenFalse === null ||
whenTrue.resolveCalls !== 0 ||
whenFalse.resolveCalls !== 0
) {
return null;
}
return {
fields: [...new Set([...whenTrue.fields, ...whenFalse.fields])],
resolveCalls: 0,
};
}
return null;
}
function visit(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
@@ -205,13 +264,22 @@ function apiSuccessBranches(
} else if (payload && ts.isObjectLiteralExpression(payload)) {
for (const property of payload.properties) {
if (ts.isSpreadAssignment(property)) {
const spread = inspectSpread(property.expression);
expect(
isWrittenOutcomeCall(property.expression),
spread,
`${path} has an unsupported apiSuccess spread: ${property.getText(file)}`,
).toBe(true);
if (isWrittenOutcomeCall(property.expression)) {
resolveCalls += 1;
fields.push(...writtenOutcomeFields);
).not.toBeNull();
if (spread !== null) {
for (const name of spread.fields) {
if (resolveCalls > 0) {
expect(
writtenOutcomeFields,
`${path} overrides ${name} after resolveSyncOutcome(...)`,
).not.toContain(name);
}
fields.push(name);
}
resolveCalls += spread.resolveCalls;
}
continue;
}
@@ -564,6 +632,12 @@ interface SyncRouteSpec {
const FULL_SYNC_EXCEPTION =
"The boolean intentionally echoes the requested sync mode for programmatic callers; the web card already owns that mode as request state and does not read the echo.";
const GOOGLE_HEALTH_RESOURCES_EXCEPTION =
"The bounded resource array is consumed by both readSyncOutcome and readGoogleHealthProgress so partial writes, actionable failures, and workout cache invalidation survive the response boundary.";
const GOOGLE_HEALTH_STATE_EXCEPTION =
"The bounded terminal state is consumed by readGoogleHealthProgress and drives the Google Health card's complete, partial, truncated, interrupted, and failed presentation.";
const GOOGLE_HEALTH_RUN_ID_EXCEPTION =
"The bounded opaque run identifier is a wire-level correlation token for programmatic callers and the status endpoint; the web card does not render or persist it.";
const SYNC_ROUTES: readonly SyncRouteSpec[] = [
{
@@ -578,7 +652,12 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
path: join(ROOT, "src/app/api/google-health/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "google-health-card.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
exceptions: {
fullSync: FULL_SYNC_EXCEPTION,
resources: GOOGLE_HEALTH_RESOURCES_EXCEPTION,
runId: GOOGLE_HEALTH_RUN_ID_EXCEPTION,
state: GOOGLE_HEALTH_STATE_EXCEPTION,
},
},
{
id: "nightscout",
@@ -1015,18 +1094,16 @@ describe("written-outcome response consumer guard", () => {
const extras = branch.fields.filter(
(field) => !sharedFields.includes(field),
);
const classifiedExtras =
route.responseClass === "resolved-with-full-sync-echo"
? ["fullSync"]
: [];
const classifiedExtras = Object.keys(route.exceptions);
expect(
extras.sort(),
`${route.id} branch ${index + 1} must match its response classification`,
).toEqual(classifiedExtras);
expect(
Object.keys(route.exceptions).sort(),
`${route.id} branch ${index + 1} extras need an exact consumer or documented exception`,
).toEqual(extras.sort());
).toEqual(classifiedExtras.sort());
if (route.responseClass === "resolved-with-full-sync-echo") {
expect(extras).toContain("fullSync");
} else {
expect(extras).not.toContain("fullSync");
}
for (const field of extras) {
expect(route.exceptions[field].length).toBeGreaterThan(40);
}
@@ -190,7 +190,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ parserRevision: 2 }),
data: expect.objectContaining({ parserRevision: 3 }),
}),
);
expect(mocks.bossSend).toHaveBeenCalledWith(
@@ -209,7 +209,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
expect.objectContaining({
where: expect.objectContaining({
status: { not: "failed" },
parserRevision: 2,
parserRevision: 3,
}),
}),
);
@@ -233,7 +233,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parserRevision: 2 }),
where: expect.objectContaining({ parserRevision: 3 }),
}),
);
});
@@ -172,7 +172,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ parserRevision: 2 }),
data: expect.objectContaining({ parserRevision: 3 }),
}),
);
expect(mocks.bossSend).toHaveBeenCalledWith(
@@ -189,7 +189,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
expect.objectContaining({
where: expect.objectContaining({
status: { not: "failed" },
parserRevision: 2,
parserRevision: 3,
}),
}),
);
@@ -215,7 +215,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parserRevision: 2 }),
where: expect.objectContaining({ parserRevision: 3 }),
}),
);
});
+29 -14
View File
@@ -129,6 +129,7 @@ import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import {
getDeviceTypeLadder,
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
@@ -363,6 +364,12 @@ function sourceOfWriterKey(key: string): string {
return i === -1 ? key : key.slice(0, i);
}
/** The device part of a writer key; legacy/bare writers rank as unknown. */
function deviceOfWriterKey(key: string): string {
const i = key.indexOf(WRITER_KEY_SEP);
return i === -1 ? "unknown" : key.slice(i + WRITER_KEY_SEP.length);
}
/**
* Thresholds for the per-session source-discrepancy annotation. Two writer
* buckets "clearly disagree" on a night only when the spread of their asleep
@@ -460,13 +467,16 @@ function granularStageCount(rows: readonly SleepStageRow[]): number {
* phone's in-bed detection next to the watch's stages) can mask the
* fullest hypnogram available for the night. Among equally rich writers
* the user's `sleep` source-priority ladder decides (lowest ladder index =
* highest priority); writers whose source is absent from the ladder fall
* back to "most asleep minutes", then a stable key tiebreak. Single-writer
* (or source-less) sessions resolve to that one bucket with no work.
* highest priority), then the `SLEEP_DURATION` device-type ladder decides
* between writers behind that same source. Writers absent from both ladders
* fall back to "most asleep minutes", then a stable key tiebreak.
* Single-writer (or source-less) sessions resolve to that one bucket with no
* work.
*/
function pickSessionWriter(
rows: SleepStageRow[],
sleepLadder: readonly MeasurementSource[],
deviceLadder: readonly string[],
): string {
// Group rows by writer, then sum each writer's asleep minutes with the
// granular-over-bare rule so a writer that stores BOTH a bare ASLEEP
@@ -538,11 +548,18 @@ function pickSessionWriter(
const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
const deviceRankOf = (key: string): number => {
const i = deviceLadder.indexOf(deviceOfWriterKey(key));
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return writers.sort((a, b) => {
const ra = rankOf(a);
const rb = rankOf(b);
if (ra !== rb) return ra - rb;
// Neither (or both) on the ladder — most asleep minutes wins, then key.
const da = deviceRankOf(a);
const db = deviceRankOf(b);
if (da !== db) return da - db;
// Neither (or both) on both ladders — most asleep minutes wins, then key.
const ma = asleepByWriter.get(a) ?? 0;
const mb = asleepByWriter.get(b) ?? 0;
if (ma !== mb) return mb - ma;
@@ -567,10 +584,9 @@ export function reconstructSleepNights(
priorityJson: unknown = null,
): SleepNight[] {
if (rows.length === 0) return [];
const sleepLadder = getSourceLadder(
parseSourcePriority(priorityJson),
"sleep",
);
const resolvedPriority = parseSourcePriority(priorityJson);
const sleepLadder = getSourceLadder(resolvedPriority, "sleep");
const deviceLadder = getDeviceTypeLadder(resolvedPriority, "SLEEP_DURATION");
// Cluster into sessions by the gap between a segment's START and the latest
// END seen so far in the current session. `measuredAt` is the segment END;
@@ -613,7 +629,7 @@ export function reconstructSleepNights(
Array<{ pool: SleepStageRow[]; all: SleepStageRow[] }>
>();
for (const session of sessions) {
const canonical = pickSessionWriter(session, sleepLadder);
const canonical = pickSessionWriter(session, sleepLadder, deviceLadder);
const kept = session.filter((r) => writerKeyOf(r) === canonical);
const pool = kept.length > 0 ? kept : session;
// The wake day is the LATEST segment END in the session (Apple Health
@@ -958,10 +974,9 @@ export function reconstructSleepSessions(
priorityJson: unknown = null,
): SleepSession[] {
if (rows.length === 0) return [];
const sleepLadder = getSourceLadder(
parseSourcePriority(priorityJson),
"sleep",
);
const resolvedPriority = parseSourcePriority(priorityJson);
const sleepLadder = getSourceLadder(resolvedPriority, "sleep");
const deviceLadder = getDeviceTypeLadder(resolvedPriority, "SLEEP_DURATION");
const startOf = (r: SleepStageRow): number =>
r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000;
@@ -984,7 +999,7 @@ export function reconstructSleepSessions(
const sessions: SleepSession[] = [];
for (const session of rawSessions) {
const canonical = pickSessionWriter(session, sleepLadder);
const canonical = pickSessionWriter(session, sleepLadder, deviceLadder);
const kept = session.filter((r) => writerKeyOf(r) === canonical);
const pool = kept.length > 0 ? kept : session;
// v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less
@@ -354,6 +354,13 @@ describe("buildMedsTodayBlock — next due (real engine)", () => {
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.dueCandidates).toEqual([
{
medicationId: "med-mounjaro",
medicationName: "Mounjaro",
dueAt: "2026-06-10T09:00:00.000Z",
overdue: true,
availableFrom: "2026-06-09T08:00:00.000Z",
},
{
medicationId: "med-ramipril",
medicationName: "Ramipril",
@@ -361,18 +368,11 @@ describe("buildMedsTodayBlock — next due (real engine)", () => {
overdue: true,
availableFrom: "2026-06-10T07:00:00.000Z",
},
{
medicationId: "med-mounjaro",
medicationName: "Mounjaro",
dueAt: "2026-06-10T09:00:00.000Z",
overdue: false,
availableFrom: "2026-06-09T08:00:00.000Z",
},
]);
expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z");
expect(block.nextDueOverdue).toBe(true);
expect(block.nextDueMedicationName).toBe("Ramipril");
expect(block.nextDueMedicationId).toBe("med-ramipril");
expect(block.nextDueMedicationName).toBe("Mounjaro");
expect(block.nextDueMedicationId).toBe("med-mounjaro");
});
it("returns null next-due when no schedule has an upcoming slot", async () => {
@@ -61,7 +61,10 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock,
}));
vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock }));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return { ...actual, refreshAccessToken: refreshAccessTokenMock };
});
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake }));
@@ -223,7 +226,20 @@ describe("getValidToken — dead-token cycle verdict", () => {
const res = await syncUserGoogleHealth("user-1");
expect(res).toEqual({ imported: 0, failed: true });
expect(res).toEqual(
expect.objectContaining({
imported: 0,
failed: true,
state: "failed",
resources: expect.arrayContaining([
expect.objectContaining({
status: "failed",
reasonCode: "token_failed",
written: 0,
}),
]),
}),
);
expect(recordSyncSuccessMock).not.toHaveBeenCalled();
// No connection write may carry the watermark stamp.
for (const call of prismaMock.googleHealthConnection.update.mock.calls) {
@@ -45,7 +45,10 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return { ...actual, refreshAccessToken: vi.fn() };
});
import { upsertGoogleHealthMeasurements } from "../sync-core";
@@ -74,13 +74,13 @@ describe("apple health import — pg-boss send policy", () => {
});
describe("apple health import — parser revision boundary", () => {
it("isolates revision-2 sends from legacy workers", () => {
expect(APPLE_HEALTH_IMPORT_PARSER_REVISION).toBe(2);
it("isolates revision-3 sends from legacy workers", () => {
expect(APPLE_HEALTH_IMPORT_PARSER_REVISION).toBe(3);
expect(APPLE_HEALTH_IMPORT_V2_QUEUE).toBe("apple-health-import-v2");
expect(APPLE_HEALTH_IMPORT_LEGACY_QUEUE).toBe("apple-health-import");
});
it("drains revision-2 jobs separately while bridging legacy backlog", () => {
it("drains current-parser jobs separately while bridging legacy backlog", () => {
const allQueues = maintenanceSource.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
@@ -110,7 +110,7 @@ describe("apple health import — parser revision boundary", () => {
);
});
it("explicitly creates revision-2 stand-ins and reconciles only revision-2 mirrors", () => {
it("explicitly creates current-revision stand-ins and reconciles only current-revision mirrors", () => {
expect(workerSource).toMatch(
/importJob\.create\([\s\S]{0,400}parserRevision:\s*APPLE_HEALTH_IMPORT_PARSER_REVISION/,
);
@@ -56,7 +56,11 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("@/lib/google-health/credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("@/lib/google-health/client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("@/lib/google-health/client", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/google-health/client")>();
return { ...actual, refreshAccessToken: vi.fn() };
});
import {
GOOGLE_HEALTH_TOKEN_HARD_FAIL,
@@ -11,6 +11,7 @@ const { syncUserEcg } = vi.hoisted(() => ({
) => Promise<number>
>(),
}));
const isReauthRequired = vi.hoisted(() => vi.fn(async () => false));
vi.mock("@/lib/withings/sync-ecg", () => ({
syncUserEcg: (
@@ -34,6 +35,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
recordError: vi.fn(),
recordWithingsSync: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired,
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
@@ -76,6 +80,7 @@ function job(data: WithingsEcgSyncPayload): Job<WithingsEcgSyncPayload> {
beforeEach(() => {
vi.clearAllMocks();
ecgFindMany.mockResolvedValue([]);
isReauthRequired.mockResolvedValue(false);
});
const registrarSource = readFileSync(
join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"),
@@ -96,10 +101,20 @@ describe("handleWithingsEcgSync", () => {
await expect(handleWithingsEcgSync([queued])).resolves.toEqual({
ok: true,
did: {
provider: "withings",
outcome: "useful",
total: 1,
users_synced: 1,
users_complete: 1,
users_partial: 0,
users_failed: 0,
recordings_imported: 1,
users_parked: 0,
users_skipped: 0,
users_useful: 1,
users_clean_zero: 0,
users_retryable: 0,
downstream_failed: 0,
measurements_imported: 1,
},
});
@@ -140,10 +155,20 @@ describe("handleWithingsEcgSync", () => {
).resolves.toEqual({
ok: true,
did: {
provider: "withings",
outcome: "clean_zero",
total: 2,
users_synced: 1,
users_complete: 1,
users_partial: 0,
users_failed: 1,
recordings_imported: 0,
users_parked: 0,
users_skipped: 0,
users_useful: 0,
users_clean_zero: 1,
users_retryable: 1,
downstream_failed: 0,
measurements_imported: 0,
},
});
expect(syncUserEcg).toHaveBeenCalledTimes(2);
@@ -9,6 +9,7 @@ const syncUserMeasurements = vi.hoisted(() =>
);
const findMany = vi.hoisted(() => vi.fn().mockResolvedValue([]));
const addWarning = vi.hoisted(() => vi.fn());
const isReauthRequired = vi.hoisted(() => vi.fn(async () => false));
vi.mock("@/lib/withings/sync", () => ({
retryDueWithingsWebhookSubscriptions,
@@ -30,6 +31,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
recordError: vi.fn(),
recordWithingsSync: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired,
}));
vi.mock("@/lib/logging/fire-and-forget", () => ({
fireAndForget: vi.fn(),
}));
@@ -66,6 +70,7 @@ beforeEach(() => {
findMany.mockResolvedValue([]);
retryDueWithingsWebhookSubscriptions.mockResolvedValue(0);
syncUserMeasurements.mockResolvedValue({ imported: 0, failed: false });
isReauthRequired.mockResolvedValue(false);
});
describe("handleWithingsFallbackSync subscription repair", () => {
@@ -77,11 +82,20 @@ describe("handleWithingsFallbackSync subscription repair", () => {
await expect(handleWithingsFallbackSync([queued])).resolves.toEqual({
ok: true,
did: {
provider: "withings",
outcome: "clean_zero",
total: 0,
users_synced: 0,
users_complete: 0,
users_partial: 0,
users_failed: 0,
users_parked: 0,
users_skipped: 0,
users_useful: 0,
users_clean_zero: 0,
users_retryable: 0,
downstream_failed: 0,
measurements_imported: 0,
subscription_repair_failed: false,
},
});
@@ -106,11 +120,20 @@ describe("handleWithingsFallbackSync subscription repair", () => {
await expect(handleWithingsFallbackSync([queued])).resolves.toEqual({
ok: true,
did: {
provider: "withings",
outcome: "clean_zero",
total: 2,
users_synced: 2,
users_complete: 2,
users_partial: 0,
users_failed: 0,
users_parked: 0,
users_skipped: 0,
users_useful: 0,
users_clean_zero: 2,
users_retryable: 0,
downstream_failed: 1,
measurements_imported: 0,
subscription_repair_failed: true,
},
});
@@ -70,7 +70,9 @@ describe("makePollCohortHandler — marker-aware recordFailure boundary", () =>
"boom",
expect.objectContaining({ message: "unmarked write failure" }),
);
expect(warnings.some((w) => w.includes("boom"))).toBe(true);
expect(warnings).toContain("job.test_sync failed for one cohort member");
expect(warnings.join(" ")).not.toContain("boom");
expect(warnings.join(" ")).not.toContain("unmarked write failure");
});
it("does NOT record a MARKED error (the source already recorded it)", async () => {
@@ -88,8 +90,10 @@ describe("makePollCohortHandler — marker-aware recordFailure boundary", () =>
await handler([job()]);
expect(recordFailure).not.toHaveBeenCalled();
// Still warned so the cohort event carries the failure.
expect(warnings.some((w) => w.includes("already recorded"))).toBe(true);
// Still warned so the cohort event carries the failure, without copying
// provider/user details into the shared job log.
expect(warnings).toContain("job.test_sync failed for one cohort member");
expect(warnings.join(" ")).not.toContain("already recorded at source");
});
it("a recorder rejection never breaks the cohort loop", async () => {
@@ -140,15 +140,10 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
expect(summary.latest).toBe(420);
});
// v1.4.49.1 (commit 6b7b8a7f) delegated the default /api/analytics slice
// to the slim rollup-tier reader, which counts raw rows per type instead
// of running pickCanonicalSourceRows. The picker now only fires on the
// cumulative-day-sum + workout paths. The three two-axis-override tests
// below exercise the summaries path and therefore no longer see picker
// semantics — they are skipped until source priority is reintroduced
// there or the tests are relocated to a path where the picker still
// runs. Covered indirectly today by pick-canonical-workout-rows.test.ts.
it.skip("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
// The slim summaries reader post-processes sleep rows through the canonical
// per-night writer picker. These DB-backed cases pin that final route seam,
// not only the pure source-priority helper.
it("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
// Same night, both sources contributed sleep duration. Without an
// override the default ladder (APPLE_HEALTH > WITHINGS) would win;
// with the override the picker returns the Withings row instead.
@@ -191,7 +186,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
expect(summary.latest).toBe(410);
});
it.skip("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
it("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
// Same source, same night — but Apple Watch and iPhone both wrote
// sleep samples. The default device-type ladder ranks watch above
// phone, so the watch row should survive.
@@ -202,7 +197,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
@@ -214,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 380,
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
@@ -227,10 +222,10 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
const summary = envelope.data.summaries.SLEEP_DURATION;
// Watch wins; the phone row drops out of the aggregate.
expect(summary.count).toBe(1);
expect(summary.latest).toBe(480);
expect(summary.latest).toBe(380);
});
it.skip("user override flips device-type ladder so phone wins", async () => {
it("user override flips device-type ladder so phone wins", async () => {
// Same fixture as the previous test, but the user pinned phone
// first via the deviceTypePriority.default ladder.
const prisma = getPrismaClient();