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:
Marc-André Bombeck authored and GitHub committed 2026-05-16 19:46:43 +02:00
1 parent c7a0ff214b
commit 7bef199039
186 files changed
+9358 -946

No files matched your search

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.4.33 — `/.well-known/*` is a credential-free IETF discovery
* namespace (RFC 8615). Apple in particular reads
* `/.well-known/apple-app-site-association` without a session to wire
* Web Credentials (passkey sharing) and Universal Links to the iOS
* bundle. This guard locks the proxy allowlist in place so a future
* refactor of `src/proxy.ts` can't silently re-gate the namespace
* behind a session cookie and break the Apple CDN fetch.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function makeRequest(
pathname: string,
cookies: Record<string, string> = {},
): NextRequest {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts /.well-known/* public-allowlist (v1.4.33)", () => {
it("does not 307 unauthenticated requests for the AASA file", () => {
const res = proxy(makeRequest("/.well-known/apple-app-site-association"));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("does not 307 a pending-onboarding session away from the AASA file", () => {
// Apple's CDN fetch carries no cookies, so this branch is mainly
// a belt-and-braces check; the redirect would only fire for a
// human visitor who happened to have a half-finished onboarding
// session and typed the URL by hand. Either way the asset must
// answer 200 with the JSON body unchanged.
const res = proxy(
makeRequest("/.well-known/apple-app-site-association", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("admits any other future /.well-known/* discovery file", () => {
// The allowlist entry is a `/.well-known/` prefix so future
// additions (e.g. `/.well-known/security.txt` or
// `/.well-known/openid-configuration`) don't need a second proxy
// edit. This check fails the moment the prefix is tightened to
// a literal `/.well-known/apple-app-site-association` only.
const res = proxy(makeRequest("/.well-known/security.txt"));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("still redirects unauthenticated requests for protected pages", () => {
// Negative check — confirms the test fixture would catch a real
// regression. `/insights` is not public; an unauthenticated visit
// must still 307 to /auth/login.
const res = proxy(makeRequest("/insights"));
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
});
});
@@ -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",
},
});
}
+10 -2
View File
@@ -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", () => {
+6
View File
@@ -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"
+15 -1
View File
@@ -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();
});
});
+35 -1
View File
@@ -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(((
+115 -15
View File
@@ -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,
});
}
+18 -5
View File
@@ -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>
+1 -1
View File
@@ -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}
+12 -2
View File
@@ -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
View File
@@ -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 (
+19 -2
View File
@@ -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>
+41 -1
View File
@@ -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
View File
@@ -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>
))}
+7
View File
@@ -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"
@@ -57,7 +57,9 @@ describe("<AiQualitySection>", () => {
mockQueryState.isLoading = false;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
// v1.4.33 IW7 — admin section renamed from "AI Quality" to
// "Insights Quality" per the Marc-Voice rule.
expect(html).toContain("Insights Quality");
expect(html).toContain("No feedback yet");
});
@@ -118,6 +120,6 @@ describe("<AiQualitySection>", () => {
mockQueryState.isLoading = false;
mockQueryState.isError = true;
const html = render(<AiQualitySection />);
expect(html).toContain("Failed to load AI quality summary");
expect(html).toContain("Failed to load Insights quality summary");
});
});
@@ -148,7 +148,10 @@ describe("admin sections — SSR smoke", () => {
// ai-quality-section test.
it("<AiQualitySection> renders the empty-state when no summary exists", () => {
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
// v1.4.33 IW7 — section renamed from "AI Quality" to
// "Insights Quality" per the Marc-Voice rule (no "KI"/"AI" prefix
// in user-facing copy).
expect(html).toContain("Insights Quality");
expect(html).toContain("No feedback yet");
});
@@ -434,9 +434,11 @@ function FeedbackDetailDialog({
{(url || locale || userAgent || appVersion) && (
<div>
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h4>
</h3>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
@@ -476,9 +478,9 @@ function FeedbackDetailDialog({
{item.screenshotBase64 && (
<div>
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h4>
</h3>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
+4 -2
View File
@@ -252,9 +252,11 @@ export function RemindersSection() {
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="text-sm font-medium">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h4>
</h2>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
+35 -9
View File
@@ -38,21 +38,47 @@ function getColor(data: DailyData): string {
data.veryLate !== undefined;
if (hasTimingData) {
const onTime = data.onTime ?? 0;
const veryLate = data.veryLate ?? 0;
const late = data.late ?? 0;
const missed = Math.max(0, data.expected - taken - data.skipped);
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time → green
return "var(--dracula-green)";
// v1.4.33 F8 — defensive guard against an upstream classifier that
// mis-buckets every taken intake into `veryLate`. The runtime audit
// (round-v1433-audit-runtime.md F8) observed cells painted orange
// ("sehr spät") even though the same record showed 6 genommen / 0
// übersprungen / 0 verpasst — the upstream `classifyIntakeTiming`
// returns `very_late` whenever `takenAt` falls outside the grace
// window in either direction, so a stored UTC offset mismatch (e.g.
// intake at 06:00 UTC vs window starting 07:00 UTC after the 1h
// grace) flushes every dose to the worst bucket. Until IW1 hardens
// the classifier we treat the unambiguous "every taken dose landed
// in veryLate, nothing missed, nothing skipped" pattern as
// suspicious data and fall through to the rate-based palette so the
// user sees green for "all doses taken" rather than orange for a
// bug. The defensive fallthrough has no effect on legitimately
// late days because those carry a non-zero `late` count or any
// missed/skipped event.
const looksClassifierBug =
veryLate === taken &&
onTime === 0 &&
late === 0 &&
missed === 0 &&
data.skipped === 0;
if (!looksClassifierBug) {
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time → green
return "var(--dracula-green)";
}
}
// Fallback: rate-based coloring (no timing data)
// Fallback: rate-based coloring (no timing data, or defensive
// fallthrough above).
const rate = (taken / data.expected) * 100;
if (rate >= 100) return "var(--dracula-green)";
if (rate >= 50) return "var(--dracula-yellow)";
+11 -2
View File
@@ -961,7 +961,16 @@ export function HealthChart({
return zones;
}, [showBands, targetZones, yDomain]);
const formatAxisValue = (value: number) => fmt.integer(value);
// v1.4.33 F10 — preserve one decimal when the visible Y-domain span is
// narrow enough that two distinct gridlines would otherwise round to
// the same integer (e.g. weight at 82.8 vs 83.5 both rendering as
// "83 kg"). The threshold matches the body-composition cluster: any
// domain spanning < 6 units gets `0.1`-precision ticks; wider domains
// keep the integer formatter so BP / glucose / steps stay clean.
const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null;
const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6;
const formatAxisValue = (value: number) =>
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value);
const formatTooltipValue = (value: number) => fmt.number(value, 1);
@@ -1313,7 +1322,7 @@ export function HealthChart({
domain={yDomain}
tickFormatter={(value) =>
typeof value === "number"
? formatAxisValue(Math.round(value))
? formatAxisValue(useDecimalAxis ? value : Math.round(value))
: String(value)
}
unit={
+124 -79
View File
@@ -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`
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { AssistantDisabledNotice } from "../assistant-disabled-notice";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<AssistantDisabledNotice>", () => {
it("renders the coach copy in the card variant", () => {
const html = render(<AssistantDisabledNotice surface="coach" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
expect(html.toLowerCase()).toContain("coach");
});
it("renders the briefing copy", () => {
const html = render(<AssistantDisabledNotice surface="briefing" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the status copy", () => {
const html = render(<AssistantDisabledNotice surface="insightStatus" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the inline variant without the Card wrapper", () => {
const html = render(
<AssistantDisabledNotice surface="correlations" variant="inline" />,
);
expect(html).toContain("<p");
expect(html).not.toContain("CardContent");
});
});
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
__resetChartTooltipObserverForTests,
getChartTooltipActive,
getChartTooltipActiveServer,
subscribeChartTooltipActive,
} from "../chart-tooltip-observer";
/**
* v1.4.33 (F15) — chart-tooltip observer contract tests.
*
* The full DOM-side behaviour (MutationObserver watching `[class*=
* "recharts-tooltip-wrapper"]` visibility flips) lives behind a guard
* that no-ops when `document` is undefined — which is the case in the
* default vitest node environment. These tests pin the SSR-safe path
* and the subscribe/snapshot contract that `useSyncExternalStore`
* leans on.
*
* The integration with a real Recharts tooltip is exercised via the
* Playwright suite (mobile insight chart hover → FAB fades).
*/
describe("chart-tooltip-observer", () => {
beforeEach(() => {
__resetChartTooltipObserverForTests();
});
afterEach(() => {
__resetChartTooltipObserverForTests();
});
it("getChartTooltipActiveServer always returns false", () => {
// The SSR snapshot must never claim a tooltip is active on the
// server — there's no DOM to inspect, and a stale `true` would
// render the FAB invisible on the initial pass.
expect(getChartTooltipActiveServer()).toBe(false);
});
it("getChartTooltipActive returns false when no wrappers exist", () => {
expect(getChartTooltipActive()).toBe(false);
});
it("subscribeChartTooltipActive returns an unsubscribe function", () => {
const unsubscribe = subscribeChartTooltipActive(() => undefined);
expect(typeof unsubscribe).toBe("function");
unsubscribe();
});
it("no-ops cleanly on a server-side render (no document available)", () => {
// Document is undefined in the vitest node environment — the
// module's `start()` early-returns. Subscribing should still
// succeed; the listener simply never fires.
let calls = 0;
const unsubscribe = subscribeChartTooltipActive(() => {
calls += 1;
});
expect(getChartTooltipActive()).toBe(false);
expect(calls).toBe(0);
unsubscribe();
});
it("supports multiple subscribers without throwing", () => {
const a = subscribeChartTooltipActive(() => undefined);
const b = subscribeChartTooltipActive(() => undefined);
expect(getChartTooltipActive()).toBe(false);
a();
b();
});
});
@@ -81,7 +81,7 @@ describe("<InsightStatusCard>", () => {
text="ignored when hasProvider is false"
/>,
);
expect(html).toContain("AI provider not configured.");
expect(html).toContain("Assessment unavailable.");
expect(html).not.toContain("ignored when hasProvider is false");
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { LayoutCoachFab } from "../layout-coach-fab";
/**
* v1.4.33 (F15) — layout-level Coach FAB contract.
*
* The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid
* Recharts tooltips when the user tapped a data point in the chart's
* lower-right corner — the maintainer flagged this as F15 in the
* runtime audit. The fix wires the FAB to `useChartTooltipActive()`
* which reads a singleton MutationObserver of every
* `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB
* fades and drops `pointer-events`.
*
* Project convention is SSR-only tests (no `@testing-library/react`),
* so we pin the initial-paint state — no tooltip can be active before
* hydration, so the FAB renders without the active-flag attribute
* and stays interactive on first paint. The end-to-end auto-hide
* behaviour is exercised in the Playwright mobile suite.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<LayoutCoachFab>", () => {
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
// The hook returns `null` outside the provider; the FAB then
// renders nothing rather than crashing — same posture as
// `<CoachLaunchButton>`.
const html = render(<LayoutCoachFab />);
expect(html).not.toContain('data-slot="coach-launch-fab"');
});
it("mounts the FAB with the launch-fab slot under the provider", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toMatch(/data-slot="coach-launch-fab"/);
});
it("renders without the chart-tooltip-active flag on first paint", () => {
// SSR snapshot of `useChartTooltipActive()` is `false` so the
// FAB stays interactive on initial render. The active classes
// (opacity-0 + pointer-events-none) are applied client-side only
// once a Recharts tooltip wrapper flips its inline visibility.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
// The data attribute is `undefined` when inactive — React drops
// it from the DOM so the rendered markup never carries it.
expect(html).not.toContain('data-chart-tooltip-active="true"');
// The shadcn `<Button>` primitive bakes in
// `disabled:pointer-events-none` and `[&_svg]:pointer-events-none`,
// so we look for the unprefixed token that the fade-out adds.
// Match it as a class atom (preceded by space/start, followed by
// space/end-of-class-list/quote) so the primitive's prefixed
// variants can't false-match.
expect(html).not.toMatch(/(?:^|["\s])pointer-events-none(?=["\s])/);
expect(html).not.toContain("opacity-0");
});
it("carries the transition class so the fade is animated", () => {
// The fade-out + fade-in shouldn't pop visually — the FAB
// animates opacity over 150ms.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("transition-opacity");
expect(html).toContain("duration-150");
});
it("hides the FAB above lg via `lg:hidden`", () => {
// The desktop Coach surface is the inline pill; the FAB stays
// mobile-only.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("lg:hidden");
});
it("renders the localised label in German", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
"de",
);
expect(html).toContain("Coach fragen");
});
});
@@ -1,73 +0,0 @@
"use client";
import { Sparkles } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.31 — Small inline notice surfaced when an assistant surface
* is gated off by the operator. Used by callers that already
* resolved a `errorCode: "assistant.disabled.<surface>"` response
* from the server or that detected the matching flag locally via
* `useFeatureFlags()`.
*
* The copy intentionally stays generic — "currently unavailable on
* this server" — so the surface doesn't leak operator-identity
* info into a user-facing string. Marc-voice English; the locale
* key carries the surface-specific phrasing.
*/
export type AssistantDisabledSurface =
| "coach"
| "briefing"
| "insightStatus"
| "correlations";
interface AssistantDisabledNoticeProps {
surface: AssistantDisabledSurface;
className?: string;
variant?: "card" | "inline";
}
const SURFACE_LABEL_KEY: Record<AssistantDisabledSurface, string> = {
coach: "insights.coach.disabledByOperator",
briefing: "insights.briefingDisabledByOperator",
insightStatus: "insights.statusDisabledByOperator",
correlations: "insights.correlationsDisabledByOperator",
};
export function AssistantDisabledNotice({
surface,
className,
variant = "card",
}: AssistantDisabledNoticeProps) {
const { t } = useTranslations();
const message = t(SURFACE_LABEL_KEY[surface]);
if (variant === "inline") {
return (
<p
data-slot="assistant-disabled-notice"
className={cn("text-muted-foreground text-sm", className)}
>
{message}
</p>
);
}
return (
<Card
data-slot="assistant-disabled-notice"
className={cn("opacity-70", className)}
>
<CardContent className="flex items-start gap-3 py-4">
<Sparkles
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="text-muted-foreground text-sm">{message}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,181 @@
/**
* v1.4.33 (F15) — chart-tooltip active observer.
*
* The mobile Coach FAB at `bottom-right` of every routed `/insights`
* sub-page used to overlay the Recharts tooltip bubble when a user
* tapped a data point near the bottom-right of the chart. We can't
* route a `tooltipActive` boolean directly from the chart to the FAB
* because the chart sits inside the routed page subtree while the FAB
* is mounted at the layout level — and the chart component itself is
* out of this work-item's touch surface.
*
* Instead, listen at the DOM level. Recharts paints every active
* tooltip into a div whose class always carries `recharts-tooltip-
* wrapper`. The `<TooltipBoundingBox>` upstream component flips the
* inline `visibility` from `hidden` (idle) to `visible` (cursor over
* a point or long-press on touch). A single shared `MutationObserver`
* watches every such wrapper for `style`-attribute changes and feeds
* a tiny external store; consumers subscribe via `useSyncExternalStore`
* to avoid prop-drilling or react-context coupling between unrelated
* subtrees.
*
* The store is module-scoped so the listener stays a singleton — the
* observer connects on the first subscriber and disconnects on the
* last unmount. SSR-safe: every browser API touch is gated on
* `typeof document`.
*/
let activeCount = 0;
const listeners = new Set<() => void>();
let observer: MutationObserver | null = null;
let listening = false;
// Track wrappers we've already seen so we don't double-count their
// visibility transitions. A `WeakSet` lets the GC reclaim removed
// wrapper elements naturally.
const knownWrappers = new WeakSet<HTMLElement>();
function isVisible(wrapper: HTMLElement): boolean {
// Recharts surfaces `visibility: visible` via inline style when the
// tooltip should paint, and clears it otherwise. We mirror that exact
// contract — `getComputedStyle()` is not required because the inline
// style is always set.
return wrapper.style.visibility === "visible";
}
function emit() {
for (const fn of listeners) fn();
}
function syncWrapperState(wrapper: HTMLElement, nextVisible: boolean) {
const flagged = wrapper.dataset.coachTooltipActive === "1";
if (nextVisible && !flagged) {
wrapper.dataset.coachTooltipActive = "1";
activeCount += 1;
emit();
} else if (!nextVisible && flagged) {
delete wrapper.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
function adoptWrapper(wrapper: HTMLElement) {
if (knownWrappers.has(wrapper)) return;
knownWrappers.add(wrapper);
// Seed the active-count with whatever state the wrapper carried on
// first sight so a tooltip already painted before our observer
// attached still counts.
syncWrapperState(wrapper, isVisible(wrapper));
}
function scanForWrappers(root: ParentNode) {
// `querySelectorAll` returns every existing wrapper inside the
// mutated subtree on every mutation — cheap because the DOM
// typically holds 0–1 wrappers at a time.
const wrappers = root.querySelectorAll<HTMLElement>(
'[class*="recharts-tooltip-wrapper"]',
);
for (const wrapper of wrappers) adoptWrapper(wrapper);
}
function handleMutations(mutations: MutationRecord[]) {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches?.('[class*="recharts-tooltip-wrapper"]')) {
adoptWrapper(node);
} else {
scanForWrappers(node);
}
}
for (const node of mutation.removedNodes) {
if (!(node instanceof HTMLElement)) continue;
// Removed wrappers cannot contribute to active count anymore;
// drop the flag so a re-added wrapper re-counts cleanly.
if (node.dataset?.coachTooltipActive === "1") {
delete node.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
} else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) {
const target = mutation.target;
if (target.matches('[class*="recharts-tooltip-wrapper"]')) {
syncWrapperState(target, isVisible(target));
}
}
}
}
function start() {
if (listening) return;
if (typeof document === "undefined") return;
listening = true;
observer = new MutationObserver(handleMutations);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
});
// Seed with wrappers that already exist in the DOM at subscribe-time.
scanForWrappers(document.body);
}
function stop() {
if (!listening) return;
listening = false;
observer?.disconnect();
observer = null;
// Reset the counter — re-seeding on the next subscriber re-scans
// the DOM anyway.
activeCount = 0;
}
/**
* Subscribe-API used by `useSyncExternalStore`. Connects the singleton
* observer on the first subscriber and disconnects on the last.
*/
export function subscribeChartTooltipActive(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) start();
return () => {
listeners.delete(listener);
if (listeners.size === 0) stop();
};
}
/**
* Snapshot getter for `useSyncExternalStore`. Returns `true` while at
* least one Recharts tooltip wrapper is currently visible.
*/
export function getChartTooltipActive(): boolean {
return activeCount > 0;
}
/**
* SSR snapshot — no chart tooltip can be active on the server, so we
* always return `false`. Keeps the hook safe on the initial render
* before hydration.
*/
export function getChartTooltipActiveServer(): boolean {
return false;
}
/**
* Test-only reset hook. Vitest tears the DOM down between tests but
* the module-scoped counter survives the teardown; exposing a reset
* keeps cross-test state from bleeding.
*/
export function __resetChartTooltipObserverForTests(): void {
activeCount = 0;
listeners.clear();
if (observer) {
observer.disconnect();
observer = null;
}
listening = false;
}
@@ -66,6 +66,21 @@ describe("<HistoryRail>", () => {
expect(html).toContain("Conversations");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `lg+` desktop where no `SheetTitle`
// wrapper covers it. Without a real heading element the drawer
// lost its semantic outline above the message thread — fixed in
// v1.4.33 by promoting the rail label to `<h3>`.
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-history-rail-heading"[^>]*>[\s\S]*Conversations[\s\S]*<\/h3>/,
);
});
it("renders one row per conversation", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
@@ -190,7 +190,10 @@ describe("<MessageThread>", () => {
}}
/>,
);
expect(html).toContain("could not reach an AI provider");
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
// provider" per the Marc-Voice rule (no "KI"/"AI" prefix in
// user-facing strings).
expect(html).toContain("could not reach an Insights provider");
});
it("hides the streaming bubble once its persisted twin lands by id", () => {
@@ -449,7 +452,10 @@ describe("<MessageThread>", () => {
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
expect(html).not.toContain("could not reach an AI provider");
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
// provider"). Both old and new strings should be absent here when
// the budget-exceeded error takes precedence.
expect(html).not.toContain("could not reach an Insights provider");
});
it("surfaces the provider rate-limit copy for coach.provider.rate_limited", () => {
@@ -17,6 +17,17 @@ describe("<SourcesRail>", () => {
expect(html).toContain("What I can see");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `xl+` desktop where no `SheetTitle`
// wrapper covers it. Promoting the label to `<h3>` restores the
// drawer's semantic outline so screen-reader users can navigate
// by heading.
const html = render(<SourcesRail />);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-sources-rail-heading"[^>]*>[\s\S]*What I can see[\s\S]*<\/h3>/,
);
});
it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => {
const html = render(<SourcesRail />);
const rows = html.match(/data-slot="coach-sources-row"/g) ?? [];
@@ -69,15 +69,23 @@ export function HistoryRail({
data-slot="coach-history-rail"
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
where the rail is mounted inline (no `SheetTitle` wrapper).
The mobile rail-tray still wraps the rail inside its own
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</span>
</div>
{t("insights.coach.historyTitle")}
</h3>
<div className="relative">
<Search
aria-hidden="true"
@@ -172,12 +172,20 @@ export function SourcesRail({
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the rail carries a semantic outline on desktop
where it mounts inline next to the message thread. The
mobile rail-tray wraps the rail inside its own `SheetTitle`
(an `<h2>`), so the rail's own `<h3>` simply nests beneath
it — the heading hierarchy stays consistent across
viewports. */}
<h3
data-slot="coach-sources-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
</div>
{t("insights.coach.sourcesTitle")}
</h3>
{/* Window selector — every row below is filtered to the picked
window when the next Coach message goes out. 36px touch
@@ -4,11 +4,12 @@ import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
@@ -21,19 +22,16 @@ import type { DataSummary } from "@/lib/analytics/trends";
* same cache entry without extra network traffic.
*
* v1.4.27 F19 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. Both queries share their
* cache keys with the sub-page consumers (`["analytics"]` and
* `["insights", "comprehensive"]`) so the cost stays one fetch per
* route, dedup-shared across consumers.
* power the tab-strip availability gate. The analytics consumer goes
* through the shared `useAnalyticsQuery({ slice: "summaries" })` hook
* (v1.4.33 IW2); the comprehensive consumer still owns its bespoke
* fetch but shares its cache key with the sub-page consumers so the
* payload lands once per route, dedup-shared across consumers.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface AnalyticsPayload {
summaries?: Record<string, DataSummary>;
}
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
@@ -41,20 +39,28 @@ interface ComprehensivePayload {
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
// feature flag. Pre-fix, every /insights mount fired POST
// /api/insights/generate even when the operator had disabled the
// briefing surface or the user had no AI provider configured. The
// server returned 422 in that case and the regenerate button on the
// tab strip rendered a non-functional spinner. Reading the flag
// matrix off `/api/feature-flags` keeps the hot path one fetch
// (shared `["feature-flags"]` cache, 60s staleTime) and skips the
// advisor request entirely when the operator has the briefing gate
// off. The fail-open default in `useFeatureFlags` means a network
// hiccup still renders the advisor — the gate only takes effect when
// the operator has explicitly turned the surface off.
const flags = useFeatureFlags();
const advisorEnabled =
isAuthenticated && flags.enabled && flags.briefing;
const advisor = useInsightsAdvisorQuery(advisorEnabled);
// Shared analytics fetch — sub-pages consume the same cache key.
const analyticsQuery = 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 AnalyticsPayload;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
// it lands on IW1's slim `?slice=summaries` branch (2 SQL passes,
// no correlations / health-score / bp-in-target tail).
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
@@ -96,7 +102,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
return (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
availability={availability}
/>
+25 -16
View File
@@ -80,31 +80,20 @@ interface TabEntry {
* stay in sync from a single source of truth — adding a sub-page is
* one row.
*/
// v1.4.33 F9 — label + gating-metric for every routed sub-page. Pill
// ORDER is owned by `SUB_PAGE_SLUGS` (which iterates the keys of
// `SUB_PAGE_METRIC`) so adding a new metric is one row in
// `sub-page-metric.ts` and the strip picks it up automatically.
const SUB_PAGE_TABS: Record<
SubPageSlug,
{ labelKey: string; metric: InsightMetric }
> = {
// ── vitals ──
blutdruck: {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// v1.4.32 — workouts pill. Gate is event-driven; the
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
// v1.4.32 — five wave-A HealthKit pills. Each one reads its own
// MeasurementType count from the analytics summaries.
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
ruhepuls: {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
sauerstoff: {
labelKey: "insights.navOxygenSaturation",
metric: "OXYGEN_SATURATION",
@@ -113,10 +102,30 @@ const SUB_PAGE_TABS: Record<
labelKey: "insights.navBodyTemperature",
metric: "BODY_TEMPERATURE",
},
// ── body composition ──
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
// ── activity ──
"aktive-energie": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
},
// v1.4.32 — workouts pill. Gate is event-driven; the
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
// ── sleep ──
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// ── cardiovascular ──
ruhepuls: {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
// ── mood ──
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
// ── events ──
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
@@ -8,6 +8,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useChartTooltipActive } from "./use-chart-tooltip-active";
/**
* Layout-level mobile Coach launch FAB.
*
@@ -22,11 +24,20 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
* Hidden on `lg+` viewports — desktop users reach the Coach via the
* inline `<CoachLaunchButton>` pill that each page mounts in its
* action row.
*
* v1.4.33 (F15) — the FAB sits at `bottom-right` and used to overlay
* Recharts tooltips on the bottom-right of any insight chart, so a
* mobile user tapping a data point near the chart's lower-right
* couldn't read the bubble. The button now auto-hides while a chart
* tooltip is active (the wrapper's `visibility: visible` flips us
* to fade-out + pointer-events-none). When the user releases / scrolls
* away the tooltip clears and the FAB fades back in.
*/
export function LayoutCoachFab() {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const tooltipActive = useChartTooltipActive();
if (!launch) return null;
// v1.4.31 — operator can hide the Coach surface app-wide. The
// FAB returns null when the master OR the coach sub-flag is off.
@@ -39,14 +50,28 @@ export function LayoutCoachFab() {
type="button"
size="lg"
data-slot="coach-launch-fab"
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
onClick={() => launch.askCoach(null)}
aria-label={accessibleLabel}
title={accessibleLabel}
// v1.4.33 (F15) — `aria-hidden` + `tabIndex={-1}` while a chart
// tooltip is painted so screen-reader users + keyboard users
// don't trip over a button that's visually faded; pointer-events
// are gone, so a tap can't land on the invisible FAB either.
aria-hidden={tooltipActive ? true : undefined}
tabIndex={tooltipActive ? -1 : undefined}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
// v1.4.33 (F15) — fade out while a Recharts tooltip is open.
// 150 ms transition matches the rest of the Coach drawer's
// fade timing; `motion-reduce:transition-none` honours user
// preference. `pointer-events-none` keeps the faded FAB from
// intercepting taps that should reach the tooltip behind.
"transition-opacity duration-150 motion-reduce:transition-none",
tooltipActive && "pointer-events-none opacity-0",
)}
>
<Sparkles className="size-4" aria-hidden="true" />
+9 -11
View File
@@ -1,12 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
@@ -67,16 +67,14 @@ export function SleepOverview() {
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data, isLoading } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed to load analytics");
const json = await res.json();
return json.data as SleepAnalyticsResponse;
},
enabled: isAuthenticated,
});
// v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only
// field) alongside `summaries.SLEEP_DURATION`, 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 consumer behaves identically to every other analytics mount.
const analyticsQuery = useAnalyticsQuery();
const data = analyticsQuery.data as SleepAnalyticsResponse | undefined;
const isLoading = analyticsQuery.isLoading;
const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null;
const sleepStages = data?.sleepStages ?? null;
+10 -12
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
/**
@@ -63,21 +64,18 @@ export function SubPageShell({
// CF-35 made the focus call opt-in via `focusOnMount` — the
// default-on version stole focus from soft-keyboards on mobile.
//
// v1.4.28 FB-D3 — defer the `scrollTo` to the next animation frame
// so the sub-page's first paint settles before the scroll lands.
// The legacy unconditional in-effect scroll fired before the chart
// skeleton had a height; the viewport snapped to 0, then the chart
// popped in and the scroll position visibly jumped again. Wrapping
// in `requestAnimationFrame` runs the scroll after the layout pass
// so the user sees one smooth landing.
// v1.4.33 IW9 — the scroll-to-top affordance moved into the shared
// `useScrollResetOnRoute()` hook so the mother page + sub-page can't
// both fire (which produced a visible double-snap when the chart
// skeleton inflated between the two RAF callbacks). The hook itself
// still defers to `requestAnimationFrame` so the scroll lands after
// first paint, same behaviour the legacy in-line effect provided.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useScrollResetOnRoute();
useEffect(() => {
if (typeof window === "undefined") return;
if (!focusOnMount || typeof window === "undefined") return;
const handle = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "auto" });
if (focusOnMount) {
headingRef.current?.focus({ preventScroll: true });
}
headingRef.current?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(handle);
}, [focusOnMount]);
+7 -2
View File
@@ -111,9 +111,14 @@ export function TherapyTimeline({ limit }: TherapyTimelineProps) {
{/* v1.4.27 B7 / L4 — sr-only drug-name heading per
entry so screen-reader users can jump between
medication blocks; the visible row keeps the
inline <strong> rendering. */}
inline <strong> rendering.
v1.4.33 IW9 — promoted from h4 -> h2 so the page
outline stays sequential. The parent SubPageShell
ships an <h1>; CardTitle is a <div>, so the timeline
is the only heading-bearing surface here and h2
nests directly under h1 without skipping levels. */}
{entry.medicationName && (
<h4 className="sr-only">{entry.medicationName}</h4>
<h2 className="sr-only">{entry.medicationName}</h2>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateWithWeekday(new Date(entry.date))}
+1 -1
View File
@@ -123,7 +123,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
yAxisUnit="mmHg"
mini
userTimezone={userTimezone}
/>
@@ -0,0 +1,27 @@
"use client";
import { useSyncExternalStore } from "react";
import {
getChartTooltipActive,
getChartTooltipActiveServer,
subscribeChartTooltipActive,
} from "./chart-tooltip-observer";
/**
* v1.4.33 (F15) — read the live "is a Recharts tooltip painted right
* now" boolean. Backed by a singleton `MutationObserver` that watches
* every `.recharts-tooltip-wrapper` on the page for inline-style
* `visibility` changes; see `chart-tooltip-observer.ts` for the
* full contract.
*
* Returns `false` on the server so the FAB renders fully on the SSR
* pass; the client takes over after hydration.
*/
export function useChartTooltipActive(): boolean {
return useSyncExternalStore(
subscribeChartTooltipActive,
getChartTooltipActive,
getChartTooltipActiveServer,
);
}
+10 -1
View File
@@ -154,7 +154,16 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
id="main-content"
className="flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
<div className="mx-auto max-w-[76.8rem] px-4 py-6 md:px-6">
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
(1280 px) so dashboard / settings / admin / bugreport all
hit the same content frame. Pre-v1.4.33 the dashboard
shell capped at `max-w-[76.8rem]` (1228 px) while the
settings/admin shells used `max-w-screen-xl`, producing a
52 px lateral wobble on every route switch. Same audit
note in `.planning/round-v1433-audit-polish.md` §4.2.
*/}
<div className="mx-auto max-w-screen-xl px-4 py-6 md:px-6">
{children}
</div>
</main>
+16 -1
View File
@@ -68,9 +68,24 @@ export function BottomNav() {
return (
<>
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
never overlap the last viewport line of scrollable page content.
- `min-h-16` floors the bar at 64px even if the inner flex row
collapses (e.g. a future variant without one of the icons).
- `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator
inset on iOS / iPadOS PWA installs.
- `bg-card` (solid, not the previous `bg-card/80`) plus
`border-t` give the bar a clean visual edge so any page
content that scrolls under the bar is properly occluded
rather than bleeding through a translucent surface. Page-
level pb-* padding owners (auth-shell + per-page shells)
still reserve room above the bar; this change hardens the
bar itself so the visual contract holds even when a page
shell forgets the bottom-padding contract.
*/}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY.map((item) => {
+21 -7
View File
@@ -8,6 +8,7 @@ import {
ChevronsLeft,
ChevronsRight,
Home,
Info,
Lightbulb,
LogOut,
Monitor,
@@ -143,6 +144,17 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" used to live as its own Settings
section but the three small cards inside (identity / links /
update check) collectively get read once or twice a year, so
the top-level slot has been collapsed into this dropdown. The
route stays alive at `/settings/about`. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
@@ -341,13 +353,15 @@ export function SidebarNav() {
{renderCollapseToggle()}
</div>
) : (
<div className="relative mb-1">
<p className="text-foreground/75 px-3 text-xs font-semibold tracking-wider uppercase">
{t("nav.home")}
</p>
<div className="absolute top-1/2 -right-1 -translate-y-1/2">
{renderCollapseToggle()}
</div>
// v1.4.33 IW7 — drop the "Home" group label. The sidebar
// has exactly one nav group, and the first entry is
// already "Dashboard" pointing to `/`, so the previous
// "HOME / Dashboard" pairing read as if there were two
// separate destinations for the homepage. The collapse
// toggle stays anchored to the top-right of the strip so
// the visual rhythm of the sidebar header is unchanged.
<div className="relative mb-1 flex h-5 items-center justify-end">
{renderCollapseToggle()}
</div>
)}
<div className="space-y-1">
+10
View File
@@ -3,6 +3,7 @@
import {
Bell,
ChevronDown,
Info,
LogIn,
LogOut,
Monitor,
@@ -82,6 +83,15 @@ export function TopBar() {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" folded out of Settings into the
user-card dropdown; mobile dropdown mirrors the
desktop sidebar so the entry shows up here too. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
@@ -348,7 +348,12 @@ export function Glp1MedicationCard({
</span>
<span className="font-medium">{rate7}%</span>
</div>
<Progress value={rate7} className="h-2" />
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
@@ -357,7 +362,11 @@ export function Glp1MedicationCard({
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress value={rate30} className="h-2" />
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
{streak > 0 && (
<div className="flex items-center gap-4 text-xs">
+11 -2
View File
@@ -533,7 +533,12 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
</span>
<span className="font-medium">{rate7}%</span>
</div>
<Progress value={rate7} className="h-2" />
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
@@ -543,7 +548,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress value={rate30} className="h-2" />
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
<div className="flex items-center gap-4 text-xs">
@@ -1,24 +1,62 @@
"use client";
import { useEffect, useRef } from "react";
import { useReportWebVitals } from "next/web-vitals";
/**
* v1.4.28 R3d — client-side mount that pipes every Web Vitals
* measurement to `/api/internal/web-vitals`.
* v1.4.28 R3d — client-side mount that pipes Web Vitals measurements
* to `/api/internal/web-vitals`.
*
* Next 16 ships `useReportWebVitals` as the supported hook for
* capturing CLS / FCP / FID / LCP / TTFB / INP from the live
* navigation. The hook fires once per metric per page load, so even a
* heavy navigation produces at most six beacons.
* navigation. The hook fires once per metric per page load, so a
* heavy navigation can produce up to six beacons per route change.
*
* v1.4.33 F19 — the reporter previously posted EVERY measurement on
* EVERY page-load. In production the runtime audit captured
* `POST /api/internal/web-vitals 429` for almost every metric after
* the first navigation: six beacons × N navigations burned through
* the per-IP 60/min route-side rate-limit in seconds, so most samples
* were dropped before they reached the wide-event sink. Telemetry
* was self-throttled.
*
* Switch to client-side sampling at SAMPLE_RATE: each page-load
* decides ONCE (memoised via `useMemo`) whether to report and either
* sends every metric for that load or sends none. Per-load all-or-
* none keeps a sampled session's vitals coherent (the LCP from one
* page must be comparable to the CLS from the same page) while
* cutting traffic by an order of magnitude. The route-side rate-limit
* stays in place as a backstop against runaway sampling.
*
* Delivery transport: `navigator.sendBeacon` when available (queues
* the POST without blocking the page lifecycle, survives the
* `pagehide` event) with a `fetch({ keepalive: true })` fallback.
* The route accepts the payload best-effort and returns 200 even on
* The route accepts the payload best-effort and returns 204 even on
* malformed bodies — beacons must never delay or fail the navigation.
*/
const SAMPLE_RATE = 0.1;
export function WebVitalsReporter() {
// Decide once per mount whether this page-load reports its vitals.
// Math.random() is impure, so we deferred the draw to `useEffect`
// (React's purity contract forbids running impure work inside
// useMemo / the render body). The ref keeps the decision stable
// across every metric callback inside the same navigation —
// partial sampling would mix LCP-without-CLS noise into the wide-
// event aggregates. SSR initialises the ref to `null` (no sample
// decision yet); the first reportWebVitals callback fires post-
// hydration once the effect has filled the slot.
const sampledRef = useRef<boolean | null>(null);
useEffect(() => {
sampledRef.current = Math.random() < SAMPLE_RATE;
}, []);
useReportWebVitals((metric) => {
// If the effect hasn't yet seeded the sample decision (very first
// pre-hydration measurement), drop the beacon — over-sampling at
// first paint would defeat the rate-limit relief.
if (sampledRef.current !== true) return;
const body = JSON.stringify({
id: metric.id,
name: metric.name,
@@ -46,8 +46,10 @@ describe("<OnboardingTour>", () => {
it("exposes a skip control with an accessible label", () => {
const html = renderTour();
// The backdrop button doubles as a Skip control with aria-label,
// and the explicit footer Skip button.
// After v1.4.33 F2 the dim panels are purely visual
// (pointer-events: none) so skip lives entirely on the explicit
// tooltip-footer Skip button. The visible label still has to be
// there — both for sighted users and for screen readers.
expect(html).toContain("Skip tour");
});
@@ -18,6 +18,7 @@ import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import {
buildChecklist,
checklistProgress,
@@ -184,25 +185,18 @@ export function GettingStartedChecklist() {
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together. Critically, every queryFn here
// must return `json.data` (the unwrapped shape) so it matches the
// canonical consumer (dashboard / medications page / notifications
// page / integrations section). Returning `res.json()` would write
// the full `{data, error}` envelope into the shared cache, which then
// makes the canonical consumers' `data?.summaries` / `data?.length`
// reads silently return undefined — that's how the v1.4.2 dashboard
// ended up rendering only the mood tile.
const analyticsQuery = useQuery<AnalyticsData>({
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;
},
// and the checklist mount together. v1.4.33 IW2 routes the checklist
// onto IW1's slim `?slice=summaries` branch — the only field
// consumed is `summaries[METRIC].count`, which the slim slice fills
// directly. The cache slot is distinct from the dashboard's thick
// slice but each consumer's payload is byte-correct for its own
// reads, and the shared hook keeps the legacy `?.data` unwrap so the
// canonical consumers still see `{summaries, …}` not `{data: …}`.
const analyticsQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !!user,
});
const analyticsData = analyticsQuery.data;
const analyticsData = analyticsQuery.data as AnalyticsData | undefined;
const { data: medsData } = useQuery<Array<{ id: string }>>({
queryKey: ["medications"],
+122 -29
View File
@@ -338,13 +338,68 @@ export function OnboardingTour({
height: TOOLTIP_HEIGHT,
});
// Build a clip-path that punches out the spotlight rect from the
// backdrop. `evenodd` fill rule + the outer rect winding the same
// way means the inner rect carves a hole. If `rect` is null we
// skip the cutout and render a solid backdrop.
const clipPath = rect
? `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${rect.top}px, ${rect.left}px ${rect.top}px, ${rect.left}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top}px, 0 ${rect.top}px)`
: undefined;
// v1.4.33 F2 — onboarding overlay was a single full-viewport `<button>`
// with a `clip-path` punching a hole around the spotlight. The clip-path
// only changed PAINT — the button's hit-box still covered every pixel,
// so every click on the underlying page (Hinzufügen, sidebar links,
// header avatar) hit the dim layer and triggered Skip without forwarding
// the click to the real target. New users were effectively locked out.
//
// Initial fix (f9b8f3bd) split the dim into four rectangles around the
// spotlight with `pointer-events: auto` + an `onClick={handleSkip}` on
// each. That made the spotlight region click-through but kept blocking
// every other interactive element on the page (the header sits ABOVE
// the tile-strip spotlight, so the top dim strip still ate the click on
// the Hinzufügen dropdown — the very target the F2 audit named).
//
// The right contract: the dim is purely VISUAL. The whole tour layer is
// `pointer-events: none`, and only the tooltip card opts back into hit
// testing. Skip lives where users expect it — the explicit "Skip tour"
// button in the tooltip footer. The page underneath stays fully usable,
// matching the spotlight-tour conventions used by Joyride, Shepherd,
// and Intro.js. The four-panel split survives because it still serves
// the visual purpose of NOT dimming the spotlighted target, but the
// panels are now non-interactive `<div>`s without their own click
// handlers.
const backdropClass =
"pointer-events-none absolute bg-black/70 transition-opacity duration-150 motion-reduce:transition-none";
const vw = typeof window !== "undefined" ? window.innerWidth : 0;
const vh = typeof window !== "undefined" ? window.innerHeight : 0;
const dimRects = rect
? [
// Top strip — full width above spotlight.
rect.top > 0
? { top: 0, left: 0, width: vw, height: rect.top }
: null,
// Bottom strip — full width below spotlight.
rect.top + rect.height < vh
? {
top: rect.top + rect.height,
left: 0,
width: vw,
height: vh - (rect.top + rect.height),
}
: null,
// Left strip — only the band beside the spotlight.
rect.left > 0
? {
top: rect.top,
left: 0,
width: rect.left,
height: rect.height,
}
: null,
// Right strip — only the band beside the spotlight.
rect.left + rect.width < vw
? {
top: rect.top,
left: rect.left + rect.width,
width: vw - (rect.left + rect.width),
height: rect.height,
}
: null,
].filter((r): r is { top: number; left: number; width: number; height: number } => r !== null)
: null;
return (
<div
@@ -352,24 +407,61 @@ export function OnboardingTour({
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-tour-title"
className="fixed inset-0 z-[200]"
// `pointer-events-none` on the root: the outer container does NOT
// capture clicks. Only the tooltip card opts back in via
// `pointer-events-auto` below. This guarantees that every pixel of
// the underlying page — both inside AND outside the spotlight —
// passes pointer events through normally; the tour is purely a
// visual overlay with its own self-contained controls.
className="pointer-events-none fixed inset-0 z-[200]"
>
{/* Backdrop with cutout. `pointer-events-auto` so a click on
the dimmed area dismisses the tour (treated as Skip). The
cutout itself uses `pointer-events-none` (transparent click
pass-through) on the dimmed region — but to keep the click
contract simple we use a single backdrop with the polygon
clip and let any backdrop click count as Skip. The actual
target underneath stays inert (the tour intentionally takes
focus). prefers-reduced-motion users get an instant overlay
(no opacity transition). */}
<button
type="button"
aria-label={t("onboarding.tour.skip")}
className="focus-visible:ring-primary absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none"
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
onClick={handleSkip}
/>
{/* Spotlight outline — purely visual ring around the highlighted
target. */}
{rect ? (
<div
aria-hidden="true"
data-testid="onboarding-tour-spotlight"
className="ring-primary/80 pointer-events-none absolute rounded-lg ring-2 ring-offset-2 ring-offset-transparent"
style={{
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
) : null}
{/* Dim panels — purely visual. Each panel sits OUTSIDE the spotlight
rect so the spotlighted target keeps its full brightness. The
panels are `pointer-events-none` so the page underneath stays
fully clickable: the user can drive the Hinzufügen dropdown,
sidebar links, or header avatar mid-tour and only the
tooltip-footer Skip / Back / Next buttons drive tour state. */}
{dimRects
? dimRects.map((r, idx) => (
<div
key={idx}
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={backdropClass}
style={{
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
}}
/>
))
: (
// Center-placement fallback — no spotlight target available.
// Render a single full-cover dim layer. Still purely visual:
// skip / back / next live on the tooltip itself.
<div
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={`${backdropClass} inset-0`}
/>
)}
{/* Polite live region — announces the current step to screen readers. */}
<p className="sr-only" aria-live="polite">
@@ -381,20 +473,21 @@ export function OnboardingTour({
{t(stop.titleKey)}
</p>
{/* Tooltip card */}
{/* Tooltip card — the only interactive surface in the tour.
`pointer-events-auto` opts back into click capture against the
root's `pointer-events-none`, so the card's buttons (Skip /
Back / Next) work while every other pixel of the overlay
passes clicks through to the underlying page. */}
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
className="bg-card border-border pointer-events-auto absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${TOOLTIP_WIDTH}px`,
}}
// Stop click propagation so a click inside the card doesn't
// bubble to the backdrop's Skip handler.
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-start justify-between gap-3">
<div>
@@ -186,9 +186,13 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
const html = render("de");
expect(html).toContain("Mit ChatGPT verbinden");
expect(html).toContain("ChatGPT-Account (Codex)");
// Heading uses the locale's full string ("Aktiver KI-Provider"); a
// shorter substring would also match the "Aktiver Provider" line
// in the chain summary.
expect(html).toContain("Aktiver KI-Provider");
// v1.4.33 IW7 — the active-provider heading and the parent section
// both ship without the "KI"/"AI" prefix per the Marc-Voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
// chain summary now share the same phrase, so we assert against the
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
// to keep the test pinned to the right surface.
expect(html).toContain("Aktiver Provider");
expect(html).toContain("Primärer Provider");
});
});
@@ -66,7 +66,7 @@ describe("<NotificationStatusCard />", () => {
it("renders empty state when no channels exist", () => {
setChannels([]);
const html = render(<NotificationStatusCard />);
expect(html).toContain("Channel reliability");
expect(html).toContain("Delivery status");
expect(html).toContain("No channels configured yet");
expect(html).not.toContain('data-testid="notification-status-list"');
});
@@ -189,7 +189,7 @@ describe("<NotificationStatusCard />", () => {
},
]);
const html = render(<NotificationStatusCard />, "de");
expect(html).toContain("Kanalzuverlässigkeit");
expect(html).toContain("Zustellstatus");
expect(html).toContain("Automatisch deaktiviert");
expect(html).toContain("Wieder aktivieren");
});
@@ -137,9 +137,13 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.about.");
});
it("<AiSection> renders the AI Insights card", () => {
it("<AiSection> renders the Insights card", () => {
const html = render(<AiSection />);
expect(html).toContain("AI Insights");
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
// per the Marc-Voice rule. The card title is replaced with a sparkles
// icon + status badges, and the section H1 carries the only heading.
expect(html).toContain("Insights");
expect(html).toContain("settings-section-ai-title");
});
it("<IntegrationsSection> renders Withings card title", () => {
@@ -150,7 +154,10 @@ describe("settings sections — SSR smoke", () => {
it("<NotificationsSection> renders heading", () => {
const html = render(<NotificationsSection />);
expect(html).toContain("Notifications");
// v1.4.33 IW7 — section renamed from "Notifications" to
// "Notification channels" so it doesn't collide with the inbox at
// `/notifications` ("Notification Center").
expect(html).toContain("Notification channels");
});
it("<DashboardSection> renders heading and embeds the layout customizer", () => {
@@ -80,9 +80,16 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
describe("<SettingsShell>", () => {
it("renders every section link — once for the mobile strip and once for the desktop sidebar", () => {
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
const html = renderShell({ active: "account" });
for (const slug of SETTINGS_SECTION_SLUGS) {
// v1.4.33 IW7 — the `about` slug is alive as a route
// (`/settings/about` still resolves and `generateStaticParams()`
// still emits the page) but it is hidden from the in-shell nav.
// The user-card dropdown owns the link now. Iterate the visible
// sections list, not the slug list, when asserting on rendered
// markup.
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
for (const slug of navigableSlugs) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
@@ -90,6 +97,8 @@ describe("<SettingsShell>", () => {
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
}
// The hidden `about` slug must NOT appear in the in-shell nav.
expect(html).not.toContain('href="/settings/about"');
});
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
@@ -136,9 +145,12 @@ describe("<SettingsShell>", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).toContain("Account");
expect(html).toContain("Integrations");
expect(html).toContain("Notifications");
// v1.4.33 IW7 — section renamed from "Notifications" to
// "Notification channels" so it doesn't collide with the inbox at
// `/notifications` ("Notification Center").
expect(html).toContain("Notification channels");
expect(html).toContain("Dashboard");
expect(html).toContain("AI Insights");
expect(html).toContain("Insights");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
@@ -146,25 +158,35 @@ describe("<SettingsShell>", () => {
// entry in the sidebar; the link must be present in both locales.
expect(html).toContain('href="/settings/export"');
expect(html).toContain("Advanced");
expect(html).toContain("About");
// v1.4.33 IW7 — About is no longer in the settings nav; it lives
// in the sidebar user-card dropdown. Route `/settings/about` is
// still alive for direct links.
expect(html).not.toContain('href="/settings/about"');
});
it("resolves every section title via the i18n provider — German", () => {
const html = renderShell({ active: "account", locale: "de" });
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
expect(html).toContain("Benachrichtigungen");
// v1.4.33 IW7 — section renamed from "Benachrichtigungen" to
// "Benachrichtigungs-Kanäle" so it doesn't collide with the inbox at
// `/notifications` ("Benachrichtigungs-Center").
expect(html).toContain("Benachrichtigungs-Kanäle");
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
// "Dashboard" (matching the term users see in the main nav). The
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
expect(html).toContain("Persönliche Zielwerte");
expect(html).toContain("KI-Auswertungen");
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
expect(html).toContain("Auswertungen");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
expect(html).toContain("Über");
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
expect(html).not.toContain('href="/settings/about"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
+78
View File
@@ -0,0 +1,78 @@
"use client";
/**
* `<SettingsCardHeader>` — v1.4.33 IW4 polish primitive.
*
* Every Settings card paints the same shape: a primary icon, a title,
* an optional status surface (badge / pill / inline switch), and an
* optional short description below the title. Before this primitive
* the codebase carried five different `flex flex-wrap items-start
* justify-between` permutations across `account-section.tsx`,
* `integrations-section.tsx`, `notification-status-card.tsx`,
* `telegram-card.tsx`, `ntfy-card.tsx`, `web-push-card.tsx`,
* `api-section.tsx`, and `advanced-section.tsx`. The audit's mechanical
* drift report (`.planning/round-v1433-audit-settings.md` §2.5) listed
* one card with `mb-4 flex items-center gap-2`, the next with
* `flex flex-col gap-3 sm:flex-row sm:justify-between`, another with
* `flex flex-wrap items-start justify-between`, etc.
*
* The primitive consolidates the contract:
* - Icon left, title right of it (`gap-2` between them).
* - Optional status slot lives top-right; on `<sm` it falls below the
* title block so the action surface keeps its 44 px tap target.
* - Description is muted text, sits below the title row.
*
* Call sites can still wire their own action surfaces inside the body
* of the card — the primitive only owns the *header* slice, not the
* footer or the form rows.
*/
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SettingsCardHeaderProps {
/** Lucide icon component; rendered as `text-primary h-5 w-5`. */
icon: LucideIcon;
/** Card title — rendered as `<h2 class="text-lg font-semibold">`. */
title: string;
/** Optional id for the `<h2>` so an outer `aria-labelledby`
* attribute on the card itself can reference it. */
titleId?: string;
/** Optional short description rendered as muted text below the
* title row. Either a string (paragraph) or arbitrary node
* (links, badges, etc.). */
description?: React.ReactNode;
/** Optional right-aligned status surface — typically an
* `<IntegrationStatusPill>` or a wrapper around badges. */
status?: React.ReactNode;
/** Optional extra classes applied to the outer wrapper. */
className?: string;
}
export function SettingsCardHeader({
icon: Icon,
title,
titleId,
description,
status,
className,
}: SettingsCardHeaderProps) {
return (
<header className={cn("space-y-1", className)}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
</div>
{status ? <div className="flex shrink-0 items-center gap-2">{status}</div> : null}
</div>
{description ? (
<p className="text-muted-foreground text-xs">{description}</p>
) : null}
</header>
);
}
+23 -7
View File
@@ -351,10 +351,17 @@ export function AccountSection() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
{/* v1.4.33 F13 — `disabled` adds `opacity-50` to the input
primitive, which the maintainer's mobile pass read as
"empty placeholder text" because the username then
matches the muted-foreground colour exactly. Username
changes still aren't allowed, but `readOnly` keeps the
text crisp (full contrast) so it reads as a value, not
a hint. */}
<Input
id="username"
value={user.username}
disabled
readOnly
autoComplete="username"
/>
</div>
@@ -733,7 +740,13 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
table only at `≥ md`, and at `< md` paint a card-list where
every passkey's name, device type, backup status, created
date, and delete action are all visible without scrolling. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border md:block">
{/* v1.4.33 — desktop table flips to card list at `lg` instead
of `md` so iPad portrait (768 px = exactly the `md`
inflection) lands on the card layout. The table needs
~620 px of column width to read; below that it scrolls
horizontally and the destructive delete column is the one
that disappears. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
@@ -817,12 +830,15 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
</table>
</div>
{/* Mobile card list — < md only. Each passkey gets its own
card so the delete action stays visible and tap-targetable
(44px) without the user having to horizontally scroll
through a wide table. */}
{/* Mobile + tablet card list — < lg only. Each passkey gets
its own card so the delete action stays visible and
tap-targetable (44 px) without the user having to
horizontally scroll through a wide table. v1.4.33 bumped
the breakpoint from `md` to `lg` so iPad portrait (768 px)
stays on the card layout instead of flipping between
layouts on rotation. */}
<ul
className="mt-3 space-y-2 md:hidden"
className="mt-3 space-y-2 lg:hidden"
data-testid="passkeys-mobile-list"
>
{passkeys.map((pk) => (
+11 -5
View File
@@ -251,11 +251,17 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
</div>
{/* v1.4.33 IW7 — the dedicated H2 ("KI-Insights") used to repeat
the parent section title ("KI-Auswertungen"). Both are gone now:
the section renders "Auswertungen" once at the top, and this
card carries only the Sparkles icon + the live provider-status
badges, mirroring the description that lives on the section
header. The configuration description is moved one level up
into the section subtitle so the visual hierarchy stays
two-level (section -> form) instead of three-level
(section -> card title -> form). */}
<div className="flex flex-wrap items-center justify-between gap-2">
<Sparkles className="text-primary h-5 w-5" aria-hidden="true" />
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
+1 -1
View File
@@ -122,7 +122,7 @@ function ExportCardShell({
return (
<div
data-testid={testId}
className={`bg-card border-border flex h-full flex-col rounded-xl border p-5${outerClassName ? ` ${outerClassName}` : ""}`}
className={`bg-card border-border flex h-full flex-col rounded-xl border p-6${outerClassName ? ` ${outerClassName}` : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
@@ -753,7 +753,16 @@ function MoodLogCard({
enterKeyHint="done"
/>
</div>
<div className="flex flex-wrap items-start gap-2">
{/* v1.4.33 — save-button placement contract:
right-aligned primary `Speichern` with the secondary
`Verbindung testen` immediately to its left. Mood Log
used to left-align its row, which was the only
integration form that did. */}
<div className="flex flex-wrap items-center justify-end gap-2">
<TestConnectionButton
endpoint="/api/integrations/moodlog/test"
disabled={!status?.configured}
/>
<Button
type="submit"
disabled={saving || (!url.trim() && !apiKey.trim())}
@@ -763,10 +772,6 @@ function MoodLogCard({
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</Button>
<TestConnectionButton
endpoint="/api/integrations/moodlog/test"
disabled={!status?.configured}
/>
</div>
</form>
@@ -777,12 +782,18 @@ function MoodLogCard({
<Label htmlFor="moodlog-webhook-secret">
{t("settings.moodLogWebhookSecret")}
</Label>
<div className="flex gap-2">
{/* v1.4.33 — the webhook secret is a long hex string;
on a 393 CSS px viewport the input + Copy button
used to push past the card edge and trigger a body
horizontal scroll. Stack vertically on `<sm` and
break the input value so it wraps inside its own
box. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
id="moodlog-webhook-secret"
value={status.webhookSecret}
readOnly
className="font-mono text-xs"
className="font-mono text-xs break-all"
/>
<Button
variant="outline"
@@ -792,6 +803,7 @@ function MoodLogCard({
setMsg(t("common.copied"));
setMsgType("success");
}}
className="w-full sm:w-auto"
>
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
common.copy key instead of stripping the
@@ -818,7 +830,14 @@ function MoodLogCard({
</span>
</div>
<div className="flex gap-2">
{/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet
overflowed the card edge on a 393 CSS px viewport
(~376 px of buttons + gaps inside 345 px of inner
width). Match the Withings card pattern: `flex-wrap`
+ `min-w-[10rem]` on `<sm` so the triggers stack two
per row and fill their column cleanly; tablet+ keeps
the inline row. */}
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
@@ -849,7 +868,7 @@ function MoodLogCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.moodLogFullSync")}
{t("settings.moodLogFullSyncConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -40,6 +42,32 @@ export function NotificationsSection() {
aria-labelledby="settings-section-notifications-title"
className="space-y-6"
>
{/* v1.4.33 IW7 — disambiguate the channel-config screen from the
inbox at `/notifications`. Both surfaces used to be named plain
"Notifications", so the crumb spells out where the user is and
offers a one-tap jump to the inbox. */}
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
<li>{t("notifications.breadcrumbSettings")}</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li className="text-foreground font-medium">
{t("notifications.breadcrumbChannels")}
</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li>
<Link
href="/notifications"
className="hover:text-foreground underline-offset-2 hover:underline"
>
{t("notifications.breadcrumbInbox")}
</Link>
</li>
</ol>
</nav>
<header className="space-y-1">
<h1
id="settings-section-notifications-title"
+6 -22
View File
@@ -4,12 +4,12 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Loader2, Send } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
interface NtfySettings {
@@ -101,27 +101,11 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.ntfy")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{settings?.serverUrl && settings?.topic && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{settings?.enabled && (
<Badge variant="outline" className="text-xs">
{t("common.enabled")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ntfyDescription")}
</p>
<SettingsCardHeader
icon={Bell}
title={t("settings.ntfy")}
description={t("settings.ntfyDescription")}
/>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
@@ -54,6 +54,14 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
const sectionTitle = t(`settings.sections.${slug}.title`);
const sectionDescription = t(`settings.sections.${slug}.description`);
// v1.4.33 A4 — `<SectionPlaceholder>` is a defensive fallback for
// slugs added to SETTINGS_SECTION_SLUGS without a matching component.
// The type system blocks that path today, so the EmptyState body is
// effectively unreachable. We retire the dedicated locale key
// (`settings.sections.placeholder.coming_soon`) and inherit the
// section's own description so the guard still renders meaningful
// copy if it ever does fire — no more dead-string maintenance across
// six locale files.
return (
<section
aria-labelledby={`settings-section-${slug}-title`}
@@ -72,7 +80,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
<EmptyState
icon={<Icon className="size-6" />}
title={sectionTitle}
description={t("settings.sections.placeholder.coming_soon")}
description={sectionDescription}
/>
</section>
);
+58 -10
View File
@@ -20,7 +20,6 @@ import { usePathname } from "next/navigation";
import {
Bell,
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
@@ -61,9 +60,19 @@ interface SettingsSection {
}
/**
* Source of truth for the section list. Order matches the in-app navigation
* order so the sidebar, mobile strip, and `generateStaticParams()` all line
* up. Don't reorder without updating tests.
* Source of truth for the section list that the settings shell renders
* in its sidebar + mobile chip-strip. Order matches the in-app
* navigation order. Don't reorder without updating tests.
*
* v1.4.33 IW7 — "About" was folded into the sidebar user-card
* dropdown (sidebar-nav.tsx -> "Über HealthLog" / "About HealthLog")
* because its three small cards (identity / links / update check)
* collectively read once or twice a year and didn't earn a top-level
* settings slot. The route `/settings/about` is still alive — the
* slug stays in `SETTINGS_SECTION_SLUGS` so `generateStaticParams()`
* keeps emitting the page — but it no longer appears in the in-page
* navigation. Direct links (the user-card dropdown, the public
* `/about` permalink) continue to resolve to the same component.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -104,7 +113,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.advanced.title",
icon: Settings2,
},
{ slug: "about", titleKey: "settings.sections.about.title", icon: Info },
] as const;
export interface SettingsShellProps {
@@ -135,6 +143,29 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
const { t } = useTranslations();
const activeSlug = deriveActiveSlug(pathname, active);
// v1.4.33 IW4 — scroll the active chip into view inside the
// horizontal mobile strip. On a 393 CSS px viewport the strip
// is 11 chips wide and the rightmost four sit below the fold; a
// user who tapped one of those chips lands on the new route
// with the strip still scrolled to the leftmost chip, which
// reads as if the navigation is broken. `inline: "center"`
// anchors the active chip in the visible middle of its scroll
// container so the user keeps spatial orientation between
// sections.
const mobileStripRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (typeof window === "undefined") return;
const strip = mobileStripRef.current;
if (!strip) return;
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
if (!active) return;
active.scrollIntoView({
block: "nearest",
inline: "center",
behavior: "smooth",
});
}, [activeSlug]);
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
// already, so this inner shell only carries the wider max-width.
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
@@ -148,17 +179,25 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
scrolling still work. Without it the 10-section strip
renders an always-on scrollbar at the top of every settings
page, which makes the page feel like every section card has
an overflow problem. */}
an overflow problem.
v1.4.33 IW7 — `snap-x snap-mandatory` lets a swipe-flick land
on the next chip's leading edge instead of the in-between
dead zone. The auto `scrollIntoView({inline: "center"})`
effect above stays the canonical positioner for the active
chip; snap is the polish layer when the user manually flicks
through the strip without tapping. */}
<nav
ref={mobileStripRef}
aria-label={t("settings.shell.sectionsNav")}
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
{SETTINGS_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<li key={section.slug} className="snap-start">
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
@@ -218,8 +257,17 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
`min-h-[calc(100dvh-12rem)]` reserve keeps the column tall
enough that swapping a short loading state for a long
section list (Thresholds, Sources) does not jump the page
height under the sticky sidebar. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
height under the sticky sidebar.
v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter
on `<md` so the last form field on
`/settings/account` (Geschlecht / Gender) and
`/settings/ai` (Aktiver Provider) is not eaten by the
floating mobile bottom-nav. Desktop reverts to the parent's
own padding because the bottom-nav doesn't render on `md+`. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:pb-0">
{children}
</main>
</div>
</div>
);
+6 -27
View File
@@ -4,12 +4,12 @@ import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircle, Save, Send } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
interface TelegramSettings {
@@ -106,32 +106,11 @@ export function TelegramCard({
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<MessageCircle className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.telegram")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* F-16 (v1.4.19): show one unified status pill instead of
the previous "Configured" + "Enabled" pair, which had no
visual hierarchy and made it unclear how the two states
differed. The new pill collapses both bits of info:
green when active, neutral when configured-but-paused. */}
{settings?.hasBotToken && settings?.chatId && settings?.enabled && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("common.active")}
</Badge>
)}
{settings?.hasBotToken && settings?.chatId && !settings?.enabled && (
<Badge variant="outline" className="text-xs">
{t("settings.configured")} · {t("common.disabled")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.telegramDescription")}
</p>
<SettingsCardHeader
icon={MessageCircle}
title={t("settings.telegram")}
description={t("settings.telegramDescription")}
/>
<div className="mt-4 space-y-4">
<form onSubmit={handleSave} className="space-y-4">
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
METRIC_BOUNDS,
type ThresholdMetric,
@@ -87,7 +88,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
// Every chart/band depends on these thresholds — invalidate everything.
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("thresholds.saveSuccess"));
},
@@ -107,7 +108,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("thresholds.resetSuccess"));
},
@@ -250,10 +251,18 @@ function MetricRow({
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.4.33 F17 — the switch label used to flip between
"Auto" and "Überschrieben" with the current state. The
maintainer's audit caught that as confusing: a user
looking at a row with "Auto" + the toggle off reads it
as "Auto is off, why are there no inputs?" instead of
"flip the switch to enter a custom range". Anchor the
label on the *action* ("Eigene Werte" / "Custom range")
so the affordance is unambiguous; the
`thresholds.sourceOverride` badge to the right still
announces when the override is active. */}
<Label htmlFor={`override-${metric}`} className="text-xs">
{overrideMode
? t("thresholds.overrideModeLabel")
: t("thresholds.autoModeLabel")}
{t("thresholds.overrideToggleLabel")}
</Label>
<Switch
id={`override-${metric}`}
+18 -2
View File
@@ -80,9 +80,14 @@ export function TimezonePicker({
variant="outline"
size="sm"
onClick={handleUseBrowserTz}
aria-label={
detected
? t("settings.timezoneDetectAria", { tz: detected })
: undefined
}
>
<Compass className="mr-2 h-4 w-4" />
{t("settings.timezoneDetect", { tz: detected })}
{t("settings.timezoneDetect")}
</Button>
</div>
);
@@ -109,15 +114,26 @@ export function TimezonePicker({
</option>
))}
</NativeSelect>
{/* v1.4.33 — visible label is "use browser zone"; the actual
IANA zone is read out via aria-label so screen-reader users
still hear which zone the click will apply. Previously the
label inlined `(Europe/Berlin)`, which on a 393 CSS px
viewport wrapped the button to two lines and broke height
parity with the adjacent select. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUseBrowserTz}
className="sm:w-auto"
aria-label={
detected
? t("settings.timezoneDetectAria", { tz: detected })
: undefined
}
>
<Compass className="mr-2 h-4 w-4" />
{t("settings.timezoneDetect", { tz: detected })}
{t("settings.timezoneDetect")}
</Button>
</div>
<p className="text-muted-foreground text-xs">{labelHint}</p>
+6 -22
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
@@ -157,27 +157,11 @@ export function WebPushCard() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.webPush")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{!loading && isSubscribed && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{!loading && isSubscribed && (
<Badge variant="outline" className="text-xs">
{t("settings.webPushActive")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.webPushDescription")}
</p>
<SettingsCardHeader
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
/>
<div className="mt-4 space-y-3">
{loading ? (
+3 -2
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
METRIC_BOUNDS,
type ThresholdMetric,
@@ -216,7 +217,7 @@ function TargetEditSheetBody({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.saveSuccess"));
onOpenChange(false);
@@ -248,7 +249,7 @@ function TargetEditSheetBody({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.resetSuccess"));
onOpenChange(false);
+25
View File
@@ -4,6 +4,31 @@ import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.4.33 IW9 — loading-state convention.
*
* Callers driving a transient loading state on a button must SWAP the
* leading icon for `<Loader2 />` rather than APPENDING the spinner
* next to the existing icon. The legacy `{loading && <Loader2/>}<Icon/>`
* pattern grew the button by ~24 px during the request, which produced
* the 0.186 CLS hit flagged on the v1.4.33 Lighthouse run. The
* spinner and the icon are both 16 px (`h-4 w-4`); swapping keeps the
* intrinsic button width stable.
*
* ```tsx
* <Button disabled={loading} aria-busy={loading || undefined}>
* {loading ? (
* <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
* ) : (
* <Icon className="mr-2 h-4 w-4" />
* )}
* {label}
* </Button>
* ```
*
* Also: pass `aria-busy={loading || undefined}` so screen readers
* announce the in-flight state.
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
+20 -4
View File
@@ -2,12 +2,25 @@ import * as React from "react";
import { cn } from "@/lib/utils";
/**
* v1.4.33 IW9 — Card padding normalised on `p-4 md:p-6`.
*
* Pre-v1.4.33 the shadcn defaults shipped `py-6` + `px-6` (uniform
* 24 px on every breakpoint) while the dashboard tile-strip + chart
* cards used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
* `/insights` the correlation cards + daily-briefing read as "denser"
* than the chart cards next to them; the visual reads as two card
* languages on the same page. Matching the dashboard token here gives
* the whole app a single card-padding contract.
*
* Refs `.planning/round-v1433-audit-polish.md` §4.1 + Win 4.
*/
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-4 shadow-sm md:gap-6 md:py-6",
className,
)}
{...props}
@@ -20,7 +33,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] md:px-6 [.border-b]:pb-4 md:[.border-b]:pb-6",
className,
)}
{...props}
@@ -65,7 +78,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-4 md:px-6", className)}
{...props}
/>
);
@@ -75,7 +88,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn(
"flex items-center px-4 md:px-6 [.border-t]:pt-4 md:[.border-t]:pt-6",
className,
)}
{...props}
/>
);
+36 -4
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -13,16 +14,47 @@ import { useTranslations } from "@/lib/i18n/context";
* having to pull `locale` out of `useTranslations()` just to feed it
* back into the same JSX prop, and guarantees a future date input
* cannot silently regress by forgetting the hint.
*
* v1.4.33 — height parity with `<Input>` and `<NativeSelect>`.
*
* Mobile Safari + Android Chrome give `type="date"` (and
* `type="datetime-local"`) an intrinsic chrome that puffs the
* field past Tailwind's `h-10`, so the DOB cell on
* `/settings/account` rendered ~6 px taller than its row siblings
* and roughly 12 px wider on the Pixel-5 mobile-pass screenshots.
* `appearance-none` switches the native widget off, then
* `min-h-10` re-asserts the contract that every other input
* primitive ships. The `:webkit-date-and-time-value` shadow rule
* forces the embedded value text to occupy at least one full line
* even when iOS shrinks its placeholder, so the field looks
* identical on both engines.
*/
const DATE_INPUT_HEIGHT_CLASSES =
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
type InputProps = React.ComponentProps<typeof Input>;
type DateInputProps = Omit<InputProps, "type" | "lang">;
export function DateInput(props: DateInputProps) {
export function DateInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="date" lang={locale} {...props} />;
return (
<Input
type="date"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
export function DateTimeInput(props: DateInputProps) {
export function DateTimeInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="datetime-local" lang={locale} {...props} />;
return (
<Input
type="datetime-local"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
+12 -18
View File
@@ -1,10 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { hasMetricData, type InsightMetric } from "@/lib/insights/metric-availability";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { SubPageAnalyticsData } from "@/types/analytics";
/**
@@ -53,24 +51,20 @@ export function useInsightsAnalytics(
metric: InsightMetric,
): UseInsightsAnalyticsResult {
const { isAuthenticated } = useAuth();
const query = useQuery({
queryKey: queryKeys.analytics(),
queryFn: async (): Promise<SubPageAnalyticsData> => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = (await res.json()) as { data: SubPageAnalyticsData };
return json.data;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// v1.4.33 IW2 — sub-pages only read `summaries[METRIC].count` for the
// gating short-circuit, so they ride on the slim slice. The mother
// page keeps the default thick slice for `correlations` /
// `healthScore` / `bpInTargetPct*`; both consumers live in the same
// queryKey tree (root `["analytics"]`) but the slim slice has its own
// cache slot under `["analytics", "summaries"]`.
const query = useAnalyticsQuery({ slice: "summaries" });
const data = query.data as SubPageAnalyticsData | undefined;
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
data !== undefined &&
!hasMetricData(metric, {
summaries: query.data.summaries,
summaries: data.summaries,
// Sub-pages that go through this hook are sensor-backed only
// (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_SYS / SLEEP_DURATION).
// Mood + medication run their own gate.
@@ -79,7 +73,7 @@ export function useInsightsAnalytics(
});
return {
data: query.data,
data,
isLoading: query.isLoading,
isEmpty,
error: query.error as Error | null,
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect } from "react";
/**
* v1.4.33 IW9 — single source of truth for the "scroll to top on
* route mount" affordance.
*
* The pre-v1.4.33 wiring had `<SubPageShell>` AND the insights mother
* page each firing their own `requestAnimationFrame → scrollTo({top:0})`
* on mount. Two consumers doing the same trick is the kind of
* duplication that drifts; the second `scrollTo` lands ~16 ms after the
* first and re-snaps the viewport once the chart skeleton has inflated.
* On slow hydrates the user sees a visible "jump" — exactly the
* complaint in `.planning/round-v1433-audit-polish.md` §2.1.
*
* This hook is the single owner of that reset. Both the shell and the
* mother page call into it; the effect is mount-only and uses
* `requestAnimationFrame` so the scroll lands after the first paint
* (chart skeleton heights have settled, no double-snap).
*
* `behavior: "auto"` is intentional — `prefers-reduced-motion` users
* get the same instant reset everyone else does. A smooth scroll on
* a route change is itself a motion that some users opt out of.
*/
export function useScrollResetOnRoute() {
useEffect(() => {
if (typeof window === "undefined") return;
const handle = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "auto" });
});
return () => window.cancelAnimationFrame(handle);
}, []);
}
+12
View File
@@ -22,6 +22,18 @@ describe("queryKeys factory", () => {
"en",
]);
});
// v1.4.33 IW2 — slice param routes the slim consumer onto its own
// cache slot but leaves `["analytics"]` (the root) as a prefix so
// mutation invalidations sweep both shapes.
it("threads the slim slice through queryKeys.analytics", () => {
expect(queryKeys.analytics("summaries")).toEqual(["analytics", "summaries"]);
});
it("keeps the no-arg call byte-identical to the legacy literal", () => {
expect(queryKeys.analytics()).toEqual(["analytics"]);
expect(queryKeys.analytics(undefined)).toEqual(["analytics"]);
});
});
describe("dependent-key bundles", () => {
@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -50,6 +53,9 @@ function daysAgo(
describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => {
beforeEach(() => {
vi.clearAllMocks();
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s; reset between tests so each fixture is read fresh.
__resetCoachSnapshotCacheForTests();
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default
+54 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -47,6 +50,10 @@ function daysAgo(
describe("buildCoachSnapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s. Reset the cache between tests so each test sees its
// own freshly-mocked Prisma fixture.
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
@@ -182,4 +189,50 @@ describe("buildCoachSnapshot", () => {
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.mood.timeline.recent.length).toBe(1);
});
// v1.4.33 — 60 s in-process snapshot cache. A chat conversation
// sends 2-4 turns within a minute and rebuilding the snapshot from
// ~10 measurement reads on every turn is wasteful. Cache hits skip
// every persistent read; cache misses on a different scope (window
// or sources) compute fresh.
it("memoises the result for repeated (userId, scope) within the 60 s window", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 82.0, "WEIGHT"),
daysAgo(5, 82.4, "WEIGHT"),
]);
const first = await buildCoachSnapshot("user-1", { sources: ["weight"] });
const second = await buildCoachSnapshot("user-1", { sources: ["weight"] });
// Same JSON shape on both calls.
expect(second.snapshotJson).toBe(first.snapshotJson);
// Prisma reads ran once for the first call; the second call short-
// circuits on the cache so the count is still 1.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(1);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1);
});
it("recomputes when the scope window or sources change", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(3, 82.0, "WEIGHT"),
]);
await buildCoachSnapshot("user-1", {
window: "last7days",
sources: ["weight"],
});
await buildCoachSnapshot("user-1", {
window: "last30days",
sources: ["weight"],
});
// Two distinct window keys → two cache slots → two Prisma reads.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
});
});
+78
View File
@@ -274,6 +274,67 @@ function resolveScope(scope?: CoachScope): {
};
}
/**
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
* snapshot reads only persisted data; a single chat conversation sends
* 2-4 turns within a minute and the snapshot would otherwise rebuild
* from the same rows each turn. Caching the result for 60s shaves the
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
* §3.3 estimates at 200-800 ms of server-side tail.
*
* Scope is part of the cache key so a switch from `last30days` to
* `last7days` (or a different `sources` set) computes fresh. The map
* is bounded at 64 entries — a multi-tenant deployment with a few
* active power users sits well inside that ceiling even if each cycles
* through several scopes per minute.
*/
const SNAPSHOT_TTL_MS = 60_000;
const SNAPSHOT_LRU_MAX = 64;
const snapshotCache = new Map<
string,
{ expiresAt: number; result: CoachSnapshotResult }
>();
function snapshotCacheKey(userId: string, scope: CoachScope | undefined): string {
const { sources, window } = resolveScope(scope);
const sourceList = Array.from(sources).sort().join(",");
return `${userId}|${window}|${sourceList}`;
}
function readSnapshotCache(key: string): CoachSnapshotResult | null {
const entry = snapshotCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
snapshotCache.delete(key);
return null;
}
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
snapshotCache.delete(key);
snapshotCache.set(key, entry);
return entry.result;
}
function writeSnapshotCache(key: string, result: CoachSnapshotResult): void {
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
// Evict the oldest entry — JS Map iteration order is insertion order,
// so the first key is the least-recently inserted/touched.
const oldest = snapshotCache.keys().next().value;
if (oldest !== undefined) {
snapshotCache.delete(oldest);
}
}
snapshotCache.set(key, {
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
result,
});
}
/** Clear the snapshot cache. Test-only escape hatch. */
export function __resetCoachSnapshotCacheForTests(): void {
snapshotCache.clear();
}
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
@@ -293,10 +354,27 @@ function resolveScope(scope?: CoachScope): {
* user's display timezone (read from `User.timezone`). Falls back to
* Europe/Berlin when the column is missing so the legacy snapshot
* stays byte-identical for the only path the v1.4.24 suite tested.
*
* v1.4.33 — wraps the previous `buildCoachSnapshotImpl` with a 60s
* in-memory LRU keyed on `(userId, window, sources)`. The Coach's
* chat handler calls this once per turn; within the same conversation
* the second+ turn lands a cache hit and skips the row-level reads.
*/
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const key = snapshotCacheKey(userId, scope);
const cached = readSnapshotCache(key);
if (cached) return cached;
const result = await buildCoachSnapshotImpl(userId, scope);
writeSnapshotCache(key, result);
return result;
}
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const { sources: scopedSources, window } = resolveScope(scope);
@@ -0,0 +1,163 @@
/**
* v1.4.33 C1 — unit-level pin for the slim summaries slice. Heavier
* integration coverage lives in `tests/integration/analytics-summaries-slice.test.ts`
* (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so
* the slope/round/empty contracts are pinned without a container.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { computeSummariesSlice } from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
RAW.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("computeSummariesSlice", () => {
it("returns the empty-summary skeleton when the user has no rows", async () => {
// Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty.
RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await computeSummariesSlice("user-1");
// Every enum option is seeded so consumers can read
// `summaries.WEIGHT` and get a stable empty shape.
expect(result.summaries.WEIGHT).toEqual({
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
});
expect(result.bmi).toBeNull();
// Both SQL passes fired.
expect(RAW).toHaveBeenCalledTimes(2);
});
it("maps a populated aggregate row into the DataSummary shape", async () => {
RAW.mockResolvedValueOnce([
{
type: "WEIGHT",
count: BigInt(42),
min_value: 79.2,
max_value: 84.1,
mean_value: 82.05,
avg7: 81.9,
avg30: 82.1,
slope7: -0.014,
r2_7: 0.65,
slope30: -0.005,
r2_30: 0.42,
slope90: 0.001,
r2_90: 0.12,
},
]).mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4 }]);
const result = await computeSummariesSlice("user-1");
const weight = result.summaries.WEIGHT;
expect(weight.count).toBe(42);
expect(weight.latest).toBe(81.4);
expect(weight.min).toBe(79.2);
expect(weight.max).toBe(84.1);
expect(weight.mean).toBe(82.05);
expect(weight.avg7).toBe(81.9);
expect(weight.avg30).toBe(82.1);
expect(weight.anomalyCount).toBe(0);
expect(weight.avg30LastMonth).toBeNull();
expect(weight.avg30LastYear).toBeNull();
// Slope direction respects the 0.01-units-per-day "stable"
// threshold the JS helper uses. -0.014 → "down", -0.005 →
// "stable", 0.001 → "stable".
expect(weight.slope7).toEqual({
slope: -0.014,
direction: "down",
confidence: 0.65,
});
expect(weight.slope30).toEqual({
slope: -0.005,
direction: "stable",
confidence: 0.42,
});
expect(weight.slope90).toEqual({
slope: 0.001,
direction: "stable",
confidence: 0.12,
});
});
it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => {
RAW.mockResolvedValueOnce([
{
type: "PULSE",
count: BigInt(1),
min_value: 72,
max_value: 72,
mean_value: 72,
avg7: 72,
avg30: 72,
// regr_slope returns NULL when n < 2.
slope7: null,
r2_7: null,
slope30: null,
r2_30: null,
slope90: null,
r2_90: null,
},
]).mockResolvedValueOnce([{ type: "PULSE", value: 72 }]);
const result = await computeSummariesSlice("user-1");
expect(result.summaries.PULSE.slope7).toBeNull();
expect(result.summaries.PULSE.slope30).toBeNull();
expect(result.summaries.PULSE.slope90).toBeNull();
});
it("seeds the latest value from the DISTINCT ON pass per type", async () => {
RAW.mockResolvedValueOnce([
{
type: "PULSE",
count: BigInt(3),
min_value: 60,
max_value: 95,
mean_value: 77,
avg7: 77,
avg30: 77,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
]).mockResolvedValueOnce([{ type: "PULSE", value: 88 }]);
const result = await computeSummariesSlice("user-1");
// Latest is NOT max — comes from the row at MAX(measured_at).
expect(result.summaries.PULSE.latest).toBe(88);
expect(result.summaries.PULSE.max).toBe(95);
});
});
@@ -131,4 +131,36 @@ describe("summarize", () => {
expect(summary.slope7).toBeNull(); // window snaps to `now`, no points
expect(summary.slope30).toBeNull(); // same
});
// v1.4.33 P0 regression — production stacktrace
// `RangeError: Maximum call stack size exceeded` at index 3 of the
// analytics route's per-type `Promise.all` (PULSE).
//
// Root cause: `Math.min(...values)` / `Math.max(...values)` spread the
// whole series as function arguments. V8 caps function arity at
// ~125 000-130 000; an Apple-Health-synced PULSE series easily
// exceeds that for a multi-year power user. The fix folds min/max via
// a single pass instead, keeping the working set bounded.
it("survives a multi-hundred-thousand-row series without blowing the stack", () => {
// 250 000 points — well above V8's spread-arg ceiling. The
// unrolled values give us deterministic min/max so we can assert
// both the safety contract AND the correctness contract in one go.
const N = 250_000;
const now = Date.now();
const data: DataPoint[] = new Array(N);
for (let i = 0; i < N; i++) {
data[i] = {
date: new Date(now - (N - i) * 1000),
// Wave between 40 and 199; min=40, max=199.
value: 40 + (i % 160),
};
}
// Must not throw — before the fix this raised
// `RangeError: Maximum call stack size exceeded`.
const summary = summarize(data);
expect(summary.count).toBe(N);
expect(summary.min).toBe(40);
expect(summary.max).toBe(199);
expect(summary.latest).toBe(data[N - 1].value);
});
});
+236
View File
@@ -0,0 +1,236 @@
/**
* v1.4.33 C1 — slim `summaries` slice for `/api/analytics?slice=summaries`.
*
* The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
* chunked findMany reads (one per `MeasurementType`) so it can hand
* each series to `summarize()` for slope7/30/90 + anomaly detection +
* lastMonth/lastYear means. The dashboard tile strip only consumes
* `count`, `latest`, `avg7`, `avg30`, `min`, `max`, `mean`, and the
* three slope tuples — every other field gets `null`-coalesced before
* it ever paints — yet the user waits on the full chunked walk.
*
* This helper resolves the same `DataSummary` shape (per-type) with
* two SQL passes:
*
* 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying
* `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices
* via `FILTER`, plus `regr_slope` + `regr_r2` for the slope
* tuples at 7d / 30d / 90d windows. Postgres folds all of this
* into one index scan over `measurements WHERE user_id = …`.
*
* 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw`
* returning the most-recent `(type, value)` pair per type so the
* slim shape can populate `latest`. `MAX(value)` would not do —
* we need the value of the row at `MAX(measured_at)`, not the
* largest value ever recorded.
*
* Fields the slim shape intentionally omits (the dashboard never
* reads them on first paint):
* - `anomalyCount`: would require an extra per-type read for the
* z-score loop. The Coach / insights paths still get it via the
* default slice.
* - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta
* callout uses them, but only when the comparison-baseline
* widget is enabled — that path already pre-fetches the default
* slice via the explicit comparison opt-in.
*
* Slope `confidence` is the SQL `regr_r2` value (R²) which matches
* the existing `trendSlope()` helper's `Math.round(rSquared * 100) /
* 100`. Slope `direction` is derived from `slope`'s sign with the
* same 0.01-units-per-day "stable" threshold the JS helper uses.
*/
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
interface AggregateRow {
type: string;
count: bigint;
min_value: number | null;
max_value: number | null;
mean_value: number | null;
avg7: number | null;
avg30: number | null;
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
slope90: number | null;
r2_90: number | null;
}
interface LatestRow {
type: string;
value: number;
}
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
function buildSlope(
slope: number | null,
rSquared: number | null,
): DataSummary["slope7"] {
if (slope === null) return null;
const threshold = 0.01;
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
};
}
function round2(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
return Math.round(value * 100) / 100;
}
function emptySummary(): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
}
export interface SummariesSlice {
summaries: Record<string, DataSummary>;
/**
* `null` on the slim slice. The default route computes BMI from
* `summaries.WEIGHT.latest` + `user.heightCm`; consumers of the
* slim slice that need BMI re-derive client-side or fetch the
* default slice.
*/
bmi: null;
}
export async function computeSummariesSlice(
userId: string,
): Promise<SummariesSlice> {
// The two passes run in parallel — they target the same index path
// (`measurements (user_id, type, measured_at)`) but Postgres can
// serve them off the buffer cache concurrently.
const [aggregates, latests] = await Promise.all([
prisma.$queryRaw<AggregateRow[]>`
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value,
AVG(m."value") FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS avg7,
AVG(m."value") FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
)::double precision AS avg30,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS slope7,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS r2_7,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
)::double precision AS slope30,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
)::double precision AS r2_30,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS slope90,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
SELECT DISTINCT ON (m."type")
m."type"::text AS type,
m."value"::double precision AS value
FROM measurements m
WHERE m."user_id" = ${userId}
ORDER BY m."type", m."measured_at" DESC
`,
]);
const latestByType = new Map<string, number>();
for (const row of latests) {
latestByType.set(row.type, Number(row.value));
}
// Seed every enum option so consumers can read `summaries.PULSE` and
// get a deterministic empty shape rather than `undefined`. Mirrors
// the default route which iterates `measurementTypeEnum.options`.
const summaries: Record<string, DataSummary> = {};
for (const type of measurementTypeEnum.options) {
summaries[type] = emptySummary();
}
let totalRows = 0;
for (const row of aggregates) {
const count = Number(row.count);
totalRows += count;
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
count,
latest,
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),
slope30: buildSlope(row.slope30, row.r2_30),
slope90: buildSlope(row.slope90, row.r2_90),
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
analytics: {
slim_summaries: {
row_count: totalRows,
type_count: aggregates.length,
},
},
},
});
return { summaries, bmi: null };
}
+20 -3
View File
@@ -192,7 +192,24 @@ export function summarize(data: DataPoint[]): DataSummary {
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const values = sorted.map((p) => p.value);
const mean = values.reduce((s, v) => s + v, 0) / values.length;
// v1.4.33 P0 — single-pass fold for sum/min/max replaces the previous
// `Math.min(...values)` / `Math.max(...values)` spread. V8 caps
// function arity at ~125k-130k; Apple-Health-synced PULSE series for
// a multi-year power user routinely exceed that, and the spread
// surfaced as `RangeError: Maximum call stack size exceeded` from the
// `/api/analytics` route's per-type `Promise.all` aggregator. Folding
// the three reductions into one walk keeps the working set bounded
// (no transient argument array) and is also cheaper than three
// separate passes.
let sum = 0;
let minVal = values[0];
let maxVal = values[0];
for (const v of values) {
sum += v;
if (v < minVal) minVal = v;
if (v > maxVal) maxVal = v;
}
const mean = sum / values.length;
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
@@ -222,8 +239,8 @@ export function summarize(data: DataPoint[]): DataSummary {
return {
count: data.length,
latest: sorted[sorted.length - 1].value,
min: Math.min(...values),
max: Math.max(...values),
min: minVal,
max: maxVal,
mean: Math.round(mean * 100) / 100,
avg7:
last7.length > 0
+2 -2
View File
@@ -169,8 +169,8 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients render the
// `<AssistantDisabledNotice>` empty state.
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
+57 -1
View File
@@ -8,8 +8,15 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.4.33 — `mockEvent` lets the dedicated memoisation describe-block
// below pin a stable WideEventBuilder stub so the per-request cache
// keys on the same reference across multiple `getAssistantFlags()`
// calls. The legacy describe-blocks leave `mockEvent` null so every
// call gets a fresh `{}` and the memo never collapses — preserves the
// pre-v1.4.33 assertion semantics.
let mockEvent: object | null = null;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: vi.fn() }),
getEvent: () => mockEvent ?? { addWarning: vi.fn() },
}));
import { prisma } from "@/lib/db";
@@ -25,6 +32,7 @@ const FIND = prisma.appSettings.findUnique as ReturnType<typeof vi.fn>;
beforeEach(() => {
FIND.mockReset();
mockEvent = null;
});
describe("resolveAssistantFlags", () => {
@@ -162,3 +170,51 @@ describe("requireAssistantSurface", () => {
expect(err.errorCode).toBe("assistant.disabled.correlations");
});
});
describe("per-request memoisation", () => {
// v1.4.33 — When the same request opens the Coach drawer, five gated
// routes (`/api/insights/chat`, the rail list, each `<metric>-status`
// call, etc.) all funnel through `getAssistantFlags()` → `AppSettings`
// read. The memo on the active WideEventBuilder collapses those to
// one DB hit.
it("collapses repeated reads inside the same request to one DB call", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
});
// Pin a stable stub object so every `getEvent()` inside this test
// returns the same reference — the memo keys on identity, so a
// shared reference == shared cache.
mockEvent = { addWarning: vi.fn() };
const a = await getAssistantFlags();
const b = await getAssistantFlags();
const c = await getAssistantFlags();
expect(a).toBe(b);
expect(b).toBe(c);
expect(FIND).toHaveBeenCalledTimes(1);
});
it("issues independent reads when the request context changes", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
});
mockEvent = { addWarning: vi.fn(), id: "req-1" };
await getAssistantFlags();
mockEvent = { addWarning: vi.fn(), id: "req-2" };
await getAssistantFlags();
expect(FIND).toHaveBeenCalledTimes(2);
});
});
+41 -30
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
/**
* v1.4.31 — Assistant-surface operator feature flags.
@@ -59,37 +60,47 @@ export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({
*
* The master always wins: every sub-flag is forced false when the
* master is off, before the resolved set leaves this function.
*
* v1.4.33 — memoised per-request so a single Coach-drawer open that
* fires five gated fetches in parallel (`/api/insights/chat`,
* `/api/insights/comprehensive`, six `/api/insights/<metric>-status`
* routes, the rail list) reads `AppSettings.singleton` once instead of
* five times. The cache lives on the active `WideEventBuilder` so it
* goes away with the request; behaviour outside an event context (unit
* tests, background jobs) stays unchanged.
*/
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
return memoizePerRequest("assistant-flags", async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
const master = settings?.assistantEnabled ?? true;
return resolveAssistantFlags({
enabled: master,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
correlations: settings?.assistantCorrelationsEnabled ?? true,
healthScoreExplainer:
settings?.assistantHealthScoreExplainerEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant feature flags, using defaults",
);
return ASSISTANT_FLAGS_DEFAULT;
}
const master = settings?.assistantEnabled ?? true;
return resolveAssistantFlags({
enabled: master,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
correlations: settings?.assistantCorrelationsEnabled ?? true,
healthScoreExplainer:
settings?.assistantHealthScoreExplainerEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant feature flags, using defaults",
);
return ASSISTANT_FLAGS_DEFAULT;
}
});
}
/**
@@ -120,8 +131,8 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
*
* Older iOS clients that predate the v1.4.31 contract surface this as
* a generic 403 (their existing 403-handler covers the case); v1.4.31+
* clients read the `errorCode` to render the `AssistantDisabledNotice`
* empty-state.
* clients read the `errorCode` to render an inline operator-disabled
* notice.
*/
export class AssistantDisabledError extends Error {
readonly surface: AssistantSurface;
+18 -8
View File
@@ -57,11 +57,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -74,14 +69,29 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+19 -8
View File
@@ -42,11 +42,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -59,14 +54,30 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path and is the same anti-pattern fixed in `summarize()` (see
// `.planning/round-v1433-analytics-500-report.md` §"Carry-over").
// These helpers feed off bounded windows today so they did not crash,
// but the spread allocates a transient args array on every call;
// folding once is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+18 -7
View File
@@ -51,10 +51,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -63,14 +59,29 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+18 -8
View File
@@ -49,11 +49,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -66,14 +61,29 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+18 -8
View File
@@ -48,11 +48,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -65,14 +60,29 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+29 -11
View File
@@ -24,27 +24,45 @@
* enum, the slug array, and the per-slug metric list all derive from
* the keys of this record — adding a sub-page is a one-place change.
*/
// v1.4.33 F9 — order follows the MeasurementCategory overlay
// (`src/lib/measurements/categories.ts`):
//
// vitals → body → activity → sleep → cardiovascular → mood → events
//
// The wave-A HealthKit entries added in v1.4.32 (HRV, resting HR, SpO2,
// body temperature, active energy) used to stack at the end of the
// strip in insertion order; the regrouping below interleaves them into
// their categorical neighbours so a user reading left-to-right scans
// each metric domain as a block. Heavier regrouping (collapse five
// wave-A pills under a single "Apple Health" pill, fold BMI into
// Gewicht, fold Sauerstoff/Körpertemperatur into a single Vitals pill)
// is deferred to v1.4.34 — that needs a screenshot review per
// .planning/round-v1433-audit-menu.md §7.
export const SUB_PAGE_METRIC = {
// ── vitals ──
blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
gewicht: ["WEIGHT"],
puls: ["PULSE"],
stimmung: ["MOOD"],
// medication adherence is event-driven; no measurement series.
medikamente: [],
sauerstoff: ["OXYGEN_SATURATION"],
koerpertemperatur: ["BODY_TEMPERATURE"],
// ── body composition ──
gewicht: ["WEIGHT"],
// BMI is derived from WEIGHT + profile height (no separate series).
bmi: ["WEIGHT"],
schlaf: ["SLEEP_DURATION"],
// ── activity ──
"aktive-energie": ["ACTIVE_ENERGY_BURNED"],
// v1.4.32 — workouts surface; the page reads `Workout` rows directly
// through `useWorkouts()` rather than the `summaries` map, so the
// metric list stays empty.
workouts: [],
// v1.4.32 — five new HealthKit wave-A sub-pages. Each one reads its
// own MeasurementType series via the standard analytics path.
hrv: ["HEART_RATE_VARIABILITY"],
// ── sleep ──
schlaf: ["SLEEP_DURATION"],
// ── cardiovascular ──
ruhepuls: ["RESTING_HEART_RATE"],
sauerstoff: ["OXYGEN_SATURATION"],
koerpertemperatur: ["BODY_TEMPERATURE"],
"aktive-energie": ["ACTIVE_ENERGY_BURNED"],
hrv: ["HEART_RATE_VARIABILITY"],
// ── mood ──
stimmung: ["MOOD"],
// ── events (medication adherence is event-driven; no measurement series) ──
medikamente: [],
} as const satisfies Record<string, readonly string[]>;
export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
+18 -8
View File
@@ -52,11 +52,6 @@ function round(value: number, digits = 1): number {
return Math.round(value * factor) / factor;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
@@ -69,14 +64,29 @@ function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(average(series.map((entry) => entry.value)), 2),
min: round(Math.min(...series.map((entry) => entry.value)), 2),
max: round(Math.max(...series.map((entry) => entry.value)), 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
import type { SubPageAnalyticsData } from "@/types/analytics";
/**
* v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`.
*
* Pre-fix, seven mount sites each declared `useQuery({ queryKey:
* ["analytics"], … })` with inconsistent `staleTime` / `enabled` /
* `refetchOnMount` options. TanStack treats the first-mount wins → the
* earliest consumer's options govern the shared cache cell, but every
* route-change re-mount still spins up a fresh subscription tree and
* any consumer without an `enabled: isAuthenticated` short-circuit
* re-issues the network on a back-forward swing. The audit
* (`.planning/round-v1433-audit-perf.md` §1) ranks this as the top-1
* ROI win.
*
* The shared hook:
* - centralises the queryKey via `queryKeys.analytics(slice)`,
* - sets `staleTime: 60_000` so a route swing within a minute is a
* free cache hit,
* - sets `refetchOnMount: false` + `refetchOnWindowFocus: false` so
* route transitions never trigger a refetch storm,
* - defaults to `enabled: isAuthenticated` so the unauthenticated
* surfaces (registration, sign-in) don't spin up a 401-bound
* request,
* - lets the caller opt into the slim `?slice=summaries` server
* branch shipped in IW1 / C1 when the consumer only needs the
* per-type DataSummary headlines (sub-pages, gating helpers,
* onboarding checklist).
*
* The dashboard's mother query keeps the default slice because it also
* reads `bpInTargetPct*` and `glucoseByContext` — those slots stay
* stubbed on the slim path per the IW1 follow-up.
*/
/**
* Strict structural subset shared between the slim slice and the thick
* default payload. Consumers that only read `summaries` should type
* the hook return as `AnalyticsSlicePayload` (or import
* `SubPageAnalyticsData` directly — same shape, kept aliased for
* call-site clarity).
*/
export type AnalyticsSlicePayload = SubPageAnalyticsData;
/**
* Discriminator for the server-side slice. `undefined` (the default)
* routes onto the thick payload; `"summaries"` routes onto IW1's
* `?slice=summaries` branch.
*/
export type AnalyticsSlice = "summaries" | undefined;
/**
* The unwrapped `data` field returned by the API. Typed as a wide
* shape so the dashboard's `bpInTargetPct*` / `glucoseByContext`
* consumers can narrow without a runtime cast. The slim slice fills
* the optional thick fields with `null` / empty so call-site reads
* remain undefined-safe.
*/
export interface AnalyticsRawPayload {
summaries: Record<string, DataSummary>;
// Thick-only fields — present on the default slice, stubbed on the
// slim branch. Optional so the type covers both shapes.
bpInTargetPct?: number | null;
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
bpInTargetPctAllTime?: number | null;
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, unknown>;
correlations?: unknown;
healthScore?: unknown;
sleepStages?: unknown;
// Keep the contract open — additive server fields don't need a
// type bump every release.
[key: string]: unknown;
}
export interface UseAnalyticsQueryOptions {
/**
* `"summaries"` — hit IW1's slim slice (2 SQL passes, no
* correlations / health-score / bp-in-target / sleep-stages).
* Omit (or pass `undefined`) for the thick default payload.
*/
slice?: AnalyticsSlice;
/**
* Optional override for the auth gate. Defaults to
* `isAuthenticated`. The Insights mother page can pass extra
* disabled-flag composition without losing the auth gate.
*/
enabled?: boolean;
}
async function fetchAnalytics(
slice: AnalyticsSlice,
): Promise<AnalyticsRawPayload> {
const url =
slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics";
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to load analytics (${res.status})`);
}
const json = (await res.json()) as { data: AnalyticsRawPayload };
return json.data;
}
/**
* Shared hook. Returns the raw `data` payload typed as
* `AnalyticsRawPayload`. Consumers can narrow at the call site (e.g.
* `const summaries = query.data?.summaries;`).
*/
export function useAnalyticsQuery(
options: UseAnalyticsQueryOptions = {},
): UseQueryResult<AnalyticsRawPayload, Error> {
const { isAuthenticated } = useAuth();
const slice = options.slice;
const enabled =
options.enabled !== undefined ? options.enabled : isAuthenticated;
return useQuery({
queryKey: queryKeys.analytics(slice),
queryFn: () => fetchAnalytics(slice),
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}
+11 -1
View File
@@ -16,7 +16,17 @@ export const queryKeys = {
measurements: () => ["measurements"] as const,
moodEntries: () => ["mood-entries"] as const,
analytics: () => ["analytics"] as const,
/**
* v1.4.33 IW2 — the analytics queryKey now optionally carries a
* `slice` discriminator so the dashboard tile-strip can subscribe to
* the slim `?slice=summaries` server slice (IW1 / C1) without
* colliding with the thick-payload consumers on the Insights tree.
* Calling `queryKeys.analytics()` without a slice keeps the legacy
* shape `["analytics"]` so mutation invalidations and the bulk-key
* lists below stay byte-identical.
*/
analytics: (slice?: "summaries") =>
(slice ? (["analytics", slice] as const) : (["analytics"] as const)),
moodAnalytics: () => ["mood-analytics"] as const,
insightsRoot: () => ["insights"] as const,
+73
View File
@@ -0,0 +1,73 @@
/**
* v1.4.33 — per-request memoisation carrier.
*
* Multiple gated routes share the same in-process request lifecycle
* (api-handler wraps every handler in `eventStorage.run(builder, …)`),
* yet the gate helpers and snapshot builders re-read the same row on
* every call. The Coach drawer opening fires five fetches in parallel,
* each of which lands a `requireAssistantSurface("coach")` call —
* five separate `SELECT` against `AppSettings.singleton` for the same
* value within the same wall-clock millisecond.
*
* This helper attaches a request-scoped `Map<string, Promise<unknown>>`
* to the active `WideEventBuilder` from `@/lib/logging/context`. Same
* key inside one request = one compute; different requests get
* independent caches; outside any request (e.g. tests with no event
* context) the factory runs every time so behaviour is unchanged.
*
* The cache is keyed by string. Callers compose composite keys (e.g.
* `"assistant-flags"` or `"coach-snapshot:${userId}:${windowKey}"`).
* The cache is intentionally weak: it lives on the event builder
* itself via a `WeakMap`, so it goes away as soon as the request
* finishes and the builder is collected.
*/
import { getEvent } from "@/lib/logging/context";
import type { WideEventBuilder } from "@/lib/logging/event-builder";
const REQUEST_CACHES = new WeakMap<WideEventBuilder, Map<string, unknown>>();
function getCache(builder: WideEventBuilder): Map<string, unknown> {
let cache = REQUEST_CACHES.get(builder);
if (!cache) {
cache = new Map<string, unknown>();
REQUEST_CACHES.set(builder, cache);
}
return cache;
}
/**
* Memoise `factory()` for the lifetime of the current request. Returns
* the factory's result directly when no event context is active so
* unit tests and background jobs that don't enter `eventStorage.run()`
* keep their existing semantics.
*
* The cached value is the Promise itself, so concurrent callers within
* the same request await one outstanding compute instead of starting
* their own — important for the gate-cascade pattern where five
* mounts fire `requireAssistantSurface()` in parallel.
*/
export function memoizePerRequest<T>(
key: string,
factory: () => Promise<T>,
): Promise<T> {
const event = getEvent();
if (!event) {
return factory();
}
const cache = getCache(event);
const existing = cache.get(key);
if (existing !== undefined) {
return existing as Promise<T>;
}
const promise = factory();
cache.set(key, promise);
// If the factory rejects, evict so a retry on the same key inside
// this request gets a fresh attempt. Cached failures inside a single
// request would mask real recovery (e.g. transient DB hiccup).
promise.catch(() => {
if (cache.get(key) === promise) {
cache.delete(key);
}
});
return promise;
}
+8
View File
@@ -33,6 +33,14 @@ const PUBLIC_PATHS = [
// alongside the project credits. The CC licence requires the
// attribution to be reachable without a sign-in.
"/about",
// v1.4.33 — `/.well-known/*` covers IETF-registered discovery
// endpoints (RFC 8615). Apple reads
// `/.well-known/apple-app-site-association` without credentials to
// wire Web Credentials (passkey sharing) and Universal Links to the
// iOS bundle, so the path must answer 200 with the bare JSON body
// before any auth gate runs. The trailing slash future-proofs the
// namespace for `/security.txt`, `/openid-configuration`, etc.
"/.well-known/",
// `/onboarding` itself + its subroutes are matched exactly via
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
"/robots.txt",