mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(api/dashboard): drop 7-day filter on latestEver + widen sparkline (REG-11)
Der `/api/dashboard/summary`-Endpoint hat für Blutdruck und Puls
`latestValue: null` plus `sparkline: []` zurückgegeben, sobald der
letzte Wert des Nutzers älter als sieben Tage war. Der iOS-Kachel
hatte dann weder Zahl noch Chart, obwohl die Daten in der DB lagen
und der Detail-Screen sie auch korrekt anzeigte. Fünf iOS-seitige
Anläufe blieben wirkungslos, weil der Bug im Server-SQL saß.
- `latestIn7d` → `latestEver`: WHERE-Filter `m.measured_at >=
${sevenDaysAgo}` entfernt. `DISTINCT ON (type)` über alle nicht-
gelöschten Rows liefert weiterhin nur eine Zeile pro Typ, jetzt
aber den all-time-letzten Wert. Zeilenanzahl bleibt durch
`|measurementTypes|` beschränkt.
- `sparkBuckets`: Kalender-Filter `r.bucket_start >= ${sevenDaysAgo}`
durch ein `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
bucket_start DESC) <= SPARK_DAYS` ersetzt. Liefert die letzten
sieben Tages-Buckets pro Typ, unabhängig vom Alter. Eine BP-Messung
von vor 60 Tagen bekommt damit wieder ein Chart.
- BP- und Puls-Karten zusätzlich um das gleiche `if (latest ||
meta.allTimeCount > 0)`-Gate ergänzt, das BodyFat seit v1.4.33
schon hatte. Accounts, die diese Werte noch nie geloggt haben,
bekommen keine leeren Platzhalter-Kacheln mehr.
Tests in `src/app/api/dashboard/summary/__tests__/route.test.ts`
angepasst plus vier neue Cases für REG-11:
- BP mit 60 Tage altem Wert → Kachel emittiert mit historischem
`latestValue` + `sparkline`.
- BP mit Null-Readings ever → Kachel NICHT emittiert.
- Weight innerhalb 7d → Verhalten unverändert (Regression-Guard).
- Sparkline-Window nimmt 7 Daily-Buckets auch wenn alle älter als
7 Kalendertage.
This commit is contained in:
1 parent
0fbe68a313
commit
600aa3697c
2 files changed
+324
-75
No files matched your search
@@ -116,20 +116,29 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.greeting.salutation).toBe("Hi, marc");
|
||||
expect(body.data.streak.currentDays).toBe(0);
|
||||
expect(body.data.compliance.scheduledToday).toBe(0);
|
||||
// REG-11 (v1.4.44): weight is the only always-emitted base metric.
|
||||
// BP + pulse + bodyFat + optional kinds gate on `latest ||
|
||||
// allTimeCount > 0` so accounts that have never logged a kind don't
|
||||
// get an empty placeholder tile.
|
||||
expect(body.data.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "weight" }),
|
||||
expect.objectContaining({ id: "bp" }),
|
||||
expect.objectContaining({ id: "pulse" }),
|
||||
]),
|
||||
expect.arrayContaining([expect.objectContaining({ id: "weight" })]),
|
||||
);
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "bp"),
|
||||
"bp must not emit when allTimeCount === 0 (REG-11)",
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "pulse"),
|
||||
"pulse must not emit when allTimeCount === 0 (REG-11)",
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits allTimeCount + lastSeenAt for every base metric (v1.4.33 maintainer-item-1)", async () => {
|
||||
// Empty-data path — every metric card still ships the new fields
|
||||
// so the iOS client can render a tile + "Letzter Wert vor Xd"
|
||||
// hint regardless of whether the route saw any rows in the 7-day
|
||||
// window.
|
||||
it("emits allTimeCount + lastSeenAt for the weight card on empty data (v1.4.33 maintainer-item-1)", async () => {
|
||||
// REG-11 (v1.4.44): the always-emitted base set shrank to just
|
||||
// `weight` — BP + pulse now gate on `latest || allTimeCount > 0`
|
||||
// so an account that has never logged either doesn't get a noisy
|
||||
// empty tile. Weight still ships unconditionally because the
|
||||
// onboarding flow expects the tile as a primary CTA.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
@@ -142,11 +151,9 @@ describe("GET /api/dashboard/summary", () => {
|
||||
};
|
||||
};
|
||||
const baseIds = body.data.metrics.map((m) => m.id);
|
||||
for (const required of ["weight", "bp", "pulse"]) {
|
||||
expect(baseIds, `${required} card missing from metrics list`).toContain(
|
||||
required,
|
||||
);
|
||||
}
|
||||
expect(baseIds, "weight card missing from metrics list").toContain(
|
||||
"weight",
|
||||
);
|
||||
for (const card of body.data.metrics) {
|
||||
expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0);
|
||||
expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull();
|
||||
@@ -192,6 +199,200 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString());
|
||||
});
|
||||
|
||||
it("emits the BP tile with the historical value when the last reading is 60 days old (REG-11)", async () => {
|
||||
// The original 7-day calendar filter in the latest-reading SQL
|
||||
// dropped any row outside the trailing-7-day window, so a BP
|
||||
// measurement logged 60 days ago surfaced as `latestValue: null`
|
||||
// and `sparkline: []` — the iOS tile then had nothing to render.
|
||||
// Post-REG-11 the `latestEver` aggregate carries the row + the
|
||||
// `ROW_NUMBER`-windowed sparkline carries the trailing 7 daily
|
||||
// rollup buckets regardless of how old they are.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000);
|
||||
const sixtyOneDaysAgo = new Date(Date.now() - 61 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
_count: { _all: 14 },
|
||||
_max: { measuredAt: sixtyDaysAgo },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
_count: { _all: 14 },
|
||||
_max: { measuredAt: sixtyDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
// Promise.all order: [latestEver, sparkline, streakDays].
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 132,
|
||||
measured_at: sixtyDaysAgo,
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 84,
|
||||
measured_at: sixtyDaysAgo,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucket_start: sixtyOneDaysAgo,
|
||||
mean: 130,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucket_start: sixtyDaysAgo,
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
sparkline: number[];
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const bp = body.data.metrics.find((m) => m.id === "bp");
|
||||
expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
|
||||
expect(bp?.latestValue).toBe(132);
|
||||
expect(bp?.secondaryValue).toBe(84);
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
|
||||
expect(bp?.sparkline).toEqual([130, 132]);
|
||||
});
|
||||
|
||||
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
|
||||
// Accounts that have never logged BP should not get an empty
|
||||
// placeholder tile. Mirrors the bodyFat gate that's been in place
|
||||
// since v1.4.33.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ id: string }> };
|
||||
};
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "bp"),
|
||||
"bp tile must not emit when no BP reading exists",
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "pulse"),
|
||||
"pulse tile must not emit when no pulse reading exists",
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the weight tile fresh when the latest reading is within 7d (REG-11 regression guard)", async () => {
|
||||
// The post-REG-11 SQL drops the calendar filter — the recent-path
|
||||
// (last reading <7d) must still produce identical output so we
|
||||
// don't accidentally regress the fresh-data case.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
_count: { _all: 200 },
|
||||
_max: { measuredAt: twoDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.4, measured_at: twoDaysAgo },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucket_start: twoDaysAgo,
|
||||
mean: 82.4,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
sparkline: number[];
|
||||
allTimeCount: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const weight = body.data.metrics.find((m) => m.id === "weight");
|
||||
expect(weight?.latestValue).toBe(82.4);
|
||||
expect(weight?.sparkline).toEqual([82.4]);
|
||||
expect(weight?.allTimeCount).toBe(200);
|
||||
});
|
||||
|
||||
it("sparkline window takes the last 7 daily buckets even when they're all older than 7 days (REG-11)", async () => {
|
||||
// The new `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
|
||||
// bucket_start DESC)` window guarantees the route asks the DB for
|
||||
// exactly the trailing `SPARK_DAYS` buckets per type. Because the
|
||||
// SQL runs in Postgres, the mock here only proves the route hands
|
||||
// the buckets through in chronological order without dropping any
|
||||
// for age. The asserted order matches the SQL `ORDER BY type,
|
||||
// bucket_start ASC` so the iOS chart paints left-to-right with the
|
||||
// oldest bucket first.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const days = (n: number) => new Date(Date.now() - n * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
_count: { _all: 42 },
|
||||
_max: { measuredAt: days(30) },
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 71, measured_at: days(30) },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
sparkline: number[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const pulse = body.data.metrics.find((m) => m.id === "pulse");
|
||||
expect(pulse, "pulse tile must emit for stale history").toBeDefined();
|
||||
expect(pulse?.latestValue).toBe(71);
|
||||
expect(pulse?.sparkline).toEqual([68, 70, 69, 71, 73, 72, 71]);
|
||||
expect(pulse?.sparkline).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("emits optional cards when allTimeCount > 0 but the 7-day window is empty", async () => {
|
||||
// Glucose / sleep / steps used to only emit when the 7-day
|
||||
// window had a reading. v1.4.33 widens the gate so a metric the
|
||||
|
||||
@@ -22,13 +22,21 @@
|
||||
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
|
||||
*
|
||||
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
|
||||
* - `DISTINCT ON (type)` over the 7-day window → one row per type
|
||||
* carrying the latest value + measuredAt (≤ N_metrics rows).
|
||||
* - `DISTINCT ON (type)` → one row per type carrying the latest
|
||||
* value + measuredAt (≤ N_metrics rows). REG-11 (v1.4.44) dropped
|
||||
* the trailing-7-day window from the WHERE clause; the tile now
|
||||
* surfaces the all-time-latest reading per type so a 60-day-old BP
|
||||
* measurement still feeds the tile (paired with the stale-caption
|
||||
* hint built off `lastSeenAt`).
|
||||
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
|
||||
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
|
||||
* Sparkline points become the bucket means rather than individual
|
||||
* raw samples, which is a *smoother* trend signal for high-volume
|
||||
* metrics like ACTIVITY_STEPS and bounded for every other metric.
|
||||
* REG-11 (v1.4.44) switched the calendar-window filter for a
|
||||
* `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start
|
||||
* DESC)` window so the sparkline takes the last `SPARK_DAYS`
|
||||
* buckets per type regardless of age.
|
||||
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
|
||||
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
|
||||
*
|
||||
@@ -192,25 +200,40 @@ function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the most recent reading
|
||||
* per measurement type inside the 7-day window. One row per metric
|
||||
* the user touched in the window; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`. */
|
||||
interface LatestIn7dRow {
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the single most recent
|
||||
* reading per measurement type, irrespective of age. One row per
|
||||
* metric the user has ever touched; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`.
|
||||
*
|
||||
* REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE
|
||||
* clause. The old shape returned `latestValue: null` + an empty
|
||||
* sparkline for accounts whose last BP / pulse reading was older than
|
||||
* 7 days, leaving the iOS tile blank even though the historical row
|
||||
* was still in the database. The all-time aggregate (`groupBy` on
|
||||
* `measurements`) already proves the row exists; the row count is
|
||||
* still bounded by `|measurementTypes|` via `DISTINCT ON`. */
|
||||
interface LatestEverRow {
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
|
||||
* window. At most 7 buckets per metric × N metrics — bounded by
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard
|
||||
* sparkline. At most 7 buckets per metric × N metrics — bounded by
|
||||
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count.
|
||||
*
|
||||
* v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile
|
||||
* (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket
|
||||
* MEAN. Spot metrics ignore the column; cumulative tiles fall back to
|
||||
* `mean * count` when the legacy NULL hits (boot-backfill convergence
|
||||
* window). */
|
||||
* window).
|
||||
*
|
||||
* REG-11 (v1.4.44): switched from a calendar-window filter
|
||||
* (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION
|
||||
* BY type ORDER BY bucket_start DESC)` window so the sparkline takes
|
||||
* the last `SPARK_DAYS` daily buckets per type regardless of age. An
|
||||
* account whose last BP reading is 60 days old now still gets the
|
||||
* trailing 7 days of historical buckets feeding the tile chart. */
|
||||
interface SparklineRow {
|
||||
type: MeasurementType;
|
||||
bucket_start: Date;
|
||||
@@ -308,7 +331,10 @@ async function buildDashboardSummary(
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
// REG-11 (v1.4.44): the trailing-7-day pivot used to anchor the
|
||||
// latest-reading + sparkline filters is gone — both sub-queries now
|
||||
// take the most recent N rows per type regardless of age. Only the
|
||||
// streak window still anchors on a calendar slice.
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
@@ -337,46 +363,56 @@ async function buildDashboardSummary(
|
||||
|
||||
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
|
||||
// unbounded ones. Row counts are now:
|
||||
// - latestIn7d: ≤ N metric types (one row per type via DISTINCT ON)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80)
|
||||
// - latestEver: ≤ N metric types (one row per type via DISTINCT ON,
|
||||
// REG-11: dropped the 7-day window so a stale-but-
|
||||
// valid historical reading still feeds the tile)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80,
|
||||
// REG-11: taken via ROW_NUMBER window, no calendar
|
||||
// filter, so a 60-day-old metric still paints a chart)
|
||||
// - allTimeAggregate: ≤ N metric types (unchanged)
|
||||
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestIn7d,
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
todaysIntakes,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
] = await Promise.all([
|
||||
time("latest7d", () =>
|
||||
prisma.$queryRaw<LatestIn7dRow[]>`
|
||||
time("latestEver", () =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${sevenDaysAgo}
|
||||
AND m."deleted_at" IS NULL
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean,
|
||||
r."count"::int AS count,
|
||||
r."sum_value"::double precision AS sum_value
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= ${sevenDaysAgo}
|
||||
ORDER BY r."type", r."bucket_start" ASC
|
||||
SELECT type, bucket_start, mean, count, sum_value
|
||||
FROM (
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean,
|
||||
r."count"::int AS count,
|
||||
r."sum_value"::double precision AS sum_value,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY r."type"
|
||||
ORDER BY r."bucket_start" DESC
|
||||
) AS rn
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
) sub
|
||||
WHERE rn <= ${SPARK_DAYS}
|
||||
ORDER BY type, bucket_start ASC
|
||||
`,
|
||||
),
|
||||
time("allTime", () =>
|
||||
@@ -474,8 +510,11 @@ async function buildDashboardSummary(
|
||||
|
||||
// v1.4.38 W-F — per-type latest (one row per type) + sparkline
|
||||
// (bucket means per day) assembled from the two new SQL aggregates.
|
||||
// REG-11 (v1.4.44): `latestEver` carries the most recent reading
|
||||
// regardless of age — the iOS tile shows the historical value plus a
|
||||
// muted "Letzter Wert vor Xd" caption rather than nothing at all.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestIn7d) {
|
||||
for (const row of latestEver) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
@@ -541,7 +580,10 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
// Blood pressure (paired sys/dia) — REG-11 (v1.4.44): gate the emit
|
||||
// on `latestSys || latestDia || bpAllTimeCount > 0` so an account
|
||||
// that has never logged BP doesn't get an empty placeholder tile.
|
||||
// Mirrors the body-fat gate below.
|
||||
{
|
||||
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
|
||||
@@ -560,42 +602,48 @@ async function buildDashboardSummary(
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
if (latestSys || latestDia || bpAllTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse
|
||||
// Pulse — REG-11 (v1.4.44): gate the emit on `latest || allTimeCount
|
||||
// > 0` so an account that has never logged pulse doesn't get an empty
|
||||
// placeholder tile. Mirrors the body-fat gate below.
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
const meta = metaForType("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Body fat — v1.4.33 widens the emit gate from `latestOf` (only
|
||||
|
||||
Reference in new issue
Block a user