Merge remote-tracking branch 'origin/main' into fix/v1397-tz

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:59:51 +02:00
commit ada490c7e9
57 files changed
+1485 -99

No files matched your search

+20
View File
@@ -371,6 +371,26 @@ export function resolveEffectiveBpTargets(
};
}
/**
* The user's own blood-pressure band, or `null` when they have not set one.
*
* `resolveEffectiveBpTargets` answers "which band applies" and falls back to
* the age default; this answers "did the person pick it", which is what a
* surface needs to label the band as their own rather than as the guideline.
* A one-sided override (systolic only) still counts: the other half is the
* age default, exactly as `resolveEffectiveBpTargets` fills it.
*/
export function resolveBpTargetOverride(
profile: UserProfileForRange,
thresholdsJson: unknown,
): BpTargets | null {
const hasOverride =
resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_SYS") != null ||
resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_DIA") != null;
if (!hasOverride) return null;
return resolveEffectiveBpTargets(profile, thresholdsJson);
}
/**
* Convenience: resolve every supported metric at once. Useful for /targets
* and the insight generator.
@@ -90,6 +90,9 @@ describe("rasterizePdf", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
// The page count travels with the result, so a caller can say the
// reading stopped short.
expect(result.pageCount).toBe(RASTER_MAX_PAGES + 4);
});
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
+11 -2
View File
@@ -74,7 +74,16 @@ export interface RasterImage {
* would not render" (`render-failed` — malformed / encrypted / zero pages).
*/
export type RasterResult =
| { ok: true; images: RasterImage[] }
| {
ok: true;
images: RasterImage[];
/**
* Pages in the PDF. Above `images.length` when the page cap cut the
* render short, which a caller showing the result has to say: the
* reading covers the first pages only.
*/
pageCount: number;
}
| { ok: false; reason: "unsupported" | "render-failed" };
// Minimal structural types for the slice of the pdfjs API this module uses. A
@@ -212,7 +221,7 @@ export async function rasterizePdf(
action: { name: "documents.rasterize.ok" },
meta: { pages: images.length, cappedFrom: doc.numPages },
});
return { ok: true, images };
return { ok: true, images, pageCount: doc.numPages };
} catch (err) {
annotate({
action: { name: "documents.rasterize.failed" },
@@ -34,6 +34,7 @@ import {
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepSession,
mapSleepSessionDetailed,
mapSteps,
mapVo2Max,
mapWeight,
@@ -376,6 +377,37 @@ describe("mapVo2Max — daily-vo2-max summary", () => {
});
});
describe("mapSleepSessionDetailed — unknown stage labels are counted", () => {
it("reports how many segments it skipped and which labels they carried", () => {
const session = mapSleepSessionDetailed({
sleep: {
stages: [
{
type: "DEEP",
startTime: "2026-06-02T02:00:00.000Z",
endTime: "2026-06-02T02:45:00.000Z",
},
{
type: "SLEEP_STAGE_TYPE_UNSPECIFIED",
startTime: "2026-06-02T04:00:00.000Z",
endTime: "2026-06-02T04:10:00.000Z",
},
{
type: "SLEEP_STAGE_TYPE_UNSPECIFIED",
startTime: "2026-06-02T04:10:00.000Z",
endTime: "2026-06-02T04:20:00.000Z",
},
],
},
});
expect(session.rows).toHaveLength(1);
expect(session.unknownStageSegments).toBe(2);
expect(session.unknownStageLabels).toEqual([
"SLEEP_STAGE_TYPE_UNSPECIFIED",
]);
});
});
describe("mapSleepSession — per-stage segments", () => {
it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => {
const rows = mapSleepSession({
@@ -54,6 +54,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock,
}));
import { annotate } from "@/lib/logging/context";
import { syncUserSleep } from "../sync-sleep";
beforeEach(() => {
@@ -72,6 +73,8 @@ beforeEach(() => {
],
windowStart: new Date("2026-06-01T22:00:00.000Z"),
windowEnd: sleepMeasuredAt,
unknownStageSegments: 0,
unknownStageLabels: [],
});
upsertMock.mockImplementation(
async (
@@ -125,6 +128,8 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => {
],
windowStart: new Date("2026-06-01T22:00:00.000Z"),
windowEnd: sleepMeasuredAt,
unknownStageSegments: 0,
unknownStageLabels: [],
});
await syncUserSleep("user-1");
@@ -135,3 +140,28 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => {
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []);
});
});
describe("google-health syncUserSleep — segments with an unknown stage", () => {
it("counts them on the wide event with their labels instead of dropping them silently", async () => {
fetchDataPointsMock.mockResolvedValue([{ raw: "a" }, { raw: "b" }]);
mapSleepSessionDetailedMock.mockReturnValue({
rows: [],
windowStart: null,
windowEnd: null,
unknownStageSegments: 2,
unknownStageLabels: ["SLEEP_STAGE_TYPE_UNSPECIFIED"],
});
await syncUserSleep("user-1");
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: expect.objectContaining({ name: "googleHealth.sleep.sync" }),
meta: {
unknown_stage_segments: 4,
unknown_stage_labels: "SLEEP_STAGE_TYPE_UNSPECIFIED",
},
}),
);
});
});
+28 -3
View File
@@ -955,6 +955,15 @@ export interface GoogleHealthSleepSession {
/** Latest segment end across the session (UTC), or null if none map. */
windowEnd: Date | null;
rows: GoogleHealthMappedMeasurement[];
/**
* Segments skipped because their stage label is not one this mapper knows
* (`SLEEP_STAGE_TYPE_UNSPECIFIED`, or a label Google adds later). Counted
* rather than dropped in silence: the night total reads short by exactly
* these segments, and the sync's wide event is where that shows.
*/
unknownStageSegments: number;
/** The distinct unknown labels, for the wide event (at most five). */
unknownStageLabels: string[];
}
/**
@@ -972,7 +981,8 @@ export interface GoogleHealthSleepSession {
* duplicate rows the night-total then double-counted. A segment's start instant
* is stable per block, and dropping the stage from the key means a mere
* re-classification (LIGHT→DEEP on the same block) UPDATES the row in place
* rather than orphaning it. Unknown stage labels are skipped; a session with no
* rather than orphaning it. Unknown stage labels are skipped and counted
* (`unknownStageSegments`); a session with no
* parseable segment yields an empty `rows` (and a null window).
*
* Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's
@@ -989,10 +999,18 @@ export function mapSleepSessionDetailed(
const rows: GoogleHealthMappedMeasurement[] = [];
let windowStart: Date | null = null;
let windowEnd: Date | null = null;
let unknownStageSegments = 0;
const unknownStageLabels = new Set<string>();
for (const seg of segments) {
const stage = mapGoogleHealthSleepStage(seg.stage);
if (!stage) continue;
if (!stage) {
unknownStageSegments += 1;
if (unknownStageLabels.size < 5) {
unknownStageLabels.add(seg.stage.slice(0, 40));
}
continue;
}
const mins = minutesBetween(seg.startTime, seg.endTime);
if (mins === null || !(mins > 0)) continue;
const start = parseLocalInstant(seg.startTime as string, tz);
@@ -1010,7 +1028,14 @@ export function mapSleepSessionDetailed(
});
}
return { anchor, windowStart, windowEnd, rows };
return {
anchor,
windowStart,
windowEnd,
rows,
unknownStageSegments,
unknownStageLabels: [...unknownStageLabels],
};
}
/**
+16
View File
@@ -70,6 +70,12 @@ export async function syncUserSleep(
const readings: GoogleHealthMeasurementUpsert[] = [];
const replaceWindows: GoogleHealthSleepReplaceWindow[] = [];
// Segments whose stage label the mapper does not know are skipped, so the
// night total reads short by them. Counted across the page for the wide
// event, with the labels, so a new upstream stage shows up instead of
// quietly shortening nights.
let unknownStageSegments = 0;
const unknownStageLabels = new Set<string>();
for (const point of points) {
// Per-session throw-guard (mapper parity with sync-metrics): a single
// malformed session must not abort the sibling sessions in the same page.
@@ -85,6 +91,10 @@ export async function syncUserSleep(
noteHardFailure("mapSleepSession");
continue;
}
unknownStageSegments += session.unknownStageSegments;
for (const label of session.unknownStageLabels) {
if (unknownStageLabels.size < 5) unknownStageLabels.add(label);
}
if (session.rows.length === 0) continue;
for (const m of session.rows) {
readings.push({
@@ -130,6 +140,12 @@ export async function syncUserSleep(
annotate({
action: { name: "googleHealth.sleep.sync", details: { imported } },
meta: {
unknown_stage_segments: unknownStageSegments,
...(unknownStageSegments > 0
? { unknown_stage_labels: [...unknownStageLabels].join(",") }
: {}),
},
});
return imported;
}
@@ -14,6 +14,7 @@ function medication(
id,
name: `Medication ${id}`,
active: true,
intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -62,6 +63,26 @@ describe("pickDefaultMedicationId", () => {
expect(pickDefaultMedicationId(options, now)).toBe("r");
expect(pickDefaultMedicationId([...options].reverse(), now)).toBe("r");
});
it("never preselects an ended course, even when it is the only active one", () => {
// Still active (not archived) but the course ended: the server answers
// `intakeActionable: false`, the list keeps it for back-filling, and the
// default lands on the medication that offers a dose today.
const options = [
medication("ended", {
name: "Amoxicillin",
active: true,
intakeActionable: false,
}),
medication("current", { name: "Ramipril" }),
];
expect(pickDefaultMedicationId(options)).toBe("current");
expect(
pickDefaultMedicationId([
medication("ended", { active: true, intakeActionable: false }),
]),
).toBeNull();
});
});
describe("resolveMedicationSelectionId", () => {
+13 -3
View File
@@ -8,6 +8,13 @@ export interface DefaultMedicationOption {
id: string;
name: string;
active: boolean;
/**
* The server's answer to "does this medication offer a dose today"
* (`src/lib/medications/intake-actionable.ts`): active, intake tracked,
* and the course includes today. The preselection reads it and never
* re-derives it from `active`, so an ended course is never the default.
*/
intakeActionable: boolean;
schedules: ScheduleWindowInput[];
lastTakenAt: string | null;
todayEventCount?: number | null;
@@ -27,8 +34,11 @@ const DEFAULT_THRESHOLDS: MedicationDueThresholds = {
/**
* Pick the medication that should be pre-selected when an intake form opens.
* Current, late, and overdue schedule windows win; otherwise the fallback is
* the single active medication or the first active name alphabetically.
* Only a medication that offers a dose today (`intakeActionable`) can be the
* default. Among those, current, late, and overdue schedule windows win;
* otherwise the fallback is the single candidate or the first name
* alphabetically. A list may still carry ended courses for back-filling a
* past dose; they stay pickable by hand but are never preselected.
*/
export function pickDefaultMedicationId(
options: DefaultMedicationOption[],
@@ -36,7 +46,7 @@ export function pickDefaultMedicationId(
thresholds: MedicationDueThresholds = DEFAULT_THRESHOLDS,
tz: string = DEFAULT_TIMEZONE,
): string | null {
const actives = options.filter((medication) => medication.active);
const actives = options.filter((medication) => medication.intakeActionable);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { droppedAnyLinkKeys } from "@/lib/mood/dropped-link-keys";
describe("droppedAnyLinkKeys — the web's read of a mood write response", () => {
it("is false for a response that lists nothing (the fields are absent)", () => {
expect(droppedAnyLinkKeys({})).toBe(false);
expect(droppedAnyLinkKeys(null)).toBe(false);
expect(droppedAnyLinkKeys({ droppedTagKeys: [] })).toBe(false);
});
it("is true when either list names a key", () => {
expect(droppedAnyLinkKeys({ droppedTagKeys: ["custom:gone"] })).toBe(true);
expect(droppedAnyLinkKeys({ droppedFactorKeys: ["factor_x"] })).toBe(true);
});
});
+66
View File
@@ -310,3 +310,69 @@ describe("entry-ownership guard", () => {
});
});
});
describe("the write helpers report the keys they did not store", () => {
it("createTagLinks returns the unresolved binary and factor keys", async () => {
// First lookup: rated factors (only `factor_work` is a live factor).
// Second lookup: binary keys (`happy` live; the other two are unknown or
// archived, which the `isActive` filter makes look the same).
moodTagFindMany
.mockResolvedValueOnce([
{ id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 },
])
.mockResolvedValueOnce([{ id: "mt_happy", key: "happy" }]);
const dropped = await createTagLinks(
"entry-1",
"user-1",
["happy", "custom:gone", "no_such_tag", "happy"],
undefined,
[
{ key: "factor_work", rating: 3 },
{ key: "factor_retired", rating: 2 },
],
);
expect(dropped).toEqual({
droppedTagKeys: ["custom:gone", "no_such_tag"],
droppedFactorKeys: ["factor_retired"],
});
});
it("createTagLinks reports nothing when every key landed", async () => {
moodTagFindMany.mockResolvedValue([{ id: "mt_happy", key: "happy" }]);
expect(await createTagLinks("entry-1", "user-1", ["happy"])).toEqual({
droppedTagKeys: [],
droppedFactorKeys: [],
});
});
it("replaceTagLinks does not report a key whose archived link it preserved", async () => {
// `custom:old` is archived but already on the entry: the edit keeps it,
// so it is not dropped. `custom:never` resolves to nothing anywhere.
moodTagFindMany.mockResolvedValue([]);
linkFindMany.mockResolvedValue([
{
moodTagId: "mt_old",
moodTag: { isActive: false, kind: "BINARY", key: "custom:old" },
},
]);
expect(
await replaceTagLinks("entry-1", "user-1", [
"custom:old",
"custom:never",
]),
).toEqual(["custom:never"]);
});
it("replaceRatedFactorLinks does not report an archived factor that stays on the entry", async () => {
moodTagFindMany.mockResolvedValue([]);
linkFindMany.mockResolvedValue([{ moodTag: { key: "factor_archived" } }]);
expect(
await replaceRatedFactorLinks("entry-1", "user-1", [
{ key: "factor_archived", rating: 2 },
{ key: "factor_unknown", rating: 2 },
]),
).toEqual(["factor_unknown"]);
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Client-side reader for the `droppedTagKeys` / `droppedFactorKeys` fields a
* mood write answers with (see `src/lib/mood/tag-links.ts`). Each list is
* present only when the server did not store some of the submitted keys —
* a tag archived in another tab, a custom tag deleted on another device —
* so its presence alone is the signal; the client re-derives nothing.
*/
export interface DroppedLinkKeysWire {
droppedTagKeys?: string[];
droppedFactorKeys?: string[];
}
export function droppedAnyLinkKeys(
response: DroppedLinkKeysWire | null | undefined,
): boolean {
return (
(response?.droppedTagKeys?.length ?? 0) > 0 ||
(response?.droppedFactorKeys?.length ?? 0) > 0
);
}
+128 -25
View File
@@ -6,8 +6,11 @@ import { prisma } from "@/lib/db";
*
* Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and
* write the `mood_entry_tag_links` join for a mood entry. The catalog is
* the source of truth: unknown keys are dropped silently so a stale
* client can never mint a link to a tag the deployment doesn't carry.
* the source of truth: an unknown or archived key never mints a link, so a
* stale client cannot link a tag the deployment doesn't carry. The write
* helpers return the keys they did not store, and the routes report them
* per entry (`droppedTagKeys` / `droppedFactorKeys`), so a client is never
* told an entry landed whole when part of it did not.
*
* Every helper accepts an optional `db` client so the caller can thread
* the same `$transaction` client that wrote the entry — the entry row and
@@ -76,7 +79,8 @@ interface ResolvedRatedFactor {
* resolved `MoodTag`'s own `scaleMin..scaleMax`. The route maps this to a
* 422 (the per-tag scale is the real gate; the Zod schema only enforces
* the outer 1..5 envelope). Unknown / non-RATED keys are NOT an error —
* they are dropped silently, matching the binary `tagKeys` posture.
* the entry still lands without them, matching the binary `tagKeys`
* posture, and the write helpers report them back as dropped.
*/
export class RatedFactorOutOfRangeError extends Error {
constructor(
@@ -92,10 +96,20 @@ export class RatedFactorOutOfRangeError extends Error {
}
}
/**
* The submitted keys a write did not store: unknown to the catalog, archived,
* another account's custom tag, or the wrong kind for the field they came in.
* Empty arrays when everything landed.
*/
export interface DroppedLinkKeys {
droppedTagKeys: string[];
droppedFactorKeys: string[];
}
/**
* Resolve rated-factor inputs against the catalog. Unknown keys, inactive
* tags, and `kind = 'BINARY'` keys are dropped silently (the catalog is
* the source of truth, same posture as `resolveTagKeysToIds`). A rating
* tags, and `kind = 'BINARY'` keys are not resolved (the catalog is the
* source of truth, same posture as `resolveTagKeysToIds`). A rating
* outside the resolved tag's `scaleMin..scaleMax` throws
* `RatedFactorOutOfRangeError` so the route returns 422. The last value
* wins on a duplicate key in the same payload.
@@ -104,7 +118,15 @@ export async function resolveRatedFactors(
factors: RatedFactorInput[],
db: TagLinkDb = prisma,
): Promise<ResolvedRatedFactor[]> {
if (factors.length === 0) return [];
return (await resolveRatedFactorsReporting(factors, db)).resolved;
}
/** `resolveRatedFactors`, plus the submitted keys that did not resolve. */
async function resolveRatedFactorsReporting(
factors: RatedFactorInput[],
db: TagLinkDb,
): Promise<{ resolved: ResolvedRatedFactor[]; dropped: string[] }> {
if (factors.length === 0) return { resolved: [], dropped: [] };
// Last-writer-wins on a duplicate key in the same submission.
const byKey = new Map<string, number>();
for (const f of factors) byKey.set(f.key, f.rating);
@@ -135,7 +157,11 @@ export async function resolveRatedFactors(
}
resolved.push({ moodTagId: row.id, rating });
}
return resolved;
const found = new Set(rows.map((row) => row.key));
return {
resolved,
dropped: [...byKey.keys()].filter((key) => !found.has(key)),
};
}
/**
@@ -161,8 +187,18 @@ export async function resolveTagKeysToIds(
ownerUserId?: string,
kind?: "BINARY" | "RATED",
): Promise<string[]> {
return (await resolveTagKeysReporting(keys, db, ownerUserId, kind)).ids;
}
/** `resolveTagKeysToIds`, plus the submitted keys that did not resolve. */
async function resolveTagKeysReporting(
keys: string[],
db: TagLinkDb,
ownerUserId?: string,
kind?: "BINARY" | "RATED",
): Promise<{ ids: string[]; dropped: string[] }> {
const unique = Array.from(new Set(keys));
if (unique.length === 0) return [];
if (unique.length === 0) return { ids: [], dropped: [] };
const ownerClause = ownerUserId
? { OR: [{ userId: null }, { userId: ownerUserId }] }
: { userId: null };
@@ -173,9 +209,13 @@ export async function resolveTagKeysToIds(
...(kind ? { kind } : {}),
...ownerClause,
},
select: { id: true },
select: { id: true, key: true },
});
return rows.map((row) => row.id);
const found = new Set(rows.map((row) => row.key));
return {
ids: rows.map((row) => row.id),
dropped: unique.filter((key) => !found.has(key)),
};
}
/**
@@ -191,6 +231,8 @@ export async function resolveTagKeysToIds(
* route 422) when a rating is out of the factor's scale. Asserts the
* entry belongs to `userId` before any write — a defensive guard against
* linking into an entry the acting session does not own.
*
* Returns the submitted keys that were not stored, for the route to report.
*/
export async function createTagLinks(
moodEntryId: string,
@@ -198,12 +240,13 @@ export async function createTagLinks(
keys: string[],
db: TagLinkDb = prisma,
ratedFactors: RatedFactorInput[] = [],
): Promise<void> {
): Promise<DroppedLinkKeys> {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve rated factors FIRST so an out-of-range rating aborts before
// any write, and so the rated rows (which carry a value) take priority
// over a bare binary row for the same tag id under `skipDuplicates`.
const resolvedFactors = await resolveRatedFactors(ratedFactors, db);
const { resolved: resolvedFactors, dropped: droppedFactorKeys } =
await resolveRatedFactorsReporting(ratedFactors, db);
const ratedTagIds = new Set(resolvedFactors.map((f) => f.moodTagId));
if (resolvedFactors.length > 0) {
@@ -218,16 +261,19 @@ export async function createTagLinks(
});
}
const tagIds = (await resolveTagKeysToIds(keys, db, userId)).filter(
const binary = await resolveTagKeysReporting(keys, db, userId);
const tagIds = binary.ids.filter(
// A key already written as a rated link is not re-inserted as a
// bare binary row (that would lose the rating to `skipDuplicates`).
(id) => !ratedTagIds.has(id),
);
if (tagIds.length === 0) return;
await db.moodEntryTagLink.createMany({
data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
skipDuplicates: true,
});
if (tagIds.length > 0) {
await db.moodEntryTagLink.createMany({
data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
skipDuplicates: true,
});
}
return { droppedTagKeys: binary.dropped, droppedFactorKeys };
}
/**
@@ -248,24 +294,31 @@ export async function createTagLinks(
* (`isActive: false`) are therefore preserved untouched no matter what
* the body carries; a purge (`DELETE /api/mood/tags/custom/[key]?purge=true`)
* remains the only path that removes them.
*
* Returns the submitted keys that are not on the entry afterwards. A key
* whose archived link was preserved is on the entry, so it is not reported.
*/
export async function replaceTagLinks(
moodEntryId: string,
userId: string,
keys: string[],
db: TagLinkDb = prisma,
): Promise<void> {
): Promise<string[]> {
await assertEntryOwnership(moodEntryId, userId, db);
const desiredIds = new Set(
await resolveTagKeysToIds(keys, db, userId, "BINARY"),
);
const desired = await resolveTagKeysReporting(keys, db, userId, "BINARY");
const desiredIds = new Set(desired.ids);
const existing = await db.moodEntryTagLink.findMany({
where: { moodEntryId },
select: {
moodTagId: true,
moodTag: { select: { isActive: true, kind: true } },
moodTag: { select: { isActive: true, kind: true, key: true } },
},
});
const preservedKeys = new Set(
existing
.filter((row) => !row.moodTag.isActive && row.moodTag.kind === "BINARY")
.map((row) => row.moodTag.key),
);
const existingIds = new Set(existing.map((row) => row.moodTagId));
const toDelete = existing
@@ -289,6 +342,7 @@ export async function replaceTagLinks(
skipDuplicates: true,
});
}
return desired.dropped.filter((key) => !preservedKeys.has(key));
}
/**
@@ -300,17 +354,40 @@ export async function replaceTagLinks(
* Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a
* rating is out of scale. Asserts the entry belongs to `userId` before any
* write.
*
* Returns the submitted factor keys that are not on the entry afterwards; an
* archived factor whose link survives is on the entry and is not reported.
*/
export async function replaceRatedFactorLinks(
moodEntryId: string,
userId: string,
factors: RatedFactorInput[],
db: TagLinkDb = prisma,
): Promise<void> {
): Promise<string[]> {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve (and range-check) before mutating so an out-of-range rating
// aborts the replace cleanly.
const resolved = await resolveRatedFactors(factors, db);
const { resolved, dropped } = await resolveRatedFactorsReporting(factors, db);
// Looked up only when something did not resolve: which of those keys still
// sit on the entry as an archived factor the replacement leaves alone.
const preservedKeys =
dropped.length === 0
? new Set<string>()
: new Set(
(
await db.moodEntryTagLink.findMany({
where: {
moodEntryId,
moodTag: {
isActive: false,
kind: "RATED",
key: { in: dropped },
},
},
select: { moodTag: { select: { key: true } } },
})
).map((row) => row.moodTag.key),
);
// Drop every active RATED link, including a legacy row whose rating is
// null. Same archive contract as `replaceTagLinks`: a link whose tag has
@@ -333,4 +410,30 @@ export async function replaceRatedFactorLinks(
skipDuplicates: true,
});
}
return dropped.filter((key) => !preservedKeys.has(key));
}
/**
* The wire form of `DroppedLinkKeys`: each list only when it is non-empty.
* Absent means every submitted key of that kind landed, which keeps the
* common response byte-identical to what clients read before the fields
* existed.
*/
export function droppedLinkKeysForWire(
dropped: DroppedLinkKeys,
): Partial<DroppedLinkKeys> {
return {
...(dropped.droppedTagKeys.length > 0
? { droppedTagKeys: dropped.droppedTagKeys }
: {}),
...(dropped.droppedFactorKeys.length > 0
? { droppedFactorKeys: dropped.droppedFactorKeys }
: {}),
};
}
/** No key dropped: the value a write that carried no keys reports. */
export const NO_DROPPED_LINK_KEYS: DroppedLinkKeys = Object.freeze({
droppedTagKeys: [],
droppedFactorKeys: [],
}) as DroppedLinkKeys;
+23
View File
@@ -401,6 +401,21 @@ const bulkMoodPayload = z
"iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.",
});
// The submitted keys a write did not store. Shared by the bulk result and the
// single-entry write responses so the two surfaces name the same thing.
const droppedTagKeysField = z
.array(z.string())
.optional()
.describe(
"`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written.",
);
const droppedFactorKeysField = z
.array(z.string())
.optional()
.describe(
"`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed.",
);
const bulkMoodEntryResult = z
.object({
index: z.number().int().nonnegative(),
@@ -418,6 +433,8 @@ const bulkMoodEntryResult = z
.string()
.optional()
.describe("Echoed back when the entry carried one, for client mapping."),
droppedTagKeys: droppedTagKeysField,
droppedFactorKeys: droppedFactorKeysField,
})
.meta({ id: "BulkMoodEntryResult" });
@@ -526,6 +543,12 @@ const moodEntryDto = z
.describe(
"The day's context, or null when the entry carries none. Null and an object of nulls are different answers.",
),
droppedTagKeys: droppedTagKeysField.describe(
"Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed.",
),
droppedFactorKeys: droppedFactorKeysField.describe(
"Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty.",
),
})
.meta({
id: "MoodEntryDTO",
+9
View File
@@ -85,6 +85,15 @@ const extractResponse = z
reportDate: z.string().nullable(),
providerType: z.string(),
rows: z.array(extractedRow),
pageCoverage: z
.object({
read: z.number().int().positive(),
total: z.number().int().positive(),
})
.optional()
.describe(
"Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` pages. Absent when the whole document was read.",
),
})
.meta({
id: "OcrExtractResponse",
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
interface TargetRange {
min: number;
max: number;
}
/** The slice of `GET /api/insights/targets` the blood-pressure band needs. */
interface BpTargetsSlice {
targets: ReadonlyArray<{ type: string; range: TargetRange | null }>;
bpDiastolic: { range: TargetRange | null };
}
export interface BpTargetBand {
systolic: TargetRange;
diastolic: TargetRange;
}
/**
* The blood-pressure target band for the record on screen, as the server
* resolved it.
*
* The chart used to rebuild the band in the browser from the signed-in
* account's date of birth. That lost a band the person had set themselves,
* and inside a shared or managed record it used the viewer's age rather than
* the record's. `/api/insights/targets` already resolves the band for the
* record being viewed, with the user's own target winning over the age
* default, and the reference panel under the chart reads the same payload —
* so the zones and the panel name one band.
*
* Shares the `insightsTargets()` cache entry with the panel; the payload
* shape is the route's, so both readers hold the same object.
*/
export function useBpTargetBand(): BpTargetBand | null {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () => apiGet<BpTargetsSlice>("/api/insights/targets"),
enabled: isAuthenticated,
});
return selectBpTargetBand(data ?? null);
}
/** Pure projection, exported for the unit test. */
export function selectBpTargetBand(
data: BpTargetsSlice | null,
): BpTargetBand | null {
if (!data) return null;
const systolic =
data.targets.find((entry) => entry.type === "BLOOD_PRESSURE")?.range ??
null;
const diastolic = data.bpDiastolic?.range ?? null;
if (!systolic || !diastolic) return null;
return { systolic, diastolic };
}
@@ -0,0 +1,40 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import type { MedicationDueThresholds } from "@/lib/medications/default-medication";
/**
* The account's own late / missed thresholds for the intake pickers.
*
* The cards, the table and the medications page each read
* `/api/settings/reminder-thresholds` under this key with the same fetcher
* shape (failure resolves to `null`), so the pickers share their cache entry.
* `undefined` until it lands or when it failed — the default picker then
* falls back to the 120 / 240-minute defaults every reader shares.
*/
export function useMedicationDueThresholds():
MedicationDueThresholds | undefined {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
return await apiGet<{
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
}>("/api/settings/reminder-thresholds");
} catch {
return null;
}
},
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
if (!data) return undefined;
return { lateMinutes: data.lateMinutes, missedMinutes: data.missedMinutes };
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { resolveBpTargetOverride } from "@/lib/analytics/effective-range";
import { selectBpTargetBand } from "@/lib/queries/use-bp-target-band";
import { buildVitalTargets } from "../vitals-builder";
const NOW = new Date("2026-07-21T12:00:00.000Z");
// `restingPulseProxy` is listed so the literal stays valid whichever shape of
// the input the builder carries; the assertion keeps the extra key legal.
const base = {
recentMeasurements: [],
restingPulseProxy: [],
latestByType: {},
average30ByType: {},
heightCm: 178,
age: 40,
gender: null,
timezone: "UTC",
now: NOW,
weightTargetOverride: null,
} as unknown as Parameters<typeof buildVitalTargets>[0];
const PROFILE_40 = {
heightCm: 178,
dateOfBirth: new Date("1986-03-01T12:00:00.000Z"),
gender: null,
};
describe("blood-pressure target: the user's own band", () => {
it("is null when no blood-pressure threshold is stored", () => {
expect(resolveBpTargetOverride(PROFILE_40, null)).toBeNull();
expect(
resolveBpTargetOverride(PROFILE_40, { WEIGHT: { min: 70, max: 75 } }),
).toBeNull();
});
it("carries the stored band, filling the other half from the age default", () => {
expect(
resolveBpTargetOverride(PROFILE_40, {
BLOOD_PRESSURE_SYS: { min: 110, max: 135 },
}),
).toEqual({ sysLow: 110, sysHigh: 135, diaLow: 70, diaHigh: 79 });
});
it("replaces the age band on the card and labels it as the user's", () => {
const card = buildVitalTargets({
...base,
bpTargetOverride: { sysLow: 110, sysHigh: 135, diaLow: 65, diaHigh: 85 },
});
const bp = card.targets.find((t) => t.type === "BLOOD_PRESSURE");
expect(bp?.range).toEqual({ min: 110, max: 135 });
expect(bp?.source).toBe("Custom");
expect(card.bpRange).toEqual({
sysLow: 110,
sysHigh: 135,
diaLow: 65,
diaHigh: 85,
});
});
it("keeps the ESH age band when none is set", () => {
const bp = buildVitalTargets(base).targets.find(
(t) => t.type === "BLOOD_PRESSURE",
);
expect(bp?.range).toEqual({ min: 120, max: 129 });
expect(bp?.source).toBe("ESH 2023");
});
});
describe("selectBpTargetBand — the chart reads the server's band", () => {
it("projects the systolic card range and the diastolic range", () => {
expect(
selectBpTargetBand({
targets: [
{ type: "WEIGHT", range: { min: 70, max: 75 } },
{ type: "BLOOD_PRESSURE", range: { min: 110, max: 135 } },
],
bpDiastolic: { range: { min: 65, max: 85 } },
}),
).toEqual({
systolic: { min: 110, max: 135 },
diastolic: { min: 65, max: 85 },
});
});
it("draws no band when the server resolved none", () => {
expect(selectBpTargetBand(null)).toBeNull();
expect(
selectBpTargetBand({
targets: [{ type: "BLOOD_PRESSURE", range: null }],
bpDiastolic: { range: null },
}),
).toBeNull();
});
});
+12
View File
@@ -17,6 +17,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import {
resolveBpTargetOverride,
resolveWeightTargetOverride,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
@@ -293,6 +294,17 @@ export async function buildTargetsResponse(user: AuthedUser) {
// set one, so the reference panel and the weight chart above it name the
// same band.
weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson),
// The blood-pressure card likewise names the user's own band when they
// set one. The insights page draws its chart zones from this same range,
// so the chart and the card below it cannot disagree.
bpTargetOverride: resolveBpTargetOverride(
{
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
dbUser?.thresholdsJson,
),
});
const sleepTarget = buildSleepTarget({
sleepStageRows,
+14 -2
View File
@@ -65,6 +65,15 @@ interface VitalTargetsInput {
* always treated a user override. `null` keeps the height-derived band.
*/
weightTargetOverride: { min: number; max: number } | null;
/**
* The user's own blood-pressure band from `User.thresholdsJson`
* (`resolveBpTargetOverride`). When present it replaces the age-based ESH
* band on the card, its in-target share and its seven-day strip, and the
* source reads "Custom" — the same rule as the weight override above. The
* ESH classification of the latest reading stays guideline-derived: it
* names a category, not a goal. Absent or `null` keeps the age band.
*/
bpTargetOverride?: BloodPressureTargetRange | null;
}
export interface VitalTargetsResult {
@@ -160,6 +169,7 @@ export function buildVitalTargets({
timezone,
now,
weightTargetOverride,
bpTargetOverride = null,
}: VitalTargetsInput): VitalTargetsResult {
const targets: TargetItem[] = [];
const consistencyClock = { timezone, now };
@@ -203,7 +213,9 @@ export function buildVitalTargets({
}),
});
const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null;
const bpRange =
bpTargetOverride ??
(age != null ? getBpTargetsByAge(age, referenceSex) : null);
const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone);
let bpClassification: TargetItem["classification"] = null;
if (
@@ -228,7 +240,7 @@ export function buildVitalTargets({
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
source: bpTargetOverride ? "Custom" : "ESH 2023",
...rollupFromDayMap({
dayBandByKey: bpPairsByDay,
...consistencyClock,
+6
View File
@@ -132,6 +132,12 @@ export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
/**
* Present only when a PDF was longer than the pages the scan reads
* (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total`
* pages, and the review screen says so.
*/
pageCoverage?: { read: number; total: number };
}
/**