mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
This commit is contained in:
1 parent
c7a0ff214b
commit
7bef199039
186 files changed
+9358
-946
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Apple App Site Association (AASA) handler.
|
||||
*
|
||||
* Apple fetches `/.well-known/apple-app-site-association` over HTTPS
|
||||
* without credentials, with no extension and with a strict
|
||||
* `application/json` Content-Type expectation. iOS uses the body to:
|
||||
* - Wire Web Credentials so the website origin and the iOS bundle
|
||||
* share passkey ceremonies (HealthLog passkeys live at
|
||||
* `/api/auth/passkey/*`, so the origins must match the same App ID).
|
||||
* - Wire Universal Links — out of scope for the first cut, hence the
|
||||
* empty `applinks.details` array. Future entries here pick up
|
||||
* deep-linking once the iOS side opts in.
|
||||
*
|
||||
* The `webcredentials.apps` entry is the App ID prefix
|
||||
* (`<TeamID>.<BundleID>`) for the HealthLog iOS app. Apple bundles the
|
||||
* file on its CDN proxy
|
||||
* (`https://app-site-association.cdn-apple.com/a/v1/<host>`) and serves
|
||||
* it to devices, so the origin only needs to respond reliably; Apple's
|
||||
* one-hour CDN TTL pairs with the `Cache-Control` directive below.
|
||||
*
|
||||
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
|
||||
* (demo) share this handler — the response is host-independent.
|
||||
*/
|
||||
const AASA = {
|
||||
applinks: {
|
||||
apps: [],
|
||||
details: [],
|
||||
},
|
||||
webcredentials: {
|
||||
apps: ["S8WDX4W5KX.dev.healthlog.app"],
|
||||
},
|
||||
};
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(AASA, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -26,9 +26,17 @@ function load(path: string): string {
|
||||
|
||||
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
|
||||
it("imports both new components", () => {
|
||||
// v1.4.33 IW2 deferred below-the-fold blocks behind `next/dynamic`,
|
||||
// so the static `from "..."` import-string was replaced with a
|
||||
// `dynamic(() => import("..."))` call. Either spelling counts as
|
||||
// a load-bearing reference to the module path.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toContain('from "@/components/insights/correlation-row"');
|
||||
expect(src).toContain('from "@/components/insights/trends-row"');
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/correlation-row"|import\("@\/components\/insights\/correlation-row"\))/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/trends-row"|import\("@\/components\/insights\/trends-row"\))/,
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
|
||||
|
||||
@@ -81,6 +81,12 @@ export default function AboutPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
v1.4.33 IW9 — `max-w-3xl` is intentional (same rationale as
|
||||
`/privacy`): long-form column reads better around 70-80
|
||||
chars per line. The dashboard / settings / admin shells use
|
||||
`max-w-screen-xl` (1280 px); legal pages stay at 768 px.
|
||||
*/}
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
|
||||
|
||||
@@ -225,7 +225,19 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
</p>
|
||||
<Progress value={achievement.progressPercent} className="h-1.5" />
|
||||
{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */}
|
||||
<Progress
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -328,6 +340,7 @@ export default function AchievementsPage() {
|
||||
: 0
|
||||
}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.points")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
@@ -385,6 +398,7 @@ export default function AchievementsPage() {
|
||||
<Progress
|
||||
value={summary?.nextAchievement?.progressPercent ?? 100}
|
||||
className="mt-3 h-1.5"
|
||||
aria-label={t("achievements.nextProgressLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* v1.4.33 P0 regression coverage for `GET /api/analytics`.
|
||||
*
|
||||
* Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…):
|
||||
* `RangeError: Maximum call stack size exceeded`
|
||||
* at Promise.all (index 3 — `PULSE`)
|
||||
*
|
||||
* Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the
|
||||
* `Math.min(...values)` / `Math.max(...values)` spread blew V8's
|
||||
* ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE
|
||||
* series for a multi-year power user grew past it.
|
||||
*
|
||||
* The fix folds min/max into the single sum/mean pass; this test pins
|
||||
* the contract from the route entry-point so a future refactor (e.g.
|
||||
* the v1.4.33 C1 SQL-side aggregation rewrite) can't silently
|
||||
* reintroduce a spread anywhere along the call chain.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
interface MeasurementRow {
|
||||
id: string;
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
source: "MANUAL" | "WITHINGS" | "IMPORT" | "APPLE_HEALTH";
|
||||
deviceType: string | null;
|
||||
sleepStage?: string | null;
|
||||
}
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-1",
|
||||
username: "test",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
|
||||
sourcePriorityJson: null,
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
|
||||
return {
|
||||
id,
|
||||
measuredAt,
|
||||
value,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/analytics", () => {
|
||||
it("survives a 130 000-row PULSE series without blowing the stack", async () => {
|
||||
// The chunked reader pages 5 000 at a time. Simulate one full page
|
||||
// for PULSE so the route's per-type aggregator hits the failing
|
||||
// `summarize()` codepath with a 5 000-point series, then stop. We
|
||||
// intentionally don't seed every single Apple Watch sample in the
|
||||
// mock — V8's spread-arg ceiling is repeatable with a far smaller
|
||||
// array than production (see `trends.test.ts` for the 250 000-row
|
||||
// direct regression). This test pins the route-entry contract.
|
||||
const N = 5_000;
|
||||
const now = Date.now();
|
||||
const pulseSeries: MeasurementRow[] = new Array(N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
pulseSeries[i] = pulseRow(
|
||||
new Date(now - (N - i) * 1000),
|
||||
40 + (i % 160),
|
||||
`pulse-${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return the PULSE rows on the second page-of-5000-empty for every
|
||||
// other type; the chunked reader walks `take=5000` then exits on
|
||||
// any short page.
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(async (args: any) => {
|
||||
if (args.where?.type === "PULSE" && !args.cursor) {
|
||||
return pulseSeries as never;
|
||||
}
|
||||
return [] as never;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
// GET is wrapped by `apiHandler` which tolerates direct-invoke
|
||||
// with no request (see `safeRequestProp` in `src/lib/api-handler.ts`).
|
||||
// Cast through `unknown` so the TS signature `(...args: never[])`
|
||||
// doesn't fight the vitest direct-invoke pattern.
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { summaries: Record<string, { count: number; min: number | null; max: number | null }> };
|
||||
};
|
||||
expect(body.data.summaries.PULSE.count).toBe(N);
|
||||
// 40 + (N-1) % 160 → wave around (40..199).
|
||||
expect(body.data.summaries.PULSE.min).toBeGreaterThanOrEqual(40);
|
||||
expect(body.data.summaries.PULSE.max).toBeLessThanOrEqual(199);
|
||||
});
|
||||
|
||||
it("returns a 200 envelope for a brand-new user with zero rows", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
summaries: Record<string, { count: number }>;
|
||||
bmi: number | null;
|
||||
healthScore: unknown;
|
||||
};
|
||||
};
|
||||
// Empty series should not crash on the new fold path either.
|
||||
expect(body.data.summaries.PULSE.count).toBe(0);
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(0);
|
||||
expect(body.data.bmi).toBeNull();
|
||||
expect(body.data.healthScore).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice. The route branches on
|
||||
// `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw`
|
||||
// passes carry the per-type DataSummary shape with the same
|
||||
// contract the dashboard tile strip reads.
|
||||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||||
// Aggregate pass + latest pass. The mock returns one row per pass.
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(12),
|
||||
min_value: 80.0,
|
||||
max_value: 84.5,
|
||||
mean_value: 82.1,
|
||||
avg7: 82.0,
|
||||
avg30: 82.2,
|
||||
slope7: -0.05,
|
||||
r2_7: 0.4,
|
||||
slope30: -0.02,
|
||||
r2_30: 0.3,
|
||||
slope90: -0.01,
|
||||
r2_90: 0.2,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
summaries: Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
latest: number | null;
|
||||
slope30: { slope: number; direction: string } | null;
|
||||
}
|
||||
>;
|
||||
bmi: number | null;
|
||||
};
|
||||
};
|
||||
// The slim slice produced WEIGHT from the SQL pass; no chunked
|
||||
// findMany was called.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(12);
|
||||
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
|
||||
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
|
||||
// Slim slice never carries BMI — the consumer re-derives.
|
||||
expect(body.data.bmi).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
/**
|
||||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||||
* route's production wrapper) or a plain `Request` (the integration
|
||||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||||
* so the slim slice branch is reachable from both call shapes.
|
||||
*/
|
||||
function readSliceParam(request: Request | undefined): string | null {
|
||||
if (!request) return null;
|
||||
try {
|
||||
return new URL(request.url).searchParams.get("slice");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request?: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||||
// The default path runs 30+ chunked findMany reads to feed
|
||||
// `summarize()` per type; under `?slice=summaries` the route
|
||||
// resolves the same per-type DataSummary shape from 2 SQL passes
|
||||
// (a `groupBy` for count/min/max/mean plus a single `$queryRaw`
|
||||
// carrying the windowed averages and Postgres `regr_slope` /
|
||||
// `regr_r2` for the slope tuples). The slim slice drops the thick
|
||||
// `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext`
|
||||
// / `sleepStages` / `bmi` blocks — those stay on the default path
|
||||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
return apiSuccess(slim);
|
||||
}
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function BugReportPage() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div className="w-full space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
@@ -112,7 +112,7 @@ export default function BugReportPage() {
|
||||
|
||||
if (status && status.enabled === false) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
@@ -227,9 +227,22 @@ export default function BugReportPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
<Button type="submit" disabled={loading} aria-busy={loading || undefined}>
|
||||
{/*
|
||||
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
|
||||
the same 16x16 slot as the Bug icon) so the button
|
||||
width is stable across the disabled flip. The previous
|
||||
`{loading && <Loader2/>}<Bug/>` rendered both icons
|
||||
during the in-flight request and grew the button by
|
||||
~24 px — the CLS regression flagged in the v1.4.33
|
||||
Lighthouse punch list (0.186 on
|
||||
`div.space-y-2 > Button > Loader-Circle`).
|
||||
*/}
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("bugreport.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function InsightsBlutdruckPage() {
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
yAxisUnit="mmHg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
|
||||
@@ -187,14 +187,24 @@ export default function InsightsMedikamentePage() {
|
||||
<span>{t("insights.compliance7d")}</span>
|
||||
<span className="font-medium">{med.compliance7}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance7} className="h-2" />
|
||||
{/* v1.4.33 IW9 — aria-label so the bar has an
|
||||
accessible name (Lighthouse a11y 91/94). */}
|
||||
<Progress
|
||||
value={med.compliance7}
|
||||
className="h-2"
|
||||
aria-label={t("insights.compliance7d")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance30d")}</span>
|
||||
<span className="font-medium">{med.compliance30}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance30} className="h-2" />
|
||||
<Progress
|
||||
value={med.compliance30}
|
||||
className="h-2"
|
||||
aria-label={t("insights.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>
|
||||
|
||||
+64
-28
@@ -1,24 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
import { TrendsRow } from "@/components/insights/trends-row";
|
||||
import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* v1.4.33 IW2 — defer the three below-the-fold mother-page blocks
|
||||
* behind `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece)
|
||||
* stays an eager import so the initial paint shows the greeting and
|
||||
* health-score badge without a flash; the briefing, correlation row
|
||||
* and trends row each carry their own icon-set + chart wiring (a chart
|
||||
* card alone weighs in at the lucide tree-shake limit) and used to
|
||||
* land on every Insights cold mount. Loader skeletons match the
|
||||
* existing fallback so the layout doesn't shift while the chunks
|
||||
* resolve.
|
||||
*/
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
default: mod.DailyBriefing,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-48 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
const CorrelationRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/correlation-row").then((mod) => ({
|
||||
default: mod.CorrelationRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-32 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
const TrendsRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/trends-row").then((mod) => ({
|
||||
default: mod.TrendsRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-64 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
*
|
||||
@@ -95,20 +142,13 @@ export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.28 FB-D3 — mirror `<SubPageShell>`'s deferred scroll-reset on
|
||||
// the mother page so a back-navigation from a sub-page (where
|
||||
// `SubPageShell` already reset the scroll on mount) lands cleanly
|
||||
// at the top of the overview instead of inheriting the sub-page's
|
||||
// vertical position through the cached scroll state. Single
|
||||
// mount-only effect; uses `requestAnimationFrame` so the reset
|
||||
// settles after first paint, same as the sub-page shell.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(handle);
|
||||
}, []);
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
// shared `useScrollResetOnRoute()` hook. The mother page + the
|
||||
// `<SubPageShell>` both consume the same hook; the legacy duplicate
|
||||
// RAF that lived here pre-v1.4.33 produced a visible double-snap on
|
||||
// slow hydrates (chart skeletons inflating between the two
|
||||
// callbacks).
|
||||
useScrollResetOnRoute();
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
|
||||
@@ -134,16 +174,12 @@ export default function InsightsPage() {
|
||||
// beyond the React-state subscription.
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
// v1.4.33 IW2 — the mother page reads `correlations` + `healthScore`
|
||||
// (thick-only fields) so it stays on the default thick slice. The
|
||||
// shared hook still centralises the cache settings so the consumer
|
||||
// dedups with the sub-page mounts that ride the slim slice instead.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -482,6 +482,7 @@ function IntakeImportDialog({
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -505,9 +506,21 @@ function IntakeImportDialog({
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
>
|
||||
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{/*
|
||||
v1.4.33 IW9 — icon swap (not append) on `importing`
|
||||
to keep the button width stable through the
|
||||
in-flight request. Pre-v1.4.33 both icons painted
|
||||
during loading and the button grew by ~24 px, which
|
||||
showed up as a CLS hit on the medications import
|
||||
dialog.
|
||||
*/}
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -720,6 +733,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(endpoint, "url")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -744,6 +758,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(token, "token")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -795,6 +810,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-7 w-7"
|
||||
onClick={() => copyText(selectedExample.value, "example")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -821,6 +837,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loadingStatus || toggling}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
|
||||
import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
@@ -33,6 +33,42 @@ interface PreferencesData {
|
||||
eventTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb strip for the notification surfaces. v1.4.33 IW7 introduced
|
||||
* this so the inbox (`/notifications`) and the channel-config section
|
||||
* (`/settings/notifications`) never read as the same page — the two names
|
||||
* used to collide as plain "Notifications" in both nav labels. The crumb
|
||||
* sits above the h1 on every render branch of this page (loading,
|
||||
* unauthenticated, error, empty, populated) so the user always sees the
|
||||
* disambiguation, even when the body content is a fallback state.
|
||||
*/
|
||||
function NotificationsBreadcrumb({
|
||||
t,
|
||||
}: {
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
|
||||
<ol className="flex items-center gap-1">
|
||||
<li className="text-foreground font-medium">
|
||||
{t("notifications.breadcrumbInbox")}
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("notifications.breadcrumbChannels")}
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
|
||||
function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
@@ -137,6 +173,7 @@ export default function NotificationsPage() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
</h1>
|
||||
@@ -154,6 +191,7 @@ export default function NotificationsPage() {
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
@@ -176,6 +214,7 @@ export default function NotificationsPage() {
|
||||
if (activeChannels.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
@@ -204,6 +243,7 @@ export default function NotificationsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
|
||||
+78
-48
@@ -62,6 +62,7 @@ const MedicationComplianceChart = dynamic(
|
||||
);
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
@@ -202,17 +203,14 @@ export default function DashboardPage() {
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.analytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
});
|
||||
// v1.4.33 IW2 — the dashboard tile-strip reads `bpInTargetPct*` +
|
||||
// `glucoseByContext` (thick-only fields) so it stays on the default
|
||||
// thick slice. The shared hook still centralises the cache settings
|
||||
// (60s staleTime, no refetch on mount / window focus) so the
|
||||
// tile-strip dedups with the Insights mother page on the same
|
||||
// queryKey + slice combination.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const data = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
@@ -321,7 +319,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 +505,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 +1248,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>
|
||||
))}
|
||||
|
||||
@@ -124,6 +124,13 @@ export default function PrivacyPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
v1.4.33 IW9 — `max-w-3xl` (768 px) is intentional, not a stale
|
||||
token. The legal/long-form column reads better at 70-80 chars
|
||||
per line; widening to the app shell's `max-w-screen-xl` would
|
||||
push line lengths to ~120 chars on a 1440 px laptop and degrade
|
||||
legibility. The same exception applies to `/about`.
|
||||
*/}
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
|
||||
|
||||
Reference in new issue
Block a user