mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge branch 'v143/ios' into release/v1.43.0
This commit is contained in:
commit
3fc37c9183
78 files changed
+5928
-489
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user