Merge branch 'v143/ios' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:28:40 +02:00
commit 3fc37c9183
78 files changed
+5928 -489

No files matched your search

+2 -2
View File
@@ -87,8 +87,8 @@ const SERVER_FEATURES = {
trackIntake: true,
// v1.43 — each turns true in the change that makes its route or request
// form work; until then a client must not send it (contract §0.6).
cumulativeHourBuckets: false,
syncTriggerBackfill: false,
cumulativeHourBuckets: true,
syncTriggerBackfill: true,
biomarkerMerge: true,
lifeCircumstances: false,
} as const;
+10 -2
View File
@@ -2,6 +2,9 @@
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
* With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200
* with what the delete changed — the removed cycle and the starts it gave
* back — so a client can say "the start on 17 Sep is back".
*
* The delete also hands the span back to the preceding cycle. Opening a cycle
* closes the one before it, so removing it without re-deriving that boundary
@@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
@@ -44,7 +48,7 @@ export const DELETE = apiHandler(
}
// Already a tombstone: the delete is done, and nothing is left to move.
if (existing.deletedAt !== null) {
return new Response(null, { status: 204 });
return cycleDeleteOutcome(request, null, []);
}
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
@@ -99,6 +103,10 @@ export const DELETE = apiHandler(
meta: { reanchored_prior: reanchored !== null },
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -3
View File
@@ -3,7 +3,8 @@
* (ios-contract §2.A).
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
* sync page. 204. Idempotent.
* sync page. 204. Idempotent. With `Prefer: return=representation` 200
* with the removed cycle and the starts given back (v1.43).
*
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
* another user 404s).
@@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
type RouteParams = { params: Promise<{ id: string }> };
@@ -176,11 +178,13 @@ export const PATCH = apiHandler(
row,
);
let removedCycleId: string | null = null;
let restored: Array<{ cycleId: string; startDate: string }> = [];
if (!openedCycleId && !stillBleeds(row)) {
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.date),
);
removedCycleId = removed?.cycleId ?? null;
restored = removed?.restored ?? [];
}
await auditLog("cycle.day-log.update", {
@@ -221,7 +225,9 @@ export const PATCH = apiHandler(
},
});
return apiSuccess(toCycleDayLogDTO(row));
// v1.43 (ios #116 item 9) — the starts this edit gave back by clearing
// the flow on a start day; empty when it removed none.
return apiSuccess({ ...toCycleDayLogDTO(row), restored });
},
);
@@ -303,7 +309,11 @@ export const DELETE = apiHandler(
},
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -2
View File
@@ -7,7 +7,8 @@
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
* stamps the current cycle's `periodEndDate` and writes the boundary
* day-log. Convenience over §2.A; returns
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where
* `absorbed` names a later start this one folded in (v1.43, null otherwise).
*/
import { NextRequest } from "next/server";
@@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise<Response> {
return apiSuccess({
cycle: toMenstrualCycleDTO(cycleRow),
dayLog: toCycleDayLogDTO(dayLogRow),
// v1.43 (ios #116 item 9) — a later start inside the same period that
// this start folded in, so the client can say which date stopped being a
// start. Null when nothing was folded; present on every `start`.
absorbed:
txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate
? {
cycleId: txResult.moved.absorbedCycleId,
startDate: txResult.moved.absorbedStartDate,
}
: null,
});
}
/** `end` with no preceding cycle is a no-op the client should not hit. */
function apiSuccessNoCycle(): Response {
return apiSuccess({ cycle: null, dayLog: null });
return apiSuccess({ cycle: null, dayLog: null, absorbed: null });
}
/**
@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
expect(bp?.allTimeCount).toBe(28);
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
expect(bp?.sparkline).toEqual([130, 132]);
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
// sparkline's mean sits above it, so the rise is a setback.
const judged = bp as unknown as {
direction: unknown;
trendSentiment: string;
};
expect(judged.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
expect(judged.trendSentiment).toBe("negative");
});
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
+89 -1
View File
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendSentiment,
type TrendVerdict,
type TrendVerdictContext,
} from "@/lib/targets/trend-verdict";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -185,6 +192,69 @@ interface MetricCard {
* at all.
*/
lastSeenAt: string | null;
/**
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
* the person, against their own target or a profile default
* (`src/lib/targets/trend-verdict.ts`).
*/
direction?: TrendVerdict | null;
/** v1.43 — `direction` applied to this card's `trend`. */
trendSentiment?: TrendSentiment;
}
/**
* The verdict key behind each card: the measurement type it reads, or the
* derived key for the two cards that are not one.
*/
const VERDICT_KEY: Record<MetricKind, string> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
mood: "MOOD",
bmi: "BMI",
};
/**
* Lay the verdict over each card. The reference placed against the band is
* the mean of the card's own seven-day sparkline, else its latest value, the
* seven-day average the web tiles place. Sleep cards are in hours and the
* verdict reads minutes.
*/
function withVerdicts(
cards: MetricCard[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): MetricCard[] {
const referenceOf = (card: MetricCard | undefined): number | null => {
if (!card) return null;
const spark = card.sparkline.filter((v) => Number.isFinite(v));
return spark.length > 0
? spark.reduce((sum, v) => sum + v, 0) / spark.length
: card.latestValue;
};
// BMI is judged by the weight it is derived from (the target is a weight).
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
return cards.map((card) => {
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
...ctx,
referenceValue:
reference !== null && card.kind === "sleep"
? reference * 60
: reference,
});
return {
...card,
direction,
trendSentiment: trendSentimentOfTrend(card.trend, direction),
};
});
}
/**
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
const t = getServerTranslator(ctx.locale).t;
// v1.43 — the record's profile and own targets, for the card verdicts.
const verdictProfile = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
annotate({ meta: timings });
return {
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
takenToday,
},
highlightInsight: null,
metrics,
metrics: withVerdicts(metrics, {
profile: {
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
gender: verdictProfile?.gender ?? null,
heightCm: verdictProfile?.heightCm ?? null,
},
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
}),
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO BMI",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--info)",
},
source: "ESH 2023",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ESH 2023",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
direction: {
direction: "up-good",
targetPosition: "below",
basis: "profile_default",
},
trendSentiment: "neutral",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "AASM/SRS",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "ACE",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Saint-Maurice JAMA 2020",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
range: { min: 90, max: 100 },
classification: null,
source: "7-day",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
details: {
medications: [
{
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ADA 2024 / DDG",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
daysInRange7d: 3,
daysLogged7d: 3,
daysInRange30d: 3,
@@ -533,10 +533,72 @@ describe("GET /api/integrations/healthkit — sync health", () => {
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt.toISOString(),
lastBackfillAt: null,
});
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
});
// v1.43 (ios #77) — a backfill's own ledger row reads as `lastBackfillAt`
// of its type and moves none of the live facts.
it("serves lastBackfillAt from the backfill row, apart from the live arrival", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
"PULSE",
"ACTIVITY_STEPS",
]);
mockProbes([
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
{ type: "ACTIVITY_STEPS", last_seen: ago(60 * 60 * 1000) },
]);
const receivedAt = ago(5 * 60 * 60 * 1000);
const backfillAt = ago(60 * 1000);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
{
type: "PULSE",
lastReceivedAt: receivedAt,
lastTrigger: "foreground",
lastNewSampleAt: receivedAt,
lastBackgroundAt: null,
},
{
type: "backfill:PULSE",
lastReceivedAt: backfillAt,
lastTrigger: null,
lastNewSampleAt: null,
lastBackgroundAt: null,
},
{
type: "backfill:ACTIVITY_STEPS",
lastReceivedAt: backfillAt,
lastTrigger: null,
lastNewSampleAt: null,
lastBackgroundAt: null,
},
] as never);
const body = await read();
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
);
expect(byType.PULSE).toMatchObject({
lastReceivedAt: receivedAt.toISOString(),
lastTrigger: "foreground",
lastBackfillAt: backfillAt.toISOString(),
});
// A type that only ever arrived through a backfill has no live arrival.
expect(byType.ACTIVITY_STEPS).toMatchObject({
lastReceivedAt: null,
lastTrigger: null,
lastBackfillAt: backfillAt.toISOString(),
});
expect(body.metricFreshness.map((e) => e.type)).not.toContain(
"backfill:PULSE",
);
});
it("leaves the dose entry out when there is no Apple Health dose", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
+200 -26
View File
@@ -73,10 +73,19 @@ import {
findFoldedWindowDuplicates,
} from "@/lib/measurements/folded-window";
import {
HEALTHKIT_SYNC_TRIGGERS,
BACKFILL_SYNC_TRIGGER,
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
recordHealthKitTypeArrivals,
type HealthKitTypeArrival,
} from "@/lib/integrations/healthkit-type-sync";
import {
classifyHourBucket,
HOUR_BUCKET_SKIP_REASONS,
parseHourBucketId,
writeHourBuckets,
type HourBucketWrite,
} from "@/lib/measurements/intraday-hour-buckets";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import {
classifyExternalId,
@@ -199,7 +208,9 @@ const batchEntrySchema = z.object({
// pre-#66 caller stays byte-for-byte unchanged.
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
// the app simply being open (`foreground`).
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
// v1.43 (ios #77) — `backfill` names a history catch-up. It moves only the
// per-type `lastBackfillAt`, never the live sync stamps (see below).
const syncTriggerEnum = z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS);
const batchPayloadSchema = z.object({
entries: z.array(batchEntrySchema).min(1),
@@ -380,6 +391,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
const prepared: Prepared[] = [];
const results: EntryResult[] = new Array(entries.length);
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
// (the account's, the one `/api/auth/me` publishes as `timezone`).
const hourWrites: HourBucketWrite[] = [];
let accountTz: string | undefined;
const batchNow = new Date();
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
@@ -402,6 +418,75 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.43 (ios #77) — an hourly sum of a summed type
// (`stats:<HK>:<YYYY-MM-DD>T<HH>`) is never a measurement: it lands in the
// intraday profile behind the same-time baseline, and the day's total
// stays the day row. Validated here, written after the measurements.
const hourId = parseHourBucketId(entry.externalId);
if (hourId) {
const skip = (reason: string) => {
results[index] = { index, status: "skipped", reason };
};
// The profile has no source dimension and is the phone's: a bridge or a
// hand-entered row cannot post hours into it.
if (scoped || (entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") {
skip(HOUR_BUCKET_SKIP_REASONS.unsupportedType);
continue;
}
accountTz ??= await resolveStoredTimezone(user.timezone);
const startDate = new Date(entry.startDate);
const verdict = classifyHourBucket(
hourId,
startDate,
accountTz,
batchNow,
);
if (!verdict.ok) {
skip(verdict.reason);
continue;
}
if (!isPlausibleEntryInstant(startDate)) {
skip("implausible_timestamp");
continue;
}
const hourMapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
unit: entry.unit,
startDate: entry.startDate,
endDate: entry.endDate,
});
if (!hourMapped || hourMapped.type !== verdict.type) {
// The entry's identifier must be the one its id names.
skip(
hourMapped
? HOUR_BUCKET_SKIP_REASONS.unsupportedType
: "unmappable_identifier",
);
continue;
}
if (
hourMapped.value < 0 ||
validateMeasurementRange(hourMapped.type, hourMapped.value) !== null
) {
results[index] = outOfRangeResult(
index,
hourMapped.type,
hourMapped.value,
);
continue;
}
hourWrites.push({
index,
type: verdict.type,
hkIdentifier: hourId.hkIdentifier,
dateKey: hourId.dateKey,
hour: hourId.hour,
value: hourMapped.value,
});
continue;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
@@ -898,15 +983,79 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// v1.43 — the hours, after the measurements. The last entry for one slot
// wins, as for the day rows; an earlier one in the same batch is a
// superseded duplicate.
let hourInsertedCount = 0;
let hourUpdatedCount = 0;
const hourLanded: HourBucketWrite[] = [];
if (hourWrites.length > 0 && accountTz) {
const lastBySlot = new Map<string, HourBucketWrite>();
for (const w of hourWrites) {
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
}
for (const w of hourWrites) {
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
results[w.index] = {
index: w.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount++;
}
}
const toWrite = [...lastBySlot.values()];
try {
const statuses = await writeHourBuckets(
prisma,
user.id,
accountTz,
toWrite,
);
for (const w of toWrite) {
const status = statuses.get(w.index) ?? "inserted";
results[w.index] = { index: w.index, status };
if (status === "inserted") hourInsertedCount++;
else hourUpdatedCount++;
hourLanded.push(w);
}
} catch (err) {
for (const w of toWrite) {
results[w.index] = {
index: w.index,
status: "failed",
reason: "write_failed",
};
failedCount++;
}
annotate({
action: { name: "measurement.batch.hour-bucket.failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
annotate({
action: { name: "measurement.batch.hour-bucket" },
meta: {
attempted: hourWrites.length,
inserted: hourInsertedCount,
updated: hourUpdatedCount,
processed: entries.length,
},
});
}
const healthKitSyncSucceeded =
failedCount === 0 &&
prepared.some((p) => {
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
const status = results[p.index]?.status;
return (
status === "inserted" || status === "updated" || status === "duplicate"
);
});
(hourLanded.length > 0 ||
prepared.some((p) => {
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
const status = results[p.index]?.status;
return (
status === "inserted" ||
status === "updated" ||
status === "duplicate"
);
}));
// This batch route is the native ingestion boundary, so it is also where the
// SyncMode checkpoint (`User.lastSyncedAt`, the same column
@@ -938,6 +1087,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
// uses to size its undelivered window, so letting a scale advance it can make
// the phone skip a window of its own samples — a silent data loss whose cause
// would be nowhere near the code that caused it.
//
// v1.43 (ios #77) — a `backfill` batch is a catch-up, not a sync. It records
// its types under `lastBackfillAt` only and moves none of the live stamps:
// thirty days of history must not read as thirty healthy daily syncs.
const backfill = syncTrigger === BACKFILL_SYNC_TRIGGER;
if (failedCount === 0 && !scoped) {
const syncedAt = new Date();
const deliveredWithoutTheApp =
@@ -962,6 +1116,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
if (status !== "duplicate") entry.newSamples += 1;
arrivals.set(type, entry);
}
// The hours the phone posted are an arrival of their type as well.
for (const w of hourLanded) {
const entry = arrivals.get(w.type) ?? { accepted: 0, newSamples: 0 };
entry.accepted += 1;
entry.newSamples += 1;
arrivals.set(w.type, entry);
}
if (arrivals.size > 0) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
@@ -970,21 +1131,23 @@ async function postBatch(request: NextRequest): Promise<Response> {
at: syncedAt,
});
}
await prisma.user.update({
where: { id: user.id },
data: {
lastSyncedAt: syncedAt,
...(healthKitSyncSucceeded
? {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
}
: {}),
},
});
if (!backfill) {
await prisma.user.update({
where: { id: user.id },
data: {
lastSyncedAt: syncedAt,
...(healthKitSyncSucceeded
? {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
}
: {}),
},
});
}
}
const freshlyInsertedRows = insertedPrepared.map((p) => ({
@@ -1256,10 +1419,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// v1.43 — the hours are not measurements, so they touched none of the
// measurement consumers above; only the caches that read the intraday
// profile (the same-time baseline) are marked stale.
if (
hourInsertedCount + hourUpdatedCount > 0 &&
insertedCount === 0 &&
updatedCount === 0
) {
invalidateUserMeasurements(user.id);
}
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
inserted: insertedCount + hourInsertedCount,
updated: updatedCount + hourUpdatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
);
});
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
function bpDays(values: Array<[string, number]>) {
vi.mocked(prisma.measurement.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { type: string } };
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
return Promise.resolve(
values.map(([day, value], i) => ({
id: `s${i}`,
value,
measuredAt: new Date(`${day}T08:00:00.000Z`),
})),
);
}) as never);
}
it("judges the window's change against the person's own target and draws its lines", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 128],
["2026-05-02", 130],
["2026-05-03", 134],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: {
direction: unknown;
change: unknown;
referenceLines: Array<Record<string, unknown>>;
};
};
expect(body.data.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(body.data.change).toEqual({
from: 128,
to: 134,
delta: 6,
sentiment: "negative",
});
expect(
body.data.referenceLines.map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
});
it("calls the same rise progress for someone below their target", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 100],
["2026-05-03", 106],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: { change: { sentiment: string } };
};
expect(body.data.change.sentiment).toBe("positive");
});
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
locale: "de",
thresholdsJson: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: {
change: unknown;
direction: { direction: string };
referenceLines: Array<{ value: number; label: string }>;
};
};
expect(body.data.change).toBeNull();
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
});
});
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+68 -1
View File
@@ -7,6 +7,12 @@
*
* The `kind=bloodPressure` case pairs systolic + diastolic by
* `measuredAt` so iOS can render the dual-line chart with one fetch.
*
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
* would otherwise decide itself: `direction` (which way a change of this
* metric counts for this person), `change` (first and last day value of the
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "@/lib/measurements/series-change";
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
const { user, actor } = await requireRecordAuth("read", "measurements");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
const values = dataPoints.map((p) => p.value);
// v1.43 — the resolved judgements. The profile and targets are the
// RECORD's (the chart is theirs); the labels are in the language of the
// account reading it.
const [record, reader, seriesTz] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
glucoseUnit: true,
locale: true,
},
}),
actor.id === user.id
? null
: prisma.user.findUnique({
where: { id: actor.id },
select: { locale: true },
}),
resolveUserTimezone(user.id),
]);
const rangeProfile = {
dateOfBirth: record?.dateOfBirth ?? null,
gender: record?.gender ?? null,
heightCm: record?.heightCm ?? null,
};
const verdictType = KIND_TO_TYPE[kind];
const dayValues = seriesDayValues(verdictType, points, seriesTz);
const reference = seriesReferenceValue(dayValues);
const direction = resolveTrendVerdict(verdictType, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
// The verdict reads stored units: sleep is served in hours here and
// stored in minutes. Glucose has no band of its own on this chart.
referenceValue:
reference !== null && kind === "sleep" ? reference * 60 : reference,
});
const translator = getServerTranslator(
await resolveJobLocale((reader ?? record)?.locale ?? null),
);
const referenceLines = resolveSeriesReferenceLines(kind, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
t: (key) => translator.t(key),
});
annotate({
action: { name: "measurements.series" },
meta: { kind, days, count: points.length },
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
kind,
unit,
points,
direction,
change: computeSeriesChange(dayValues, direction),
referenceLines,
stats:
statsOverride ??
(summary
@@ -80,7 +80,9 @@ const USER = {
};
/** A replayed Apple dose: the cheapest batch that still counts as an arrival. */
async function postReplayedDose(): Promise<Response> {
async function postReplayedDose(
syncTrigger: string = "background",
): Promise<Response> {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "row-existing", externalId: "hk-dose-1" },
] as never);
@@ -89,7 +91,7 @@ async function postReplayedDose(): Promise<Response> {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
syncTrigger: "background",
syncTrigger,
entries: [
{
medicationId: "med-apple",
@@ -134,4 +136,33 @@ describe("the dose arrival record and the credential that sent the batch", () =>
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled();
});
// v1.43 (ios #77) — a history catch-up records the dose arrival under
// `lastBackfillAt` only, and does not move the sync checkpoint.
it("passes a backfill through and leaves the checkpoint where it was", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "s" },
user: USER,
} as never);
const res = await postReplayedDose("backfill");
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ trigger: "backfill" }),
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves the checkpoint for a live sync", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "s" },
user: USER,
} as never);
await postReplayedDose("background");
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ lastSyncedAt: expect.any(Date) }),
}),
);
});
});
+9 -4
View File
@@ -58,7 +58,8 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
HEALTHKIT_SYNC_TRIGGERS,
BACKFILL_SYNC_TRIGGER,
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
} from "@/lib/integrations/healthkit-type-sync";
@@ -176,7 +177,9 @@ const bulkPayloadSchema = z.object({
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
// field `POST /api/measurements/batch` takes. It changes no row; it only
// feeds the `MEDICATION_DOSE` arrival record below.
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
// v1.43 (ios #77) — `backfill` for a history catch-up: it moves only the
// dose entry's `lastBackfillAt`, never the live stamps or the checkpoint.
syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(),
});
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
@@ -1038,8 +1041,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances
// it.
await markSyncCheckpoint(user.id);
// it. A `backfill` catch-up does not: it is history, not a sync.
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
await markSyncCheckpoint(user.id);
}
return apiSuccess({
processed: entries.length,
@@ -41,6 +41,22 @@ const AGGREGATE = {
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
patternsByWindow: {
30: [],
90: [],
180: [],
365: [
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
qValue: 0.001,
},
],
},
};
const call = (query = "") =>
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
expect(body.data.stabilityWindowDays).toBe(90);
});
it("serves the patterns of the same window, without the cache's internals", async () => {
const year = await (await call()).json();
expect(year.data.patterns).toEqual([
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
},
]);
expect(year.data).not.toHaveProperty("patternsByWindow");
const month = await (await call("?days=30")).json();
expect(month.data.patterns).toEqual([]);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
+10 -2
View File
@@ -12,6 +12,8 @@ import {
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
export const dynamic = "force-dynamic";
@@ -33,7 +35,8 @@ export const dynamic = "force-dynamic";
*/
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* the steadiness of the period it shows. Since v1.43 it cuts `patterns` the
* same way. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
@@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => {
},
});
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
// v1.43 — `patterns` follows the same `?days` window, and a catalogue
// tag in a pattern is named in the reader's language.
const translator = getServerTranslator(await resolveJobLocale(user.locale));
return apiSuccess(
moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)),
);
});
@@ -6,6 +6,8 @@ vi.mock("@/lib/db", () => ({
personalRecord: {
findMany: vi.fn(),
},
user: { findUnique: vi.fn() },
measurement: { findFirst: vi.fn() },
},
}));
@@ -186,4 +188,77 @@ describe("GET /api/personal-records", () => {
expect(body.data[0].value).toBe(47.2);
expect(body.data[0].metricType).toBe("VO2_MAX");
});
// v1.43 (ios #116 item 5) — every stored field stays, and the band for the
// age the record was set at rides beside it.
it("projects every stored field and adds the benchmark", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const createdAt = new Date("2026-05-02T00:00:00.000Z");
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
{
id: "pr-1",
userId: "user-1",
metricType: "VO2_MAX",
metricSlot: null,
direction: "MAX",
value: 47.2,
unit: "mL/(kg·min)",
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
sourceMeasurementId: "m-1",
source: "APPLE_HEALTH",
externalId: "x-1",
createdAt,
},
{
id: "pr-2",
userId: "user-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
direction: "MAX",
value: 21000,
unit: "steps",
achievedAt: new Date("2026-04-01T07:00:00.000Z"),
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: null,
createdAt,
},
] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1981-03-10T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
} as never);
const res = await GET(req("http://localhost/api/personal-records"));
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
expect(Object.keys(body.data[0]).sort()).toEqual(
[
"achievedAt",
"benchmark",
"createdAt",
"direction",
"externalId",
"id",
"metricSlot",
"metricType",
"source",
"sourceMeasurementId",
"unit",
"userId",
"value",
].sort(),
);
// 45 when the record was set: between the 40s and 50s male FRIEND bands.
expect(body.data[0].benchmark).toMatchObject({
unit: "mL/(kg·min)",
source: "friend_2015",
});
const band = body.data[0].benchmark as { low: number; high: number };
expect(band.low).toBeGreaterThan(31);
expect(band.high).toBeLessThan(49);
// A step record has no cited norm.
expect(body.data[1].benchmark).toBeNull();
// No six-minute walk on the page, so no weight read.
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
});
+50 -1
View File
@@ -21,6 +21,11 @@
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*
* v1.43 (ios #116 item 5) — the route projects each row instead of passing the
* Prisma result through. Every stored field stays on the wire, and each row
* gains `benchmark`: the cited age- and sex-aware band for the age the record
* was set at (`resolveRecordBenchmark`), or null.
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
@@ -28,6 +33,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
import {
mayCarryBenchmark,
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
export const dynamic = "force-dynamic";
@@ -61,5 +71,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
take: limit,
});
return apiSuccess(records);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
? await prisma.user.findUnique({
where: { id: user.id },
select: { dateOfBirth: true, gender: true, heightCm: true },
})
: null;
// Only the six-minute-walk equation needs a weight.
const weight =
profile && records.some((r) => r.metricType === "SIX_MINUTE_WALK_DISTANCE")
? await prisma.measurement.findFirst({
where: { userId: user.id, type: "WEIGHT", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true },
})
: null;
const benchmarkProfile = {
dateOfBirth: profile?.dateOfBirth ?? null,
sex: toProfileSex(profile?.gender) ?? undefined,
heightCm: profile?.heightCm ?? null,
weightKg: weight?.value ?? null,
};
return apiSuccess(
records.map((r) => ({
id: r.id,
userId: r.userId,
metricType: r.metricType,
metricSlot: r.metricSlot,
direction: r.direction,
value: r.value,
unit: r.unit,
achievedAt: r.achievedAt,
sourceMeasurementId: r.sourceMeasurementId,
source: r.source,
externalId: r.externalId,
createdAt: r.createdAt,
benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null,
})),
);
});