mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server
This commit is contained in:
1 parent
b91328d67e
commit
79f7eb199b
35 files changed
+2454
-50
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user