mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(dashboard): tile sentiment, mobile grid, BD-Ziel gate, summary metadata
- Centralise the trend-arrow + 7-day-delta + comparison-overlay colour through a single `getTrendSentiment(change, sentiment)` helper so the three elements never paint disagreeing signals on the same tile (F5 audit fix). Arrow direction now follows the same signal that drives the 7-day delta value. - Collapse the dashboard tile strip from `flex overflow-x-auto` at <sm + grid above into a single responsive `repeat(auto-fit, minmax(min(100%, 11rem), 1fr))` track. Phones wrap to two rows instead of side-scrolling; 1440×900 picks 6 columns so the headline digits stop truncating to `8…` / `1.`. - Hold the tile callout slot at `min-h-[18px]` on every breakpoint so a comparison overlay on one tile no longer pushes its callout-less neighbours 18 px taller. - Gate the BD-Zielbereich tile so a literal "0,0 %" placeholder no longer renders when every window (7d / 30d / all-time) reports zero. The tile shows once any window crosses zero; otherwise the `/targets` Blutdruck card carries the deeper analysis. - Restore proportional sizing on the dashboard Hinzufügen button: `size="default"` with `min-h-11 sm:min-h-9` keeps WCAG 2.5.5 on mobile (44 px) and tightens to a desktop-friendly 36 px above sm. - Surface `allTimeCount` + `lastSeenAt` per metric on `/api/dashboard/summary` so the iOS client can keep a tile visible during a logging gap and render a `Letzter Wert vor Xd` caption. Optional cards (glucose / sleep / steps / body fat) widen the emit gate from "7-day window has data" to "ever logged". Added the matching `staleDays` prop to `<TrendCard>` and `dashboard.staleHint` i18n keys across all six locales.
This commit is contained in:
1 parent
86aa27e68e
commit
8131fcb369
4 files changed
+414
-132
No files matched your search
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -54,6 +54,10 @@ function makeReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for
|
||||
// per-type all-time count + most-recent timestamp. Default to an
|
||||
// empty aggregate so legacy tests keep their "no data" expectations.
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
@@ -90,6 +94,107 @@ describe("GET /api/dashboard/summary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
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,
|
||||
);
|
||||
}
|
||||
for (const card of body.data.metrics) {
|
||||
expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0);
|
||||
expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces lastSeenAt from the historical aggregate even when the 7-day window is empty", async () => {
|
||||
// Power-user path — the user has logged weight for years but
|
||||
// hasn't touched the app in two weeks. `groupBy` returns the
|
||||
// all-time count + most-recent timestamp; the 7-day `findMany`
|
||||
// returns empty. The route should still emit the weight card
|
||||
// with the historical `lastSeenAt` so the iOS client renders the
|
||||
// staleness caption.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
_count: { _all: 312 },
|
||||
_max: { measuredAt: twoWeeksAgo },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const weight = body.data.metrics.find((m) => m.id === "weight");
|
||||
expect(weight, "weight card must be present").toBeDefined();
|
||||
expect(weight?.allTimeCount).toBe(312);
|
||||
expect(weight?.lastSeenAt).toBe(twoWeeksAgo.toISOString());
|
||||
// No 7-day reading → latestValue stays null but updatedAt falls
|
||||
// through to the historical timestamp so the iOS client can
|
||||
// build the relative-age caption from a single field.
|
||||
expect(weight?.latestValue).toBeNull();
|
||||
expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString());
|
||||
});
|
||||
|
||||
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
|
||||
// user logged once last month still surfaces a tile (latestValue
|
||||
// null + lastSeenAt populated → iOS renders the historical
|
||||
// hint).
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
_count: { _all: 4 },
|
||||
_max: { measuredAt: tenDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const glucose = body.data.metrics.find((m) => m.id === "glucose");
|
||||
expect(
|
||||
glucose,
|
||||
"glucose card must be present (v1.4.33 widened gate)",
|
||||
).toBeDefined();
|
||||
expect(glucose?.allTimeCount).toBe(4);
|
||||
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
|
||||
@@ -44,6 +44,24 @@ interface MetricCard {
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* v1.4.33 maintainer-item-1 — total readings the user has ever
|
||||
* logged for this metric, irrespective of the 7-day sparkline
|
||||
* window. The dashboard tile keeps showing whenever `allTimeCount > 0`
|
||||
* so a user with valid historical data isn't surprised by a tile
|
||||
* disappearing during a logging gap. Distinct from `sparkline.length`
|
||||
* which only carries the trailing-7-day points.
|
||||
*/
|
||||
allTimeCount: number;
|
||||
/**
|
||||
* v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single
|
||||
* most recent reading. When `allTimeCount > 0` but the latest
|
||||
* reading is older than 7 days, the iOS tile renders a muted
|
||||
* "Letzter Wert vor Xd" caption so the user understands the value
|
||||
* isn't stale silently. `null` when the metric has no readings at
|
||||
* all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
@@ -168,8 +186,15 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
// v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest
|
||||
// timestamps alongside the 7-day sparkline window so the iOS tile
|
||||
// can keep rendering when the recent window is empty but the user
|
||||
// has logged the metric before. `groupBy` with `_count._all` +
|
||||
// `_max(measuredAt)` is a single Postgres aggregate round-trip; no
|
||||
// additional row materialisation beyond the aggregate values
|
||||
// themselves.
|
||||
const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -179,6 +204,12 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: { userId: user.id, type: { in: measurementTypes } },
|
||||
_count: { _all: true },
|
||||
_max: { measuredAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -194,8 +225,33 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
|
||||
// always `number` per Prisma's runtime contract; defending against
|
||||
// undefined keeps the helper's narrow signature stable.
|
||||
const allTimeByType = new Map<
|
||||
MeasurementType,
|
||||
{ allTimeCount: number; lastSeenAt: Date | null }
|
||||
>();
|
||||
for (const row of allTimeAggregate) {
|
||||
allTimeByType.set(row.type, {
|
||||
allTimeCount: row._count?._all ?? 0,
|
||||
lastSeenAt: row._max?.measuredAt ?? null,
|
||||
});
|
||||
}
|
||||
function metaForType(type: MeasurementType): {
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
} {
|
||||
const slot = allTimeByType.get(type);
|
||||
return {
|
||||
allTimeCount: slot?.allTimeCount ?? 0,
|
||||
lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
@@ -240,10 +296,17 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — every emitted card now carries
|
||||
// `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep
|
||||
// a metric visible during a logging gap (gate on `allTimeCount > 0`)
|
||||
// and paint a muted "Letzter Wert vor Xd" caption when the most
|
||||
// recent reading is older than the 7-day sparkline window.
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
const meta = metaForType("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
@@ -253,7 +316,9 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,6 +328,20 @@ export const GET = apiHandler(async () => {
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
|
||||
// BP is a paired metric — the tile is "alive" whenever either side
|
||||
// of the pair has history. Sum the count to reflect total readings
|
||||
// and pick the most recent `_max` so the staleness hint follows
|
||||
// whichever side is freshest.
|
||||
const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount;
|
||||
const bpLastSeenAt = ((): string | null => {
|
||||
const sysAt = sysMeta.lastSeenAt;
|
||||
const diaAt = diaMeta.lastSeenAt;
|
||||
if (!sysAt) return diaAt;
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
@@ -273,7 +352,11 @@ export const GET = apiHandler(async () => {
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,6 +364,7 @@ export const GET = apiHandler(async () => {
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
const meta = metaForType("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
@@ -290,30 +374,43 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Body fat
|
||||
// Body fat — v1.4.33 widens the emit gate from `latestOf` (only
|
||||
// fires when the 7-day window has a reading) to `allTimeCount > 0`
|
||||
// so a tile doesn't disappear during a fortnight without a fresh
|
||||
// reading.
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
if (latest) {
|
||||
const meta = metaForType("BODY_FAT");
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
latestValue: latest.value,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards — only emitted if the user has data for that type.
|
||||
// Optional cards — emitted whenever the user has *ever* logged the
|
||||
// metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires
|
||||
// when the trailing 7-day window has a reading); v1.4.33 widens to
|
||||
// the all-time count so a glucose tile that hasn't been touched in
|
||||
// 10 days still renders with the historical value visible to the
|
||||
// iOS client (paired with the lastSeenAt caption).
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
@@ -323,18 +420,21 @@ export const GET = apiHandler(async () => {
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
if (!latest) continue;
|
||||
const meta = metaForType(type);
|
||||
if (!latest && meta.allTimeCount === 0) continue;
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
latestValue: latest.value,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+69
-37
@@ -321,7 +321,29 @@ export default function DashboardPage() {
|
||||
const hasSleep = (sleepSummary?.count ?? 0) > 0;
|
||||
const hasSteps = (stepsSummary?.count ?? 0) > 0;
|
||||
const hasVo2 = (vo2Summary?.count ?? 0) > 0;
|
||||
const hasBpInTarget = data?.bpInTargetPct != null;
|
||||
/**
|
||||
* v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %"
|
||||
* placeholder doesn't ride on the dashboard when none of the user's
|
||||
* paired readings sit inside the target band. The historical
|
||||
* behaviour (`!= null`) painted the tile whenever the analytics
|
||||
* route emitted a numeric percentage, including the legitimate-but-
|
||||
* misleading zero. The corrected gate requires at least one window
|
||||
* (7d, 30d, all-time) to report a non-zero share. When every
|
||||
* window is zero, the tile is hidden — the user sees the BP charts
|
||||
* + the `/targets` Blutdruck card for the deeper analysis instead.
|
||||
* The audit's F4 reproduction sat on 540 BP samples with all sub-
|
||||
* windows reading 0 % because the seed data straddled the target
|
||||
* ceiling; the tile keeps its place once even one sub-window
|
||||
* crosses zero.
|
||||
*/
|
||||
const hasBpInTarget =
|
||||
data?.bpInTargetPct != null &&
|
||||
[
|
||||
data?.bpInTargetPct,
|
||||
data?.bpInTargetPct7d,
|
||||
data?.bpInTargetPct30d,
|
||||
data?.bpInTargetPctAllTime,
|
||||
].some((pct) => pct != null && pct > 0);
|
||||
|
||||
// Tile (strip) gates — controlled by the new `tileVisible` flag.
|
||||
const showWeightTile = isTileVisible("weight") && hasWeight;
|
||||
@@ -485,13 +507,21 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* WCAG 2.5.5 — touch targets must be ≥44×44 CSS px on mobile.
|
||||
`size="sm"` (h-8 = 32px) was below threshold; the explicit
|
||||
min-h-11 ensures we hit 44px on the Pixel 5 viewport while
|
||||
keeping the desktop visual unchanged. */}
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
across viewports. The v1.4.27 fix pinned a `size="sm"
|
||||
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
|
||||
contract on mobile, but `size="sm"` is h-8 (32 px) and
|
||||
the `min-h-11` override stretched the cap vertically
|
||||
while keeping the small horizontal padding — the
|
||||
button read as klobig on Pixel 5. Switch to
|
||||
`size="default"` (h-10 = 40 px) on mobile with a
|
||||
responsive `min-h-11 sm:min-h-9` so the button is
|
||||
44 px tall under finger pressure and shrinks back to
|
||||
the desktop-friendly 36 px on `sm:` upwards. The icon
|
||||
+ label keep the same visual contract. */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
@@ -1220,44 +1250,46 @@ export default function DashboardPage() {
|
||||
height for any non-zero count. */}
|
||||
{trendCards.length > 0 && (
|
||||
<div
|
||||
// v1.4.27 MB7 / CF-42 — at `<sm` the tile strip
|
||||
// switches to a `flex overflow-x-auto` row so the
|
||||
// tiles scroll horizontally instead of wrapping to
|
||||
// 3-4 rows on Pixel 5 / Galaxy Fold. Each tile keeps
|
||||
// a `min-w-[10rem]` so the user sees ~2.5 tiles per
|
||||
// viewport — enough to read "there's more" without
|
||||
// crowding the headline value on the visible tile.
|
||||
// From `sm:` upwards the strip falls back to the
|
||||
// canonical `grid auto-fit + minmax(9rem, 1fr)`
|
||||
// layout (every tile equal width, wraps to a new
|
||||
// row when the 9 rem floor no longer fits) that the
|
||||
// maintainer pinned in v1.4.4.
|
||||
// v1.4.33 A3 Win 2 + F3 — collapse the bifurcated
|
||||
// mobile-flex / desktop-grid layout into one
|
||||
// responsive grid track. The v1.4.27 MB7 fix pinned a
|
||||
// `flex overflow-x-auto` row at `<sm` so tiles
|
||||
// scrolled horizontally rather than wrapping to 3-4
|
||||
// rows on a 280 px Galaxy Fold; the v1.4.33 audit
|
||||
// (Win 2) called the side-scroll an unwanted
|
||||
// regression for the Pixel 5 / iPhone-13-mini
|
||||
// viewports where two tiles per row fit naturally.
|
||||
// One grid track for every breakpoint:
|
||||
//
|
||||
// `snap-x snap-mandatory` makes the scroll feel
|
||||
// deliberate on touch and is a no-op on the grid
|
||||
// branch above `sm:`.
|
||||
// `repeat(auto-fit, minmax(min(100%, 11rem), 1fr))`
|
||||
//
|
||||
// - Galaxy Fold (280 px) → 1 column, tiles stretch
|
||||
// full width.
|
||||
// - Pixel 5 / iPhone-13-mini (375 px) → 2 columns,
|
||||
// strip wraps to a second row when needed.
|
||||
// - 1440×900 desktop → 6 columns (1440 / 220 ≈ 6.5),
|
||||
// widens the value column from the v1.4.27
|
||||
// `9rem` floor so the headline number stops
|
||||
// truncating to `8…` / `1.` (audit F3). 11 rem
|
||||
// ≈ 176 px gives every tile ~220 px including
|
||||
// gutter — comfortably wider than `text-3xl`
|
||||
// digits plus the unit + arrow.
|
||||
// - 1920+ desktop → 8 columns, no horizontal slack.
|
||||
//
|
||||
// `auto-rows-fr` keeps the row height deterministic
|
||||
// so a 7-tile run still shares one baseline across
|
||||
// the wrap, regardless of which tile renders a
|
||||
// callout.
|
||||
className={cn(
|
||||
"flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2",
|
||||
// v1.4.29 — fixed mobile tile height contract.
|
||||
// At `<sm` every tile pins to 140 px via the
|
||||
// `--tile-h` CSS custom property so divergent
|
||||
// content (callout on / off, sub-row wrap / no
|
||||
// wrap) cannot drift sibling heights. `sm:`
|
||||
// releases to `auto` where `grid auto-rows-fr`
|
||||
// already enforces equal heights.
|
||||
"[--tile-h:140px] sm:[--tile-h:auto]",
|
||||
"sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible",
|
||||
"sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]",
|
||||
"grid auto-rows-fr gap-3",
|
||||
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
|
||||
)}
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tour-id="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex h-[var(--tile-h)] min-w-[10rem] shrink-0 snap-start sm:h-auto sm:min-w-0 sm:shrink"
|
||||
>
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -39,6 +39,50 @@ interface SecondaryMetric {
|
||||
*/
|
||||
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
|
||||
|
||||
/**
|
||||
* v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day
|
||||
* delta value, and the comparison-overlay caption all paint the same
|
||||
* colour for the same signal. Before this helper each consumer ran its
|
||||
* own branch — the arrow read `slope30.direction`, the delta value read
|
||||
* `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`.
|
||||
* When the 30-day regression and the 7-day delta disagreed (weight down
|
||||
* over a month, up over a week) the tile painted a green arrow next to
|
||||
* an orange value, which read as "two metrics in one tile".
|
||||
*
|
||||
* `change` is a signed magnitude of the movement under inspection (the
|
||||
* 7-day delta in units, or the slope-projected-over-7-days for tiles
|
||||
* with only `slope30`). `sentiment` is the metric's improvement
|
||||
* direction.
|
||||
*
|
||||
* - `'positive'` — the change moves the metric toward its goal
|
||||
* (e.g. weight on `up-bad` going down, mood on `up-good` going up).
|
||||
* Renders green.
|
||||
* - `'negative'` — the change moves away from the goal. Renders orange.
|
||||
* - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target),
|
||||
* or the change is below the noise floor (|change| < 0.05). Renders
|
||||
* muted.
|
||||
*/
|
||||
export type TrendSentimentDirection = "positive" | "negative" | "neutral";
|
||||
|
||||
export function getTrendSentiment(
|
||||
change: number | null | undefined,
|
||||
sentiment: TrendDirectionSentiment,
|
||||
): TrendSentimentDirection {
|
||||
if (change == null || Math.abs(change) < 0.05) return "neutral";
|
||||
if (sentiment === "neutral") return "neutral";
|
||||
const isUp = change > 0;
|
||||
const isGood =
|
||||
(sentiment === "up-good" && isUp) ||
|
||||
(sentiment === "up-bad" && !isUp);
|
||||
return isGood ? "positive" : "negative";
|
||||
}
|
||||
|
||||
function sentimentColorClass(direction: TrendSentimentDirection): string {
|
||||
if (direction === "positive") return "text-dracula-green";
|
||||
if (direction === "negative") return "text-dracula-orange";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
interface TrendCardProps {
|
||||
label: string;
|
||||
latest: number | null;
|
||||
@@ -87,6 +131,17 @@ interface TrendCardProps {
|
||||
*/
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
compareDelta?: number | null;
|
||||
/**
|
||||
* v1.4.33 maintainer-item-1 — when the metric's most recent reading is
|
||||
* older than 7 days but the all-time count is still non-zero, the
|
||||
* caller passes a positive integer day count here. The tile renders a
|
||||
* muted "Letzter Wert vor Xd" caption underneath the value row so the
|
||||
* user sees the tile didn't disappear because of stale data — it kept
|
||||
* showing the historical value with an explicit "how old" hint.
|
||||
*
|
||||
* `null` / `undefined` suppresses the caption (current data path).
|
||||
*/
|
||||
staleDays?: number | null;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -106,70 +161,57 @@ export function TrendCard({
|
||||
trend7Delta = null,
|
||||
compareBaseline = "none",
|
||||
compareDelta = null,
|
||||
staleDays = null,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const TrendIcon =
|
||||
slope30?.direction === "up"
|
||||
? ArrowUp
|
||||
: slope30?.direction === "down"
|
||||
? ArrowDown
|
||||
: slope30
|
||||
? ArrowRight
|
||||
: Minus;
|
||||
|
||||
// v1.5: per-metric arrow sentiment. Flat ("→") and "no slope yet" ("—")
|
||||
// always stay muted; only an actual rise / fall paints colour, and the
|
||||
// colour direction depends on what's good for *this* metric. The
|
||||
// `text-muted-foreground` default keeps every metric tagged `neutral`
|
||||
// (pulse, BP-in-target %) visually identical to v1.4.6.
|
||||
const trendColor = ((): string => {
|
||||
// No data yet (Minus icon) or a "stable" slope (ArrowRight) stays muted —
|
||||
// we don't celebrate or scold a flat metric.
|
||||
if (!slope30 || slope30.direction === "stable") {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (directionSentiment === "neutral") return "text-muted-foreground";
|
||||
const isUp = slope30.direction === "up";
|
||||
const isGood =
|
||||
(directionSentiment === "up-good" && isUp) ||
|
||||
(directionSentiment === "up-bad" && !isUp);
|
||||
return isGood ? "text-dracula-green" : "text-dracula-orange";
|
||||
// v1.4.33 F5 — pick a single signal to drive both the arrow and the
|
||||
// 7-day delta value so they never disagree. `trend7Delta` is the
|
||||
// user-facing number on the tile; when present, the arrow follows
|
||||
// its sign. When the caller only supplies `slope30` (legacy tiles
|
||||
// that haven't been migrated to a delta), we project the slope over
|
||||
// a 7-day window so both signals share the same dimension. This
|
||||
// unifies the F5 audit complaint where a green arrow + orange value
|
||||
// appeared on the same tile because slope30 and trend7Delta could
|
||||
// legitimately disagree (30-day downward arc, 7-day uptick).
|
||||
const primarySignal: number | null =
|
||||
trend7Delta != null
|
||||
? trend7Delta
|
||||
: slope30
|
||||
? slope30.slope * 7
|
||||
: null;
|
||||
|
||||
const TrendIcon = ((): typeof ArrowUp => {
|
||||
if (primarySignal == null) return Minus;
|
||||
if (Math.abs(primarySignal) < 0.05) return ArrowRight;
|
||||
return primarySignal > 0 ? ArrowUp : ArrowDown;
|
||||
})();
|
||||
|
||||
// Sentiment is one centralized helper now — see `getTrendSentiment`
|
||||
// at the top of the file. All three tile elements (arrow, 7-day
|
||||
// delta value, comparison-overlay caption) route through the same
|
||||
// function so colour reads as "same signal" across the tile.
|
||||
const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment);
|
||||
const trendColor = sentimentColorClass(arrowSentiment);
|
||||
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
|
||||
// v1.4.15 Fix 4 — color the 7-day delta with the same metric-aware
|
||||
// sentiment rules the headline arrow already uses. Tiny absolute
|
||||
// deltas (< 0.05) read as "no movement" and stay muted.
|
||||
const deltaColor = ((): string => {
|
||||
if (trend7Delta == null || Math.abs(trend7Delta) < 0.05) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (directionSentiment === "neutral") return "text-muted-foreground";
|
||||
const isUp = trend7Delta > 0;
|
||||
const isGood =
|
||||
(directionSentiment === "up-good" && isUp) ||
|
||||
(directionSentiment === "up-bad" && !isUp);
|
||||
return isGood ? "text-dracula-green" : "text-dracula-orange";
|
||||
})();
|
||||
// v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so
|
||||
// both elements communicate the same sentiment for the same signal.
|
||||
const deltaSentiment = getTrendSentiment(trend7Delta, directionSentiment);
|
||||
const deltaColor = sentimentColorClass(deltaSentiment);
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — color the comparison delta with the same
|
||||
* metric-aware sentiment rules used everywhere else on the tile.
|
||||
* Tiny deltas read as stable and stay muted.
|
||||
*/
|
||||
const comparisonDeltaColor = ((): string => {
|
||||
if (compareDelta == null || Math.abs(compareDelta) < 0.05) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (directionSentiment === "neutral") return "text-muted-foreground";
|
||||
const isUp = compareDelta > 0;
|
||||
const isGood =
|
||||
(directionSentiment === "up-good" && isUp) ||
|
||||
(directionSentiment === "up-bad" && !isUp);
|
||||
return isGood ? "text-dracula-green" : "text-dracula-orange";
|
||||
})();
|
||||
const comparisonSentiment = getTrendSentiment(
|
||||
compareDelta,
|
||||
directionSentiment,
|
||||
);
|
||||
const comparisonDeltaColor = sentimentColorClass(comparisonSentiment);
|
||||
|
||||
const formatDelta = (value: number): string => {
|
||||
if (Math.abs(value) < 0.05) return `±0`;
|
||||
@@ -218,7 +260,7 @@ export function TrendCard({
|
||||
across every tile in the strip (Weight 80 kg vs. BP 122 mmHg used
|
||||
to land at different heights when one tile had a one-line heading
|
||||
and another had a two-line wrap). The arrow slot is always
|
||||
present — when there is no slope yet, a muted "—" placeholder
|
||||
present — when there is no signal yet, a muted "—" placeholder
|
||||
renders so the value row keeps a deterministic width. Marc's
|
||||
ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der
|
||||
Trend ist". */}
|
||||
@@ -237,40 +279,25 @@ export function TrendCard({
|
||||
data-slot="trend-card-arrow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{slope30 ? (
|
||||
{primarySignal != null ? (
|
||||
<TrendIcon className={`h-4 w-4 ${trendColor}`} />
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs opacity-30">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
|
||||
line below the latest value so the tile stays scannable on
|
||||
mobile viewports (no horizontal-scroll, no overlap with the
|
||||
existing 7d/30d row). Suppressed when comparison is off OR
|
||||
we don't have enough data to compute a delta.
|
||||
|
||||
v1.4.22 W5 reconcile (Design-H2) — on `<sm` viewports the
|
||||
BP-in-target tile (the only consumer of `avgAllTime`) packs
|
||||
three sub-rows + this callout into the height budget of
|
||||
every other tile. Hide the standalone callout at `<sm` for
|
||||
BP-in-target tiles; the secondary row below combines
|
||||
all-time + comparison-delta into a single line that stays
|
||||
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
|
||||
full layout returns at `>=sm`. */}
|
||||
{/* v1.4.27 MB7 / CF-41 — the `hidden sm:block` gate retired
|
||||
because the mobile-secondary block below is gone (CF-67
|
||||
vestigial wrapper drop). The callout paints on every
|
||||
viewport; when the parent grid is tight on `<sm`, the
|
||||
flex-wrap on the sibling row + this callout's `mt-1`
|
||||
horizontal-row layout absorb the overflow without crowding
|
||||
the headline value above. */}
|
||||
{/* v1.4.29 — clamp the callout to one line at `<sm` so the
|
||||
tile's overall height stays inside the 140-px contract.
|
||||
`min-h-[18px]` reserves the slot when no callout is being
|
||||
rendered so the sub-row pair below pins to the same y
|
||||
across every tile. `sm:` releases both bounds. */}
|
||||
<div className="mt-1 min-h-[18px] sm:min-h-0">
|
||||
{/* v1.4.29 — `min-h-[18px]` reserves the callout slot at every
|
||||
breakpoint so an empty callout doesn't drift sibling tiles
|
||||
18 px taller. v1.4.32 dropped the reservation at `sm:` (via
|
||||
`sm:min-h-0`); the v1.4.33 A3 Win 5 audit caught the
|
||||
asymmetry — a comparison-overlay run on the BD-Zielbereich
|
||||
tile expanded the strip's intrinsic height while its
|
||||
no-callout neighbours stayed short. Holding the floor across
|
||||
breakpoints makes the row's height deterministic regardless
|
||||
of which tiles render the callout. The callout itself clamps
|
||||
to one line at `<sm`; `sm:` releases the line-clamp + the
|
||||
[overflow-wrap:anywhere] fallback. */}
|
||||
<div className="mt-1 min-h-[18px]">
|
||||
{compareBaseline !== "none" && compareDelta != null && (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -292,6 +319,24 @@ export function TrendCard({
|
||||
)}`}
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.33 maintainer-item-1 — stale-data caption. Renders
|
||||
only when the caller passes `staleDays` (positive integer
|
||||
of days since the metric's last reading). The tile keeps
|
||||
the historical value visible because the maintainer would
|
||||
rather see "Letzter Wert vor 12d / 80,2 kg" than have the
|
||||
whole tile disappear because of a gap in logging. Ride the
|
||||
same callout-slot reservation as the comparison-overlay
|
||||
caption; on `<sm` the line-clamp keeps the 140 px height
|
||||
contract intact. */}
|
||||
{staleDays != null && staleDays > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground line-clamp-1 inline-block max-w-full text-xs leading-snug tabular-nums sm:line-clamp-none"
|
||||
data-slot="tile-stale-hint"
|
||||
data-stale-days={staleDays}
|
||||
>
|
||||
{t("dashboard.staleHint", { count: staleDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
|
||||
|
||||
Reference in new issue
Block a user