mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
29 files changed
+1585
-276
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user