Merge pull request #467 from MBombeck/release/v1.28.23

release/v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-11 00:28:50 +02:00
commit 1b512eda1d
29 files changed
+1585 -276

No files matched your search

+20
View File
@@ -2,6 +2,26 @@
## [Unreleased]
## [1.28.23] — 2026-07-11 — Sleep disagreement visible on the dashboard; Apple Health medication groundwork
When two sources disagree about last night's sleep — a sleep mat's time in bed
against a watch's time asleep — the dashboard sleep tile now carries a small
marker next to the value. The tooltip names each source and its total, so a
surprising number explains itself instead of silently picking a side. The same
marker backs the sleep panel; the shown total is unchanged either way.
Groundwork for importing medications and their taken doses from Apple Health
(iOS 26+): the server now accepts medications mirrored from an external source
and dose events carrying a stable external identity, imported idempotently —
re-syncs never duplicate. A mirrored medication is source-exclusive, so an
imported dose can never double-count against a manually logged one. The
matching client support arrives with a future app update.
Hardening: the doctor-report aggregation and PDF charts now compute their
ranges with loops instead of spread calls, so a report over a sample-dense year
(per-beat heart rate, sensor glucose) cannot overflow the call stack — the same
failure class fixed for the Google Health full sync.
## [1.28.22] — 2026-07-10 — Full sync survives dense histories; quieter document chrome
A full Google Health sync no longer fails on sample-dense accounts. Collecting
+56 -2
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.22
version: 1.28.23
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -10895,6 +10895,20 @@ components:
maxLength: 20
pattern: ^\d+$
- type: "null"
externalSource:
description: "Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+
HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication
is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays
off it."
type: string
const: APPLE_HEALTH
externalId:
description: "Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max
128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns
the existing medication with status 200 instead of minting a duplicate."
type: string
minLength: 1
maxLength: 128
startsOn:
description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the
rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour).
@@ -10936,7 +10950,9 @@ components:
schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and
`timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a
PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication
still requires at least one schedule entry.'
still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the
medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing
medication with status 200.'
MedicationScheduleInput:
type: object
properties:
@@ -22621,6 +22637,44 @@ components:
- summary
- entries
additionalProperties: false
sleepSourceDiscrepancy:
description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main
session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays
the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep
tile's headline. Null when the writers agree or the sleep module is off; optional for older cached
snapshots.
anyOf:
- type: object
properties:
deltaMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
sources:
type: array
items:
type: object
properties:
source:
type: string
deviceType:
anyOf:
- type: string
- type: "null"
asleepMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- source
- deviceType
- asleepMinutes
additionalProperties: false
required:
- deltaMinutes
- sources
additionalProperties: false
- type: "null"
required:
- summaries
- lastSeenByType
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.22",
"version": "1.28.23",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,62 @@
-- v1.28 — Apple Health medications + intake (server contract, #423).
--
-- iOS 26+ HealthKit exposes the user's medication list
-- (`HKUserAnnotatedMedication`, stable `medicationConceptIdentifier`) and
-- dose events (`HKMedicationDoseEvent`, stable UUID, taken/skipped),
-- read-only. The server side is purely additive: a provenance pair on
-- `medications` (a med with `external_source` set is MIRRORED from that
-- source — source-exclusive, no cross-source slot collapse) and an
-- external dose-event id on `medication_intake_events` that makes the
-- bulk re-sync idempotent.
-- ── 1. intake_source — append the Apple Health source ──────────────────
--
-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT
-- EXISTS` makes the rerun safe. The new value is NOT used elsewhere in
-- this migration (the columns below are typed `intake_source` but no row
-- is inserted or updated), so it is safe to extend the enum in the same
-- step — Postgres only forbids USING a freshly-added enum value inside
-- the transaction that added it.
ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
-- ── 2. medications — mirrored-medication provenance ────────────────────
--
-- `external_source` non-NULL marks the row a read-only mirror of an
-- external medication list; `external_id` carries the source's stable
-- concept identifier (HealthKit `medicationConceptIdentifier`). Both
-- nullable, no default, no backfill: every existing row stays a native
-- HealthLog medication.
ALTER TABLE "medications"
ADD COLUMN "external_source" "intake_source",
ADD COLUMN "external_id" TEXT;
-- One mirror row per external concept per user. A plain (non-partial)
-- unique matching the schema's `@@unique([userId, externalSource,
-- externalId])`: Postgres treats NULLs as distinct in unique indexes, so
-- the existing rows (both columns NULL) are unaffected — only genuinely
-- mirrored medications occupy a slot. The create route pre-queries the
-- triple and returns the existing medication; this index is the race
-- backstop (concurrent create surfaces P2002, which the route resolves
-- to the winning row).
CREATE UNIQUE INDEX "medications_user_id_external_source_external_id_key"
ON "medications" ("user_id", "external_source", "external_id");
-- ── 3. medication_intake_events — external dose-event id ───────────────
--
-- Carries the HealthKit `HKMedicationDoseEvent` UUID. Nullable, no
-- backfill: NULL for every non-mirrored row.
ALTER TABLE "medication_intake_events"
ADD COLUMN "external_id" TEXT;
-- Idempotent re-import: one LIVE row per `(user_id, external_id)`. A
-- partial unique like the live-row slot unique from migration 0121 —
-- restricted to non-NULL externalIds (rows without one, i.e. everything
-- pre-Apple, stay unconstrained and out of the index) and to live rows
-- (`deleted_at IS NULL`, so a tombstone never blocks the sync ledger).
-- The bulk intake route pre-checks the batch's externalIds and reports
-- replays as duplicates; this index is the race backstop. Prisma can't
-- express a partial-predicate unique, so this is hand-written and the
-- schema keeps the full `@@unique` for the compound where-key.
CREATE UNIQUE INDEX "medication_intake_events_user_external_id_live_key"
ON "medication_intake_events" ("user_id", "external_id")
WHERE "external_id" IS NOT NULL AND "deleted_at" IS NULL;
+43
View File
@@ -2231,6 +2231,20 @@ model Medication {
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
/// backfill.
reorderLeadDays Int? @map("reorder_lead_days")
/// v1.28 — provenance of a MIRRORED medication. Non-NULL marks this row a
/// read-only mirror of an external medication list (today only
/// APPLE_HEALTH — the iOS 26+ HealthKit Medications sync). Source-
/// exclusive by policy: dose events from that source attach only to
/// medications mirrored from it, and the app's own dose-logging UI is
/// expected to stay off a mirrored medication client-side. NULL = a
/// native HealthLog medication.
externalSource IntakeSource? @map("external_source")
/// v1.28 — stable external identifier of the mirrored medication (the
/// HealthKit `medicationConceptIdentifier`). Paired with `externalSource`
/// (both set or both NULL; the create route enforces the pairing). Drives
/// the idempotent re-mirror on `POST /api/medications` via the
/// `(userId, externalSource, externalId)` unique below.
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2276,6 +2290,13 @@ model Medication {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// v1.28 — one mirror row per external medication concept per user
/// (migration 0239). Postgres treats NULLs as distinct in unique
/// indexes, so the vast majority of rows (both columns NULL) are
/// unaffected; only genuinely mirrored medications occupy a slot. A
/// re-mirror of the same concept id returns the existing row (the
/// create route pre-queries the triple and catches the P2002 race).
@@unique([userId, externalSource, externalId])
@@index([userId])
@@index([treatmentClass])
@@map("medications")
@@ -2488,6 +2509,11 @@ enum IntakeSource {
API
REMINDER
IMPORT
/// v1.28 — Apple Health medication sync (iOS 26+ HealthKit Medications
/// API). Spelled exactly like `MeasurementSource.APPLE_HEALTH`; the iOS
/// DTO mirrors this wire spelling. Doubles as the provenance label on
/// `Medication.externalSource` for mirrored medications.
APPLE_HEALTH
@@map("intake_source")
}
@@ -2589,6 +2615,13 @@ model MedicationIntakeEvent {
/// stamp freezes history: editing `Medication.unitsPerDose` later
/// never changes what an already-taken dose consumed.
inventoryConsumption Json? @map("inventory_consumption")
/// v1.28 — external dose-event identifier (the HealthKit
/// `HKMedicationDoseEvent` UUID). Drives idempotent re-import on the
/// bulk intake route: a re-synced Apple dose dedups against the live
/// `(userId, externalId)` unique below instead of inserting again
/// (first-write-wins per external dose event). NULL for every
/// non-mirrored row.
externalId String? @map("external_id")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
@@ -2624,6 +2657,16 @@ model MedicationIntakeEvent {
/// full `@@unique` for the `userId_medicationId_scheduledFor_source`
/// compound where-key.
@@unique([userId, medicationId, scheduledFor, source])
/// v1.28 — `medication_intake_events_user_external_id_live_key` is a
/// partial UNIQUE index over `(user_id, external_id) WHERE external_id
/// IS NOT NULL AND deleted_at IS NULL` — see migration 0239. The bulk
/// intake route pre-checks the batch's externalIds and reports replays
/// as duplicates; the index is the race backstop (a concurrent insert
/// surfaces P2002, which the route resolves to the winning row). Prisma
/// can't express a partial-predicate unique (same as the live-row slot
/// unique above, migration 0121), so the schema keeps the full
/// `@@unique` for the `userId_externalId` compound where-key.
@@unique([userId, externalId])
@@index([userId, medicationId, scheduledFor])
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.22";
/* @sw-version-fallback */ "v1.28.23";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+5
View File
@@ -485,6 +485,11 @@ async function buildAnalyticsResponse(user: AuthedUser) {
correlations,
healthScore,
sleepStages,
// v1.28.x — the latest night's source-discrepancy annotation, computed
// inside the slim slice's per-night post-pass. Additive: the dashboard
// sleep tile marks the headline with a discreet "sources disagree"
// hint; clients that don't read the field stay unchanged.
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
// v1.4.34 IW-B — per-type freshness map drives the dashboard's
// staleness caption on each `<TrendCard>`. Additive: clients that
// don't read the field stay unchanged.
@@ -572,6 +572,14 @@ describe("GET /api/dashboard/summary", () => {
latestValue: number | null;
unit: string | null;
sleepStages: Record<string, number> | null;
sleepSourceDiscrepancy: {
deltaMinutes: number;
sources: Array<{
source: string;
deviceType: string | null;
asleepMinutes: number;
}>;
} | null;
}>;
};
};
@@ -585,6 +593,82 @@ describe("GET /api/dashboard/summary", () => {
expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2);
expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2);
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
// v1.28.x — the additive discrepancy field is present and null-safe:
// source-less single-writer rows carry no annotation.
expect(sleep?.sleepSourceDiscrepancy).toBeNull();
// Every non-sleep kind pins the additive field to null.
for (const m of body.data.metrics) {
if (m.id !== "sleep") expect(m.sleepSourceDiscrepancy).toBeNull();
}
});
it("carries the latest night's source-discrepancy annotation on the sleep tile (v1.28.x)", async () => {
// Two writers disagree on the night: granular Apple (410 asleep) next
// to a bare Withings aggregate claiming 615. Apple wins the served
// total; the additive annotation surfaces the disagreement.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 4 },
_max: { measuredAt: wake },
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
value: 240,
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
sleepStage: "CORE",
source: "APPLE_HEALTH",
},
{
value: 90,
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
sleepStage: "DEEP",
source: "APPLE_HEALTH",
},
{
value: 80,
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
sleepStage: "REM",
source: "APPLE_HEALTH",
},
{
value: 615,
measuredAt: wake,
sleepStage: "ASLEEP",
source: "WITHINGS",
},
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
metrics: Array<{
id: string;
latestValue: number | null;
sleepSourceDiscrepancy: {
deltaMinutes: number;
sources: Array<{
source: string;
deviceType: string | null;
asleepMinutes: number;
}>;
} | null;
}>;
};
};
const sleep = body.data.metrics.find((m) => m.id === "sleep");
expect(sleep, "sleep tile must be emitted").toBeDefined();
// Served total stays the winning writer's number (410 min → 6.83 h).
expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2);
expect(sleep?.sleepSourceDiscrepancy).toEqual({
deltaMinutes: 205,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{ source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 410 },
],
});
});
it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => {
+29
View File
@@ -60,6 +60,7 @@ import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
summarizeSleepNights,
reconstructSleepNights,
type SleepSourceDiscrepancy,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -130,6 +131,16 @@ interface MetricCard {
* headline `latestValue`.
*/
sleepStages: Partial<Record<SleepStage, number>> | null;
/**
* v1.28.x — source-discrepancy annotation for the headline night's MAIN
* session, sleep tile only; `null` for every other kind and for a night
* whose writers agree. Additive (the established wire pattern for the
* iOS-locked shape): observational only — `latestValue` stays the
* winning writer's total; clients render a discreet "sources disagree"
* hint next to the number. Minutes are whole (rounded), matching the
* `/api/sleep/night` serialisation.
*/
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
@@ -707,6 +718,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.weight,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -747,6 +759,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.bloodPressure,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(sysSpark),
sparkline: sysSpark,
updatedAt:
@@ -776,6 +789,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.pulse,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -803,6 +817,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.bodyFat,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -862,6 +877,19 @@ async function buildDashboardSummary(
unit: "h",
sleepStages:
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
// Additive, observational: the headline night's MAIN session saw
// two writer buckets with clearly different asleep totals. Round
// to whole minutes, matching the `/api/sleep/night` serialisation.
sleepSourceDiscrepancy: night?.sourceDiscrepancy
? {
deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes),
sources: night.sourceDiscrepancy.sources.map((b) => ({
source: b.source,
deviceType: b.deviceType,
asleepMinutes: Math.round(b.asleepMinutes),
})),
}
: null,
trend: trendOf(nightSpark),
sparkline: nightSpark,
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
@@ -881,6 +909,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS[kind],
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn().mockResolvedValue([]),
// v1.28 — the Apple-mirror idempotency pre-query.
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
},
medicationIntakeEvent: {
@@ -497,3 +499,134 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.28 — Apple Health mirrored medications (#423)
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
const MIRROR_BODY = {
name: "Ramipril",
dose: "5 mg",
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
};
function lastCreateData(): Record<string, unknown> {
const calls = vi.mocked(prisma.medication.create).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return (calls[calls.length - 1][0] as { data: Record<string, unknown> })
.data;
}
it("persists externalSource + externalId field-by-field on create", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(201);
const data = lastCreateData();
expect(data.externalSource).toBe("APPLE_HEALTH");
expect(data.externalId).toBe("hk-concept-1");
// The pre-query probed the mirror triple, user-scoped.
expect(prisma.medication.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: "user-1",
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
},
}),
);
});
it("returns the EXISTING medication (200) on a re-post of the same triple — no duplicate row", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "med-existing",
userId: "user-1",
name: "Ramipril",
dose: "5 mg",
unitsPerDose: 1,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
} as never);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { id: string; externalId: string; category: string };
};
expect(body.data.id).toBe("med-existing");
expect(body.data.externalId).toBe("hk-concept-1");
expect(body.data.category).toBe("OTHER");
// Idempotent replay: nothing written.
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("resolves a concurrent P2002 race on the mirror triple to the winning row (200)", async () => {
// Pre-query misses; the create then loses the race against a
// concurrent mirror of the same concept id.
vi.mocked(prisma.medication.findFirst)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce({
id: "med-winner",
userId: "user-1",
name: "Ramipril",
dose: "5 mg",
unitsPerDose: 1,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
} as never);
vi.mocked(prisma.medication.create).mockRejectedValueOnce(
Object.assign(new Error("unique"), { code: "P2002" }),
);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { id: string } };
expect(body.data.id).toBe("med-winner");
});
it("422s when externalSource is supplied without externalId", async () => {
const res = await POST(postReq({ ...MIRROR_BODY, externalId: undefined }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
expect(
body.details.issues.some((i) =>
i.message.includes("must be supplied together"),
),
).toBe(true);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s when externalId is supplied without externalSource", async () => {
const res = await POST(
postReq({ ...MIRROR_BODY, externalSource: undefined }),
);
expect(res.status).toBe(422);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s on a non-Apple externalSource value (server-owned sources stay closed)", async () => {
const res = await POST(postReq({ ...MIRROR_BODY, externalSource: "WEB" }));
expect(res.status).toBe(422);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("a plain create keeps today's behavior — no external columns forwarded", async () => {
const res = await POST(
postReq({
name: "Ramipril",
dose: "5 mg",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
const data = lastCreateData();
expect("externalSource" in data).toBe(false);
expect("externalId" in data).toBe(false);
});
});
@@ -1088,3 +1088,273 @@ describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption",
expect(consumeForIntake).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────────
// v1.28 — Apple Health dose events (#423)
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", () => {
const MIRRORED_MED = {
id: "med-apple",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
};
beforeEach(() => {
// 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
// The ownership read surfaces the mirror flag for the source gate.
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-apple", externalSource: "APPLE_HEALTH" },
] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
MIRRORED_MED as never,
);
});
afterEach(() => {
vi.useRealTimers();
});
it("422s the batch when an APPLE_HEALTH entry targets a NON-mirrored medication", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-native", externalSource: null },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-native",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe(
"medications.intake.bulk.apple_health_not_mirrored",
);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("422s an APPLE_HEALTH entry without externalId (both-or-neither refine)", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
});
it("422s an externalId without source APPLE_HEALTH", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(422);
});
it("reports a replayed external dose-event UUID as duplicate without writing (idempotent re-sync)", async () => {
// The batch pre-check finds the UUID already live.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "row-existing", externalId: "hk-dose-1" },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
duplicates: number;
inserted: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.duplicates).toBe(1);
expect(body.data.inserted).toBe(0);
expect(body.data.entries[0].status).toBe("duplicate");
expect(body.data.entries[0].id).toBe("row-existing");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(consumeForIntake).not.toHaveBeenCalled();
});
it("inserts a fresh Apple dose with source APPLE_HEALTH + externalId stored; a same-batch repeat dedups", async () => {
// Dedup pre-check: nothing live for the batch's UUIDs.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce(
[] as never,
);
// 14:00 Berlin — off-band, so the take records standalone (ad-hoc).
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-apple",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
},
// The same dose event repeated inside ONE batch (a client-side
// pagination overlap) — must dedup in-memory, not double-insert.
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
expect(body.data.entries[0].status).toBe("inserted");
expect(body.data.entries[1].status).toBe("duplicate");
expect(body.data.entries[1].id).toBe("row-apple");
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
takenAt: new Date("2026-06-15T12:00:00.000Z"),
}),
}),
);
});
it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => {
// Dedup pre-check first (empty), then the canonical-slot find returns
// the worker-minted pending REMINDER row for the 19:00 slot.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
id: "row-evening",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T17:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-evening",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T17:30:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-3",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string }> };
};
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "row-evening" },
data: expect.objectContaining({ externalId: "hk-dose-3" }),
}),
);
});
it("keeps a source-less entry byte-identical to today: source API, no externalId column", async () => {
// No entry carries an externalId, so the dedup pre-check must not
// fire a findMany at all — the hot path pays zero extra reads.
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-adhoc",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inserted: number; entries: Array<{ status: string }> };
};
expect(body.data.inserted).toBe(1);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
const createArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0] as { data: Record<string, unknown> };
expect(createArgs.data.source).toBe("API");
expect("externalId" in createArgs.data).toBe(false);
});
});
+167 -38
View File
@@ -20,6 +20,13 @@
* forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
* named real scheduled slot (server-validated
* against the band anchors)
* source?, — "APPLE_HEALTH" only (v1.28 HealthKit dose
* import); absent keeps the default "API".
* Requires externalId and a medication
* mirrored from Apple Health.
* externalId?, — HK dose-event UUID (max 128); drives the
* idempotent re-sync dedup (first-write-wins
* per Apple dose)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
@@ -77,39 +84,62 @@ const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
// intake route: pin a taken entry onto a named real scheduled slot
// instead of the default window-band attribution. Validated server-side
// against the band anchors; an instant that is not a slot marks the
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
// Ignored on non-taken entries (a pending echo / skip carries no take
// to attribute).
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.4 — per-entry dose override, mirroring the single intake
// route: free text (max 50 chars, like `Medication.dose`), persisted
// only on a taken entry. Absent = the configured medication dose
// applies.
doseTaken: z.string().trim().min(1).max(50).optional(),
});
const bulkEntrySchema = z
.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
// intake route: pin a taken entry onto a named real scheduled slot
// instead of the default window-band attribution. Validated server-side
// against the band anchors; an instant that is not a slot marks the
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
// Ignored on non-taken entries (a pending echo / skip carries no take
// to attribute).
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.4 — per-entry dose override, mirroring the single intake
// route: free text (max 50 chars, like `Medication.dose`), persisted
// only on a taken entry. Absent = the configured medication dose
// applies.
doseTaken: z.string().trim().min(1).max(50).optional(),
// v1.28 — Apple Health dose-event import (iOS 26+ HealthKit
// Medications API). Only the APPLE_HEALTH literal is accepted — the
// other IntakeSource values stay server-owned; absent keeps today's
// default ("API"). Must carry `externalId` (refine below), and may
// only target a medication mirrored from Apple Health (route-level
// 422). logStatus mapping is client-side: iOS ships taken/skipped
// shaped like every other entry.
source: z.literal("APPLE_HEALTH").optional(),
// v1.28 — the HealthKit `HKMedicationDoseEvent` UUID. Drives the
// idempotent re-sync: an entry whose `(userId, externalId)` already
// exists live reports `duplicate` (first-write-wins per Apple dose).
externalId: z.string().trim().min(1).max(128).optional(),
})
.refine(
// v1.28 — both-or-neither: an Apple entry without its dose-event UUID
// cannot dedup, and a bare externalId without the source label has no
// defined provenance.
(e) => (e.source === "APPLE_HEALTH") === (e.externalId !== undefined),
{
message: "source APPLE_HEALTH and externalId must be supplied together",
path: ["externalId"],
},
);
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
@@ -202,17 +232,70 @@ async function postBulk(request: NextRequest): Promise<Response> {
where: { id: { in: medicationIds }, userId: user.id },
// v1.8.5 — pull the injection-tracking fields so a per-entry
// `injectionSite` can be resolved + validated without an extra
// per-entry read.
// per-entry read. v1.28 — `externalSource` rides along so the
// Apple-entry mirror gate below needs no extra read either.
select: {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// v1.28 — source-exclusive Apple-mirrored medications: an APPLE_HEALTH
// dose event may only attach to a medication mirrored from Apple Health
// (`Medication.externalSource === "APPLE_HEALTH"`). Targeting a native
// medication is a client contract violation (the iOS sync maps HK dose
// events onto the meds it mirrored itself), not a per-entry data
// problem, so the whole batch 422s with a stable errorCode the client
// can branch on. Unowned medicationIds keep the per-entry
// `medication_not_found` skip below.
const appleMismatch = entries.some(
(e) =>
e.source === "APPLE_HEALTH" &&
ownedSet.has(e.medicationId) &&
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
);
if (appleMismatch) {
return apiError(
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
422,
{ errorCode: "medications.intake.bulk.apple_health_not_mirrored" },
);
}
// v1.28 — idempotent re-sync dedup: one read collects every live row
// already carrying one of the batch's external dose-event UUIDs; the
// per-entry loop reports those entries as `duplicate` (first-write-wins
// per Apple dose) without touching the DB again. Fresh inserts register
// into the same map so an intra-batch repeat of a UUID dedups too. The
// partial `(user_id, external_id)` unique (migration 0239) is the race
// backstop.
const batchExternalIds = Array.from(
new Set(
entries
.map((e) => e.externalId)
.filter((id): id is string => id !== undefined),
),
);
const rowIdByExternalId = new Map<string, string>();
if (batchExternalIds.length > 0) {
const existingExternalRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
externalId: { in: batchExternalIds },
deletedAt: null,
},
select: { id: true, externalId: true },
});
for (const row of existingExternalRows) {
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
}
}
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
// whole batch (it is user-scoped, not per-medication). Only read when
// at least one entry actually carries an `injectionSite`, so the hot
@@ -262,6 +345,19 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// v1.28 — Apple dose-event replay: the batch pre-check found a live
// row already carrying this external UUID (or an earlier entry in
// THIS batch inserted it). First-write-wins per Apple dose — report
// `duplicate` with the existing row id so the sync cursor advances.
if (entry.externalId !== undefined) {
const existingRowId = rowIdByExternalId.get(entry.externalId);
if (existingRowId !== undefined) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: existingRowId });
continue;
}
}
try {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
@@ -414,11 +510,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
// v1.28 — an Apple dose-event import stamps its provenance on a
// fresh row; every other caller keeps today's "API".
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
// v1.16.4 — dose override only documents a consumed dose.
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
@@ -524,10 +624,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
// v1.28 — Apple provenance on a fresh row (see above).
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
duplicates += 1;
@@ -573,8 +676,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
scheduledFor: effectiveScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
// v1.28 — Apple provenance on a standalone (ad-hoc) row;
// every other caller keeps today's "API".
source: entry.source ?? "API",
idempotencyKey: entry.idempotencyKey ?? null,
// v1.28 — external dose-event UUID for the idempotent
// re-sync dedup.
...(entry.externalId !== undefined && {
externalId: entry.externalId,
}),
// v1.8.5 — site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
@@ -612,6 +722,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
// only when the row's state actually changed (inserted / updated). A
// pending echo, duplicate, or no-downgrade no-op leaves the visible
// dose state untouched, so it must not wake the user's other devices.
// v1.28 — register the landed row under its external dose-event
// UUID so a repeat of the same UUID later in THIS batch dedups
// in-memory instead of tripping the partial unique.
const landed = results[results.length - 1];
if (entry.externalId !== undefined && landed?.id !== undefined) {
rowIdByExternalId.set(entry.externalId, landed.id);
}
const lastStatus = results[results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
@@ -638,7 +755,19 @@ async function postBulk(request: NextRequest): Promise<Response> {
where: { idempotencyKey: entry.idempotencyKey },
select: { id: true },
})
: null;
: // v1.28 — an Apple entry that raced the partial
// `(user_id, external_id)` unique (concurrent batches syncing
// the same dose event) resolves to the winning live row.
entry.externalId
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
externalId: entry.externalId,
deletedAt: null,
},
select: { id: true },
})
: null;
duplicates += 1;
results.push({
index: i,
+185 -97
View File
@@ -280,6 +280,43 @@ export const GET = apiHandler(async () => {
return apiSuccess(result);
});
/**
* v1.28 — idempotent response for a re-mirrored medication. The create
* route returns the EXISTING row (status 200, vs 201 for a fresh create)
* when the caller re-posts a `(externalSource, externalId)` pair this
* user already mirrors — the iOS re-sync replays its full HealthKit
* medication list, and a replay must neither duplicate nor 500. Response
* shape mirrors the fresh-create envelope (schedules included, category
* joined, Decimal unwrapped); no audit row and no cache eviction because
* nothing was written.
*/
async function respondWithExistingMirror(
medication: Record<string, unknown> & { id: string; unitsPerDose: unknown },
): Promise<Response> {
let category = "OTHER";
try {
const categories = await getMedicationCategories([medication.id]);
category = categories[medication.id] ?? "OTHER";
} catch {
getEvent()?.addWarning("Medication categories could not be loaded");
}
annotate({
action: {
name: "medication.create",
entity_type: "medication",
entity_id: medication.id,
},
meta: { idempotent_replay: true },
});
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
category,
});
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -310,6 +347,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
criticalAlarmEnabled,
atcCode,
rxNormCode,
externalSource,
externalId,
schedules,
startsOn,
endsOn,
@@ -317,6 +356,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
asNeeded,
} = parsed.data;
// v1.28 — idempotent mirror create. A mirrored medication (Apple
// Health) is keyed by the `(userId, externalSource, externalId)`
// unique; a re-post of the same concept id (iOS re-sync, retry after a
// dropped response) returns the EXISTING medication with 200 instead
// of minting a duplicate or 500-ing on P2002. The pre-query covers the
// common replay; the P2002 catch around the create below covers the
// concurrent race.
if (externalSource !== undefined && externalId !== undefined) {
const existing = await prisma.medication.findFirst({
where: { userId: user.id, externalSource, externalId },
include: { schedules: true },
});
if (existing) {
return respondWithExistingMirror(existing);
}
}
// ── v1.5 route invariants for the new scheduling primitives ───────
//
// The Zod layer already enforces `rrule` xor `rollingIntervalDays`
@@ -367,106 +423,138 @@ export const POST = apiHandler(async (request: NextRequest) => {
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
const medication = await prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — optional reorder lead override; null/omitted = inherit
// the user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Validated for format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
const createMedication = () =>
prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — optional reorder lead override; null/omitted = inherit
// the user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Validated for format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.28 — mirrored-medication provenance (Apple Health). Zod
// enforces the both-or-neither pairing; field-by-field like every
// other column.
...(externalSource !== undefined && { externalSource }),
...(externalId !== undefined && { externalId }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write `timesOfDay` from windowStart when
// the legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
// Invariant 3 — dual-write `timesOfDay` from windowStart when
// the legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. Stored as the
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
// column NULL (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
};
}),
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. Stored as the
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
// column NULL (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
};
}),
},
},
},
include: { schedules: true },
});
include: { schedules: true },
});
let medication: Awaited<ReturnType<typeof createMedication>>;
try {
medication = await createMedication();
} catch (err: unknown) {
// v1.28 — P2002 on the mirror triple: a concurrent create of the
// same `(userId, externalSource, externalId)` won the race between
// the pre-query above and this insert. Resolve to the winning row
// and answer idempotently instead of 500-ing. Any other unique
// violation (there is none on this table today) rethrows.
const isP2002 =
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002";
if (isP2002 && externalSource !== undefined && externalId !== undefined) {
const winner = await prisma.medication.findFirst({
where: { userId: user.id, externalSource, externalId },
include: { schedules: true },
});
if (winner) return respondWithExistingMirror(winner);
}
throw err;
}
const normalizedCategory = await setMedicationCategory(
medication.id,
+18
View File
@@ -55,6 +55,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
// v1.4.40 W-RSC — module-scope so the option object is stable across
@@ -221,6 +222,10 @@ export default function DashboardPageClient() {
return {
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
// v1.28.x — latest-night source-discrepancy annotation for the
// sleep tile's discreet "sources disagree" marker. Optional on
// the snapshot (additive contract), so `?? null` here.
sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
@@ -250,6 +255,7 @@ export default function DashboardPageClient() {
return {
summaries: merged.summaries,
lastSeenByType: merged.lastSeenByType,
sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy,
bpInTargetPct: merged.bpInTargetPct,
bpInTargetPct7d: merged.bpInTargetPct7d,
bpInTargetPct30d: merged.bpInTargetPct30d,
@@ -978,6 +984,18 @@ export default function DashboardPageClient() {
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummaryHours)}
staleDays={tileStaleDays("SLEEP_DURATION")}
// v1.28.x — discreet "sources disagree" marker next to the
// headline when two writers reported clearly different
// totals for the latest night (server-computed,
// observational; same marker as the hypnogram). Null on
// the ordinary path → no layout change.
valueAdornment={
data?.sleepSourceDiscrepancy ? (
<SleepSourceDiscrepancyMarker
discrepancy={data.sleepSourceDiscrepancy}
/>
) : null
}
/>
),
});
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker";
import { I18nProvider } from "@/lib/i18n/context";
/**
@@ -210,6 +211,52 @@ describe("<TrendCard> value is never truncated", () => {
});
});
describe("<TrendCard> valueAdornment slot", () => {
// v1.28.x — the dashboard sleep tile passes the discreet source-
// discrepancy marker into the value row when the server flagged two
// writers with clearly different totals for the latest night.
const baseProps = {
label: "Sleep",
latest: 10.25,
unit: "h",
avg7: 7.5,
avg30: 7.4,
slope30: null,
icon: Activity,
};
it("renders the sleep source-discrepancy marker next to the value when present", () => {
const html = render(
<TrendCard
{...baseProps}
valueAdornment={
<SleepSourceDiscrepancyMarker
discrepancy={{
deltaMinutes: 160,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{
source: "APPLE_HEALTH",
deviceType: null,
asleepMinutes: 455,
},
],
}}
/>
}
/>,
);
expect(html).toContain('data-slot="trend-card-value-adornment"');
expect(html).toContain('data-slot="sleep-source-discrepancy"');
});
it("renders no adornment slot when the annotation is absent", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).not.toContain('data-slot="trend-card-value-adornment"');
expect(html).not.toContain('data-slot="sleep-source-discrepancy"');
});
});
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
// v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime`
+21
View File
@@ -99,6 +99,14 @@ interface TrendCardProps {
* height.
*/
emptyHint?: string | null;
/**
* v1.28.x — optional adornment rendered directly after the unit in the
* value row (before the trend-arrow slot). The dashboard sleep tile
* passes the discreet source-discrepancy marker here so the hint sits
* next to the number it annotates. Absent → the row renders exactly as
* before (no reserved space, no layout shift).
*/
valueAdornment?: React.ReactNode;
}
export function TrendCard({
@@ -120,6 +128,7 @@ export function TrendCard({
compareDelta = null,
staleDays = null,
emptyHint = null,
valueAdornment = null,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -274,6 +283,18 @@ export function TrendCard({
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
{/* v1.28.x — optional inline adornment (e.g. the sleep tile's
source-discrepancy marker). `self-center` keeps the icon
vertically centred in the row without disturbing the
baseline alignment of the value + unit pair. */}
{valueAdornment != null ? (
<span
className="inline-flex shrink-0 self-center"
data-slot="trend-card-value-adornment"
>
{valueAdornment}
</span>
) : null}
</>
)}
<span
+14 -117
View File
@@ -10,16 +10,13 @@ import {
YAxis,
} from "recharts";
import { TriangleAlert } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TileHeader } from "@/components/insights/tile-header";
import {
SleepSourceDiscrepancyMarker,
formatMinutes,
type SleepSourceDiscrepancyDto,
} from "@/components/insights/sleep-source-discrepancy-marker";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
@@ -85,14 +82,7 @@ export interface SleepHypnogramSession {
* headline total stays the winning writer's). Renders as a discreet
* marker with a per-source breakdown in a tooltip.
*/
sourceDiscrepancy?: {
deltaMinutes: number;
sources: {
source: string;
deviceType: string | null;
asleepMinutes: number;
}[];
} | null;
sourceDiscrepancy?: SleepSourceDiscrepancyDto | null;
}
export interface SleepHypnogramProps {
@@ -125,69 +115,9 @@ const BREAKDOWN_ORDER = [
"IN_BED",
] as const;
function formatMinutes(total: number, locale: string): string {
const hours = Math.floor(total / 60);
const mins = Math.round(total - hours * 60);
if (locale === "de") {
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
}
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
/**
* Localised label keys per `MeasurementSource` — the same map the
* source-priority settings surface uses (`sources-section.tsx`), so both
* surfaces humanise a source identically.
*/
const SOURCE_LABEL_KEYS: Record<string, string> = {
WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS",
APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH",
MANUAL: "settings.sections.sources.sourceLabels.MANUAL",
IMPORT: "settings.sections.sources.sourceLabels.IMPORT",
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
/**
* Brand names for sources without a localised settings label — brand
* spellings are locale-invariant, so no i18n key is needed for them.
*/
const SOURCE_BRAND_NAMES: Record<string, string> = {
OURA: "Oura",
POLAR: "Polar",
GOOGLE_HEALTH: "Google Health",
NIGHTSCOUT: "Nightscout",
STRAVA: "Strava",
TELEGRAM: "Telegram",
MCP: "MCP",
};
/**
* Localised device-type label keys (`Measurement.deviceType`), reused from
* the source-priority settings surface. Appended in parentheses so two
* writer apps behind the same source (watch vs phone under Apple Health)
* stay distinguishable in the discrepancy tooltip.
*/
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
band: "settings.sections.sources.deviceLabels.band",
ring: "settings.sections.sources.deviceLabels.ring",
phone: "settings.sections.sources.deviceLabels.phone",
scale: "settings.sections.sources.deviceLabels.scale",
other: "settings.sections.sources.deviceLabels.other",
};
function sourceDisplayName(
source: string,
deviceType: string | null,
t: (key: string) => string,
): string {
const labelKey = SOURCE_LABEL_KEYS[source];
const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source);
const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined;
return deviceKey ? `${base} (${t(deviceKey)})` : base;
}
// v1.28.x — `formatMinutes`, the source/device label maps, and the
// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx`
// so the dashboard sleep tile renders the identical hint.
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
@@ -335,44 +265,11 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
{/* Discreet source-discrepancy marker: two writers reported
clearly different totals for this night. Observational — the
headline stays the winning writer's number; the tooltip lists
each source's own total. Muted, no colour wash, no banner;
negative margins keep the 44 px hit target from growing the
caption line (same trick as measurement-diversity-nudge). */}
{session.sourceDiscrepancy ? (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="sleep-source-discrepancy"
aria-label={t(
"insights.sleep.sourceDiscrepancy.tooltipTitle",
)}
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="sleep-source-discrepancy-body"
align="start"
className="max-w-xs leading-relaxed"
>
<p className="font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</p>
<p>
{session.sourceDiscrepancy.sources
.map(
(b) =>
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
)
.join(" · ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
each source's own total. Shared with the dashboard sleep
tile via `sleep-source-discrepancy-marker.tsx`. */}
<SleepSourceDiscrepancyMarker
discrepancy={session.sourceDiscrepancy}
/>
</span>
{/* The night's measuring source rides the header as a muted
caption — it is already on the wire, and "which device
@@ -0,0 +1,148 @@
"use client";
import { TriangleAlert } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.28.x — the discreet "sources disagree" marker, extracted from
* `sleep-hypnogram.tsx` (which introduced it in v1.28.21) so the dashboard
* sleep tile and the hypnogram render the identical hint off the identical
* wire shape. Observational only: the headline number always stays the
* winning writer's total; the tooltip lists each writer's own total so a
* multi-tracker user can see WHY the night reads surprising.
*/
/** Wire shape of the server-computed annotation (rounded minutes). */
export interface SleepSourceDiscrepancyDto {
deltaMinutes: number;
sources: {
source: string;
deviceType: string | null;
asleepMinutes: number;
}[];
}
export function formatMinutes(total: number, locale: string): string {
const hours = Math.floor(total / 60);
const mins = Math.round(total - hours * 60);
if (locale === "de") {
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
}
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
/**
* Localised label keys per `MeasurementSource` — the same map the
* source-priority settings surface uses (`sources-section.tsx`), so both
* surfaces humanise a source identically.
*/
const SOURCE_LABEL_KEYS: Record<string, string> = {
WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS",
APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH",
MANUAL: "settings.sections.sources.sourceLabels.MANUAL",
IMPORT: "settings.sections.sources.sourceLabels.IMPORT",
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
/**
* Brand names for sources without a localised settings label — brand
* spellings are locale-invariant, so no i18n key is needed for them.
*/
const SOURCE_BRAND_NAMES: Record<string, string> = {
OURA: "Oura",
POLAR: "Polar",
GOOGLE_HEALTH: "Google Health",
NIGHTSCOUT: "Nightscout",
STRAVA: "Strava",
TELEGRAM: "Telegram",
MCP: "MCP",
};
/**
* Localised device-type label keys (`Measurement.deviceType`), reused from
* the source-priority settings surface. Appended in parentheses so two
* writer apps behind the same source (watch vs phone under Apple Health)
* stay distinguishable in the discrepancy tooltip.
*/
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
band: "settings.sections.sources.deviceLabels.band",
ring: "settings.sections.sources.deviceLabels.ring",
phone: "settings.sections.sources.deviceLabels.phone",
scale: "settings.sections.sources.deviceLabels.scale",
other: "settings.sections.sources.deviceLabels.other",
};
export function sourceDisplayName(
source: string,
deviceType: string | null,
t: (key: string) => string,
): string {
const labelKey = SOURCE_LABEL_KEYS[source];
const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source);
const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined;
return deviceKey ? `${base} (${t(deviceKey)})` : base;
}
export interface SleepSourceDiscrepancyMarkerProps {
discrepancy: SleepSourceDiscrepancyDto | null | undefined;
/** Tooltip alignment relative to the trigger (default `"start"`). */
align?: "start" | "center" | "end";
}
/**
* Discreet source-discrepancy marker: two writers reported clearly
* different totals for the night. Muted, no colour wash, no banner;
* negative margins keep the 44 px hit target from growing the host line
* (same trick as measurement-diversity-nudge). Renders nothing when the
* annotation is absent — no layout shift on the ordinary path.
*/
export function SleepSourceDiscrepancyMarker({
discrepancy,
align = "start",
}: SleepSourceDiscrepancyMarkerProps) {
const { t, locale } = useTranslations();
if (!discrepancy) return null;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="sleep-source-discrepancy"
aria-label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="sleep-source-discrepancy-body"
align={align}
className="max-w-xs leading-relaxed"
>
<p className="font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</p>
<p>
{discrepancy.sources
.map(
(b) =>
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
)
.join(" · ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -1188,3 +1188,67 @@ describe("source discrepancy annotation", () => {
});
});
});
describe("night-level source discrepancy (dashboard path)", () => {
// Same fixture as the session-level suite: granular Apple (455 asleep)
// next to a bare Withings aggregate claiming 615 for the same overnight
// session.
const discrepantOvernight: SleepStageRow[] = [
srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"),
srcRow("2026-06-04T05:30:00.000Z", "ASLEEP", 615, "WITHINGS"),
];
it("carries the main session's annotation on the night without changing the served total", () => {
const nights = reconstructSleepNights(discrepantOvernight, "UTC");
expect(nights).toHaveLength(1);
// The served total stays the winning writer's number — the annotation
// observes, the writer collapse decides.
expect(nights[0].asleepMinutes).toBe(455);
expect(nights[0].sourceDiscrepancy).toEqual({
deltaMinutes: 160,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{ source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 455 },
],
});
// The dashboard reads the night through `summarizeSleepNights`.
const { latestNight } = summarizeSleepNights(discrepantOvernight, "UTC");
expect(latestNight?.asleepMinutes).toBe(455);
expect(latestNight?.sourceDiscrepancy).toEqual(nights[0].sourceDiscrepancy);
});
it("ignores a nap-only disagreement — sessions never cross-compare", () => {
// Clean single-writer overnight (455 asleep), then a same-wake-day nap
// (> 3 h gap) where two writers clearly disagree (120 vs 200 → delta 80
// > 45 min and > 20 % of 200). The nap's session-level annotation must
// NOT leak onto the night: the night's marker reflects the MAIN
// (overnight) session only.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"),
srcRow("2026-06-04T15:00:00.000Z", "CORE", 120, "APPLE_HEALTH"),
srcRow("2026-06-04T15:00:00.000Z", "ASLEEP", 200, "WITHINGS"),
];
// Sanity: the nap session itself IS flagged at session level.
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(2);
expect(sessions[1].sourceDiscrepancy).not.toBeNull();
const { latestNight } = summarizeSleepNights(rows, "UTC");
expect(latestNight?.night).toBe("2026-06-04");
// Overnight (455) outweighs the nap (120) → main session is clean.
expect(latestNight?.sourceDiscrepancy).toBeNull();
});
it("stays null for a single-writer night", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
];
const { latestNight } = summarizeSleepNights(rows, "UTC");
expect(latestNight?.sourceDiscrepancy).toBeNull();
});
});
+16
View File
@@ -30,6 +30,7 @@
* have caught the empty-object short-circuit bug.
*/
import type { DataSummary } from "@/lib/analytics/trends";
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
export interface AnalyticsSlimLike {
summaries?: Record<string, DataSummary> | null;
@@ -37,6 +38,12 @@ export interface AnalyticsSlimLike {
string,
{ lastSeenAt: string; daysAgo: number } | null
> | null;
/**
* v1.28.x — latest-night source-discrepancy annotation, carried by
* both slices (the slim post-pass computes it; the thick body passes
* it through). Additive and null-safe.
*/
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
}
export interface AnalyticsThickLike extends AnalyticsSlimLike {
@@ -64,6 +71,7 @@ export interface MergedDashboardAnalytics {
bpInTargetCount90: number | null;
bpInTargetSpanDays90: number | null;
glucoseByContext: Record<string, unknown> | undefined;
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
}
function hasContent(
@@ -117,5 +125,13 @@ export function mergeSlimAndThickAnalytics(
bpInTargetCount90: thick?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null,
glucoseByContext: thick?.glucoseByContext ?? undefined,
// Rides the same discriminator as `summaries`: whichever slice
// supplied the summaries also supplies the sleep annotation, so the
// marker can never point at a night the rendered headline doesn't
// show. `?? null` tolerates older cached payloads without the field.
sleepSourceDiscrepancy:
(slimSummariesHaveContent
? slim!.sleepSourceDiscrepancy
: thick?.sleepSourceDiscrepancy) ?? null,
};
}
+37 -1
View File
@@ -276,6 +276,17 @@ export interface SleepNight {
awakeMinutes: number | null;
/** Per-stage minutes for the night (only stages the winning writer reported). */
stages: Partial<Record<SleepStage, number>>;
/**
* Non-null when two writer buckets reported clearly different asleep
* totals for the night's MAIN session — the same-wake-day session with
* the most asleep minutes, normally the overnight block (see
* `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's
* writers are not measured against the overnight's. Observational only —
* the served `asleepMinutes` stays the winning writer's total; the
* annotation lets the UI mark the number with a discreet
* "sources disagree" hint.
*/
sourceDiscrepancy: SleepSourceDiscrepancy | null;
}
/** Sentinel for rows that carry no source — collapses to one bucket. */
@@ -604,6 +615,15 @@ export function reconstructSleepNights(
// edits.
let latest = nightSessions[0].pool[0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
// MAIN session of the night = most asleep minutes (same per-session
// asleep total the headline sums; tie → later wake instant, matching
// `pickMainNightAndNaps`). Its FULL raw row set (every writer,
// pre-collapse) feeds the night's source-discrepancy annotation —
// per session, so a nap's writers never cross-compare against the
// overnight block's.
let mainSessionRows: SleepStageRow[] | null = null;
let mainSessionAsleep = 0;
let mainSessionEnd = Number.NEGATIVE_INFINITY;
for (const { pool: sessionRows, all } of nightSessions) {
// In-bed: union envelope across ALL writers in the session (sessions
// are > 3 h apart, so per-session envelopes never overlap and summing
@@ -620,10 +640,14 @@ export function reconstructSleepNights(
const sawGranular = sawGranularStage(sessionRows);
// Asleep total: sum per session so a granular overnight + a bare-only
// nap both contribute (the merged-night nap under-count fix).
asleep += asleepMinutesOf(sessionRows);
const sessionAsleep = asleepMinutesOf(sessionRows);
asleep += sessionAsleep;
let sessionEnd = Number.NEGATIVE_INFINITY;
for (const r of sessionRows) {
const minutes = Number.isFinite(r.value) ? r.value : 0;
if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt;
if (r.measuredAt.getTime() > sessionEnd)
sessionEnd = r.measuredAt.getTime();
const stage = r.sleepStage;
// Drop the redundant bare ASLEEP aggregate (and stage-less rows) from
// the per-stage breakdown when this session has the granular partition,
@@ -637,6 +661,15 @@ export function reconstructSleepNights(
sawAwake = true;
}
}
if (
sessionAsleep > 0 &&
(sessionAsleep > mainSessionAsleep ||
(sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd))
) {
mainSessionAsleep = sessionAsleep;
mainSessionEnd = sessionEnd;
mainSessionRows = all;
}
}
nights.push({
night,
@@ -645,6 +678,9 @@ export function reconstructSleepNights(
inBedMinutes: sawInBed ? inBed : null,
awakeMinutes: sawAwake ? awake : null,
stages,
sourceDiscrepancy: mainSessionRows
? sessionSourceDiscrepancy(mainSessionRows)
: null,
});
}
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
+28 -3
View File
@@ -82,6 +82,7 @@ import {
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
summarizeSleepNights,
type SleepSourceDiscrepancy,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -247,6 +248,17 @@ export interface SummariesSlice {
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
/**
* v1.28.x — source-discrepancy annotation for the LATEST night's main
* session (see `SleepNight.sourceDiscrepancy`): non-null when two writer
* buckets reported clearly different asleep totals for the night behind
* `summaries.SLEEP_DURATION.latest`. Observational only — the served
* summary stays the winning writer's totals; the dashboard sleep tile
* marks the number with a discreet "sources disagree" hint. Minutes are
* whole (rounded), matching the `/api/sleep/night` serialisation.
* Additive on the wire; every consumer must tolerate `null`.
*/
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
}
export async function computeSummariesSlice(
@@ -362,12 +374,25 @@ async function withSleepNightTotals(
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const { summary } = summarizeSleepNights(rows, tz, priorityJson);
const { summary, latestNight } = summarizeSleepNights(rows, tz, priorityJson);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
// slope30 / slope90 from the night DataPoints, so use them directly.
slice.summaries.SLEEP_DURATION = summary;
// Additive, observational: the latest night's main session saw two
// writer buckets with clearly different asleep totals. Round to whole
// minutes, matching the `/api/sleep/night` serialisation.
slice.sleepSourceDiscrepancy = latestNight?.sourceDiscrepancy
? {
deltaMinutes: Math.round(latestNight.sourceDiscrepancy.deltaMinutes),
sources: latestNight.sourceDiscrepancy.sources.map((b) => ({
source: b.source,
deviceType: b.deviceType,
asleepMinutes: Math.round(b.asleepMinutes),
})),
}
: null;
return slice;
}
@@ -693,7 +718,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
},
});
return { summaries, bmi: null, lastSeenByType };
return { summaries, bmi: null, lastSeenByType, sleepSourceDiscrepancy: null };
}
/**
@@ -938,7 +963,7 @@ async function computeFromLiveAggregate(
},
});
return { summaries, bmi: null, lastSeenByType };
return { summaries, bmi: null, lastSeenByType, sleepSourceDiscrepancy: null };
}
/**
+12
View File
@@ -36,6 +36,7 @@
*/
import type { PrismaClient, MeasurementType } from "@/generated/prisma/client";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
import { getUnitForType } from "@/lib/validations/measurement";
import {
summarize,
@@ -384,6 +385,13 @@ export interface DashboardSnapshot {
summary: DataSummary | null;
entries: DashboardSnapshotMoodEntry[];
};
/**
* v1.28.x — source-discrepancy annotation for the latest night behind
* `summaries.SLEEP_DURATION.latest` (see `SummariesSlice`). Null when
* the writers agree or the sleep module is off. Optional on the type
* (additive contract) so older cached snapshots / fixtures stay valid.
*/
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
@@ -1233,6 +1241,10 @@ export async function buildDashboardSnapshot(
summary: mood.entries.length > 0 ? mood.summary : null,
entries: mood.entries,
},
// v1.28.x — gated like the SLEEP_DURATION summary itself: a
// disabled sleep module carries no sleep annotation either.
sleepSourceDiscrepancy:
modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy,
},
extras: extrasResult?.extras ?? null,
medsToday,
+27 -10
View File
@@ -442,7 +442,9 @@ export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
? null
: metricKeyForType(type as MeasurementType);
if (!metricKey) {
out.push(...rows);
// Loop, not `push(...rows)` — a sample-dense type's window rows run to
// six figures and a spread call overflows the stack (v1.28.22 class).
for (const r of rows) out.push(r);
continue;
}
const { canonicalRows } = pickCanonicalSourceRows(
@@ -457,11 +459,28 @@ export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
// `pickCanonicalSourceRows` returns the spread copies; re-key back to the
// original rows by reference identity is unnecessary because the spread
// preserves every field the caller reads (value / measuredAt / type).
out.push(...(canonicalRows as unknown as T[]));
for (const r of canonicalRows as unknown as T[]) out.push(r);
}
return out;
}
/**
* Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the
* report window's per-type rows are raw measurements, and a sample-dense type
* (per-sample heart rate, CGM glucose) runs to six figures over a year — a
* spread call overflows the stack exactly on the heaviest accounts (the same
* failure class as the Google Health fullSync tracker, fixed v1.28.22).
*/
function minMaxOf(values: readonly number[]): { min: number; max: number } {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
/**
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
@@ -491,8 +510,8 @@ export function summariseCanonicalRecovery(
type: "RECOVERY_SCORE",
latest: Math.round(latestRow.value),
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.round(Math.min(...values)),
max: Math.round(Math.max(...values)),
min: Math.round(minMaxOf(values).min),
max: Math.round(minMaxOf(values).max),
count: values.length,
latestAt: latestRow.measuredAt.toISOString(),
};
@@ -1025,8 +1044,7 @@ export async function collectDoctorReportData(
const values = entries.map((e) => e.value);
stats[type] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
...minMaxOf(values),
count: values.length,
latest: values[values.length - 1],
};
@@ -1164,8 +1182,8 @@ export async function collectDoctorReportData(
moodScores.length > 0
? {
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
min: Math.min(...moodScores),
max: Math.max(...moodScores),
min: minMaxOf(moodScores).min,
max: minMaxOf(moodScores).max,
count: moodScores.length,
distribution: {
1: moodScores.filter((s) => s === 1).length,
@@ -1207,8 +1225,7 @@ export async function collectDoctorReportData(
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
...minMaxOf(values),
count: values.length,
latest: values[values.length - 1],
};
+8 -3
View File
@@ -401,9 +401,14 @@ function drawSparkline(
doc.setTextColor(30, 30, 30);
doc.text(label, x, y + labelHeight - 1.5);
const values = points.map((p) => p.value);
const min = Math.min(...values);
const max = Math.max(...values);
// Loop, not `Math.min(...values)` — chart points can be sample-grain on
// dense accounts and a spread call overflows the stack (v1.28.22 class).
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const p of points) {
if (p.value < min) min = p.value;
if (p.value > max) max = p.value;
}
const range = max - min || 1;
const chartTop = y + labelHeight;
+21 -2
View File
@@ -57,9 +57,10 @@ export interface ApplyCanonicalSlotWriteInput {
* Source to stamp on a freshly-created slot row. `REMINDER` is the
* Telegram-webhook path — its take / skip confirmations converge onto
* the worker-minted pending row like every other write, and a fresh
* create keeps the historical reminder provenance.
* create keeps the historical reminder provenance. `APPLE_HEALTH`
* (v1.28) is the bulk route's HealthKit dose-event import.
*/
createSource: "WEB" | "API" | "REMINDER";
createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH";
/**
* v1.8.5 — resolved + server-validated injection site to persist on a
* taken write. `null` = no site (the column stays / is set NULL). Only
@@ -84,6 +85,16 @@ export interface ApplyCanonicalSlotWriteInput {
* a recorded dose away.
*/
doseTaken?: string | null;
/**
* v1.28 — external dose-event identifier (HealthKit
* `HKMedicationDoseEvent` UUID) to persist on the row. `null` / omitted
* = no external id (the column stays untouched on an update, NULL on a
* create). Like `injectionSite` / `doseTaken`, a write that carries no
* id never clears a previously-recorded one. The bulk route dedups the
* batch against the live `(userId, externalId)` unique before the
* upsert, so a carried id is trusted fresh here.
*/
externalId?: string | null;
}
export interface ApplyCanonicalSlotWriteResult {
@@ -228,6 +239,7 @@ export async function applyCanonicalSlotWrite(
injectionSite = null,
attributionSource,
doseTaken = null,
externalId = null,
} = input;
const rows = await findSlotRows(client, userId, medicationId, canonicalSlot);
@@ -260,6 +272,8 @@ export async function applyCanonicalSlotWrite(
...(attributionSource !== undefined && { attributionSource }),
// v1.16.4 — dose override only when the write carries one.
...(doseTaken !== null && { doseTaken }),
// v1.28 — external dose-event id only when the write carries one.
...(externalId !== null && { externalId }),
},
select: SLOT_ROW_SELECT,
})) as SlotIntakeRow;
@@ -307,6 +321,7 @@ async function applyToExisting(
injectionSite = null,
attributionSource,
doseTaken = null,
externalId = null,
} = input;
const existingActioned = existing.takenAt !== null || existing.skipped;
@@ -357,6 +372,10 @@ async function applyToExisting(
// v1.16.4 — dose override only when this write carries one; never
// clear a previously-recorded override with a null re-post.
...(doseTaken !== null && { doseTaken }),
// v1.28 — external dose-event id only when this write carries one
// (an Apple dose converging onto a pre-minted pending slot row);
// never clears a previously-recorded id.
...(externalId !== null && { externalId }),
},
select: SLOT_ROW_SELECT,
})) as SlotIntakeRow;
@@ -850,6 +850,25 @@ export const dashboardSnapshotResponse = z
}),
),
}),
// v1.28.x — additive: source-discrepancy annotation for the latest
// night behind `summaries.SLEEP_DURATION.latest`. Same shape as the
// per-session `sourceDiscrepancy` on the sleep-night resource.
sleepSourceDiscrepancy: z
.object({
deltaMinutes: z.number().int().nonnegative(),
sources: z.array(
z.object({
source: z.string(),
deviceType: z.string().nullable(),
asleepMinutes: z.number().int().nonnegative(),
}),
),
})
.nullable()
.optional()
.describe(
"Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots.",
),
}),
extras: z
.object({
@@ -162,6 +162,33 @@ export const createMedicationSchema = z
atcCode: atcCodeField,
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
rxNormCode: rxNormCodeField,
/**
* v1.28 — mirrored-medication provenance. Only APPLE_HEALTH is
* accepted here (the iOS 26+ HealthKit Medications sync); the other
* IntakeSource values stay server-owned. Both-or-neither with
* `externalId` (refine below).
*/
externalSource: z
.literal("APPLE_HEALTH")
.optional()
.describe(
"Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays off it.",
),
/**
* v1.28 — the source's stable concept identifier (HealthKit
* `medicationConceptIdentifier`). Re-posting the same
* `(externalSource, externalId)` pair returns the existing
* medication (200) instead of creating a duplicate.
*/
externalId: z
.string()
.trim()
.min(1)
.max(128)
.optional()
.describe(
"Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate.",
),
...courseWindowFields,
/**
* v1.16.11 — optional at the type level so an `asNeeded: true`
@@ -170,6 +197,15 @@ export const createMedicationSchema = z
*/
schedules: z.array(scheduleSchema).optional(),
})
.refine(
// v1.28 — mirrored-medication provenance is a pair: a source without
// its concept id (or vice versa) can neither dedup nor mirror.
(b) => (b.externalSource === undefined) === (b.externalId === undefined),
{
message: "externalSource and externalId must be supplied together",
path: ["externalId"],
},
)
.refine((b) => b.oneShot !== true || !!b.startsOn, {
message: "startsOn is required when oneShot is true",
path: ["startsOn"],
@@ -203,7 +239,7 @@ export const createMedicationSchema = z
.meta({
id: "CreateMedicationRequest",
description:
'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.',
'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing medication with status 200.',
});
export const updateMedicationSchema = z
+12
View File
@@ -34,6 +34,7 @@
* shape loses it.
*/
import type { DataSummary } from "@/lib/analytics/trends";
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
import type { CorrelationResult } from "@/lib/insights/correlations";
/** Shape consumed by every `/insights/<metric>/page.tsx`. */
@@ -107,6 +108,17 @@ export interface DashboardAnalyticsData {
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
/**
* v1.28.x — source-discrepancy annotation for the latest night behind
* `summaries.SLEEP_DURATION.latest` (carried by both the slim
* `?slice=summaries` branch and the thick slice; the dashboard snapshot
* mirrors it under `tiles.sleepSourceDiscrepancy`). Non-null when two
* writer buckets reported clearly different asleep totals for that
* night's main session; the sleep tile marks the headline with a
* discreet "sources disagree" hint. Additive — absent/null keeps the
* tile rendering exactly as before.
*/
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
}
/**