mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
This commit is contained in:
1 parent
c7a0ff214b
commit
7bef199039
186 files changed
+9358
-946
No files matched your search
@@ -0,0 +1,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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -26,9 +26,17 @@ function load(path: string): string {
|
||||
|
||||
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
|
||||
it("imports both new components", () => {
|
||||
// v1.4.33 IW2 deferred below-the-fold blocks behind `next/dynamic`,
|
||||
// so the static `from "..."` import-string was replaced with a
|
||||
// `dynamic(() => import("..."))` call. Either spelling counts as
|
||||
// a load-bearing reference to the module path.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toContain('from "@/components/insights/correlation-row"');
|
||||
expect(src).toContain('from "@/components/insights/trends-row"');
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/correlation-row"|import\("@\/components\/insights\/correlation-row"\))/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/trends-row"|import\("@\/components\/insights\/trends-row"\))/,
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
|
||||
|
||||
@@ -81,6 +81,12 @@ export default function AboutPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
v1.4.33 IW9 — `max-w-3xl` is intentional (same rationale as
|
||||
`/privacy`): long-form column reads better around 70-80
|
||||
chars per line. The dashboard / settings / admin shells use
|
||||
`max-w-screen-xl` (1280 px); legal pages stay at 768 px.
|
||||
*/}
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
|
||||
|
||||
@@ -225,7 +225,19 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
</p>
|
||||
<Progress value={achievement.progressPercent} className="h-1.5" />
|
||||
{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */}
|
||||
<Progress
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -328,6 +340,7 @@ export default function AchievementsPage() {
|
||||
: 0
|
||||
}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.points")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
@@ -385,6 +398,7 @@ export default function AchievementsPage() {
|
||||
<Progress
|
||||
value={summary?.nextAchievement?.progressPercent ?? 100}
|
||||
className="mt-3 h-1.5"
|
||||
aria-label={t("achievements.nextProgressLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* v1.4.33 P0 regression coverage for `GET /api/analytics`.
|
||||
*
|
||||
* Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…):
|
||||
* `RangeError: Maximum call stack size exceeded`
|
||||
* at Promise.all (index 3 — `PULSE`)
|
||||
*
|
||||
* Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the
|
||||
* `Math.min(...values)` / `Math.max(...values)` spread blew V8's
|
||||
* ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE
|
||||
* series for a multi-year power user grew past it.
|
||||
*
|
||||
* The fix folds min/max into the single sum/mean pass; this test pins
|
||||
* the contract from the route entry-point so a future refactor (e.g.
|
||||
* the v1.4.33 C1 SQL-side aggregation rewrite) can't silently
|
||||
* reintroduce a spread anywhere along the call chain.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
interface MeasurementRow {
|
||||
id: string;
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
source: "MANUAL" | "WITHINGS" | "IMPORT" | "APPLE_HEALTH";
|
||||
deviceType: string | null;
|
||||
sleepStage?: string | null;
|
||||
}
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-1",
|
||||
username: "test",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
|
||||
sourcePriorityJson: null,
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
|
||||
return {
|
||||
id,
|
||||
measuredAt,
|
||||
value,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/analytics", () => {
|
||||
it("survives a 130 000-row PULSE series without blowing the stack", async () => {
|
||||
// The chunked reader pages 5 000 at a time. Simulate one full page
|
||||
// for PULSE so the route's per-type aggregator hits the failing
|
||||
// `summarize()` codepath with a 5 000-point series, then stop. We
|
||||
// intentionally don't seed every single Apple Watch sample in the
|
||||
// mock — V8's spread-arg ceiling is repeatable with a far smaller
|
||||
// array than production (see `trends.test.ts` for the 250 000-row
|
||||
// direct regression). This test pins the route-entry contract.
|
||||
const N = 5_000;
|
||||
const now = Date.now();
|
||||
const pulseSeries: MeasurementRow[] = new Array(N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
pulseSeries[i] = pulseRow(
|
||||
new Date(now - (N - i) * 1000),
|
||||
40 + (i % 160),
|
||||
`pulse-${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return the PULSE rows on the second page-of-5000-empty for every
|
||||
// other type; the chunked reader walks `take=5000` then exits on
|
||||
// any short page.
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(async (args: any) => {
|
||||
if (args.where?.type === "PULSE" && !args.cursor) {
|
||||
return pulseSeries as never;
|
||||
}
|
||||
return [] as never;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
// GET is wrapped by `apiHandler` which tolerates direct-invoke
|
||||
// with no request (see `safeRequestProp` in `src/lib/api-handler.ts`).
|
||||
// Cast through `unknown` so the TS signature `(...args: never[])`
|
||||
// doesn't fight the vitest direct-invoke pattern.
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { summaries: Record<string, { count: number; min: number | null; max: number | null }> };
|
||||
};
|
||||
expect(body.data.summaries.PULSE.count).toBe(N);
|
||||
// 40 + (N-1) % 160 → wave around (40..199).
|
||||
expect(body.data.summaries.PULSE.min).toBeGreaterThanOrEqual(40);
|
||||
expect(body.data.summaries.PULSE.max).toBeLessThanOrEqual(199);
|
||||
});
|
||||
|
||||
it("returns a 200 envelope for a brand-new user with zero rows", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
summaries: Record<string, { count: number }>;
|
||||
bmi: number | null;
|
||||
healthScore: unknown;
|
||||
};
|
||||
};
|
||||
// Empty series should not crash on the new fold path either.
|
||||
expect(body.data.summaries.PULSE.count).toBe(0);
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(0);
|
||||
expect(body.data.bmi).toBeNull();
|
||||
expect(body.data.healthScore).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice. The route branches on
|
||||
// `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw`
|
||||
// passes carry the per-type DataSummary shape with the same
|
||||
// contract the dashboard tile strip reads.
|
||||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||||
// Aggregate pass + latest pass. The mock returns one row per pass.
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(12),
|
||||
min_value: 80.0,
|
||||
max_value: 84.5,
|
||||
mean_value: 82.1,
|
||||
avg7: 82.0,
|
||||
avg30: 82.2,
|
||||
slope7: -0.05,
|
||||
r2_7: 0.4,
|
||||
slope30: -0.02,
|
||||
r2_30: 0.3,
|
||||
slope90: -0.01,
|
||||
r2_90: 0.2,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
summaries: Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
latest: number | null;
|
||||
slope30: { slope: number; direction: string } | null;
|
||||
}
|
||||
>;
|
||||
bmi: number | null;
|
||||
};
|
||||
};
|
||||
// The slim slice produced WEIGHT from the SQL pass; no chunked
|
||||
// findMany was called.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(12);
|
||||
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
|
||||
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
|
||||
// Slim slice never carries BMI — the consumer re-derives.
|
||||
expect(body.data.bmi).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
/**
|
||||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||||
* route's production wrapper) or a plain `Request` (the integration
|
||||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||||
* so the slim slice branch is reachable from both call shapes.
|
||||
*/
|
||||
function readSliceParam(request: Request | undefined): string | null {
|
||||
if (!request) return null;
|
||||
try {
|
||||
return new URL(request.url).searchParams.get("slice");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request?: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||||
// The default path runs 30+ chunked findMany reads to feed
|
||||
// `summarize()` per type; under `?slice=summaries` the route
|
||||
// resolves the same per-type DataSummary shape from 2 SQL passes
|
||||
// (a `groupBy` for count/min/max/mean plus a single `$queryRaw`
|
||||
// carrying the windowed averages and Postgres `regr_slope` /
|
||||
// `regr_r2` for the slope tuples). The slim slice drops the thick
|
||||
// `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext`
|
||||
// / `sleepStages` / `bmi` blocks — those stay on the default path
|
||||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
return apiSuccess(slim);
|
||||
}
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -54,6 +54,10 @@ function makeReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for
|
||||
// per-type all-time count + most-recent timestamp. Default to an
|
||||
// empty aggregate so legacy tests keep their "no data" expectations.
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
@@ -90,6 +94,107 @@ describe("GET /api/dashboard/summary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits allTimeCount + lastSeenAt for every base metric (v1.4.33 maintainer-item-1)", async () => {
|
||||
// Empty-data path — every metric card still ships the new fields
|
||||
// so the iOS client can render a tile + "Letzter Wert vor Xd"
|
||||
// hint regardless of whether the route saw any rows in the 7-day
|
||||
// window.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const baseIds = body.data.metrics.map((m) => m.id);
|
||||
for (const required of ["weight", "bp", "pulse"]) {
|
||||
expect(baseIds, `${required} card missing from metrics list`).toContain(
|
||||
required,
|
||||
);
|
||||
}
|
||||
for (const card of body.data.metrics) {
|
||||
expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0);
|
||||
expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces lastSeenAt from the historical aggregate even when the 7-day window is empty", async () => {
|
||||
// Power-user path — the user has logged weight for years but
|
||||
// hasn't touched the app in two weeks. `groupBy` returns the
|
||||
// all-time count + most-recent timestamp; the 7-day `findMany`
|
||||
// returns empty. The route should still emit the weight card
|
||||
// with the historical `lastSeenAt` so the iOS client renders the
|
||||
// staleness caption.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
_count: { _all: 312 },
|
||||
_max: { measuredAt: twoWeeksAgo },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const weight = body.data.metrics.find((m) => m.id === "weight");
|
||||
expect(weight, "weight card must be present").toBeDefined();
|
||||
expect(weight?.allTimeCount).toBe(312);
|
||||
expect(weight?.lastSeenAt).toBe(twoWeeksAgo.toISOString());
|
||||
// No 7-day reading → latestValue stays null but updatedAt falls
|
||||
// through to the historical timestamp so the iOS client can
|
||||
// build the relative-age caption from a single field.
|
||||
expect(weight?.latestValue).toBeNull();
|
||||
expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString());
|
||||
});
|
||||
|
||||
it("emits optional cards when allTimeCount > 0 but the 7-day window is empty", async () => {
|
||||
// Glucose / sleep / steps used to only emit when the 7-day
|
||||
// window had a reading. v1.4.33 widens the gate so a metric the
|
||||
// user logged once last month still surfaces a tile (latestValue
|
||||
// null + lastSeenAt populated → iOS renders the historical
|
||||
// hint).
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
_count: { _all: 4 },
|
||||
_max: { measuredAt: tenDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const glucose = body.data.metrics.find((m) => m.id === "glucose");
|
||||
expect(
|
||||
glucose,
|
||||
"glucose card must be present (v1.4.33 widened gate)",
|
||||
).toBeDefined();
|
||||
expect(glucose?.allTimeCount).toBe(4);
|
||||
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
|
||||
@@ -44,6 +44,24 @@ interface MetricCard {
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* v1.4.33 maintainer-item-1 — total readings the user has ever
|
||||
* logged for this metric, irrespective of the 7-day sparkline
|
||||
* window. The dashboard tile keeps showing whenever `allTimeCount > 0`
|
||||
* so a user with valid historical data isn't surprised by a tile
|
||||
* disappearing during a logging gap. Distinct from `sparkline.length`
|
||||
* which only carries the trailing-7-day points.
|
||||
*/
|
||||
allTimeCount: number;
|
||||
/**
|
||||
* v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single
|
||||
* most recent reading. When `allTimeCount > 0` but the latest
|
||||
* reading is older than 7 days, the iOS tile renders a muted
|
||||
* "Letzter Wert vor Xd" caption so the user understands the value
|
||||
* isn't stale silently. `null` when the metric has no readings at
|
||||
* all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
@@ -168,8 +186,15 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
// v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest
|
||||
// timestamps alongside the 7-day sparkline window so the iOS tile
|
||||
// can keep rendering when the recent window is empty but the user
|
||||
// has logged the metric before. `groupBy` with `_count._all` +
|
||||
// `_max(measuredAt)` is a single Postgres aggregate round-trip; no
|
||||
// additional row materialisation beyond the aggregate values
|
||||
// themselves.
|
||||
const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -179,6 +204,12 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: { userId: user.id, type: { in: measurementTypes } },
|
||||
_count: { _all: true },
|
||||
_max: { measuredAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -194,8 +225,33 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
|
||||
// always `number` per Prisma's runtime contract; defending against
|
||||
// undefined keeps the helper's narrow signature stable.
|
||||
const allTimeByType = new Map<
|
||||
MeasurementType,
|
||||
{ allTimeCount: number; lastSeenAt: Date | null }
|
||||
>();
|
||||
for (const row of allTimeAggregate) {
|
||||
allTimeByType.set(row.type, {
|
||||
allTimeCount: row._count?._all ?? 0,
|
||||
lastSeenAt: row._max?.measuredAt ?? null,
|
||||
});
|
||||
}
|
||||
function metaForType(type: MeasurementType): {
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
} {
|
||||
const slot = allTimeByType.get(type);
|
||||
return {
|
||||
allTimeCount: slot?.allTimeCount ?? 0,
|
||||
lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
@@ -240,10 +296,17 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — every emitted card now carries
|
||||
// `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep
|
||||
// a metric visible during a logging gap (gate on `allTimeCount > 0`)
|
||||
// and paint a muted "Letzter Wert vor Xd" caption when the most
|
||||
// recent reading is older than the 7-day sparkline window.
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
const meta = metaForType("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
@@ -253,7 +316,9 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,6 +328,20 @@ export const GET = apiHandler(async () => {
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
|
||||
// BP is a paired metric — the tile is "alive" whenever either side
|
||||
// of the pair has history. Sum the count to reflect total readings
|
||||
// and pick the most recent `_max` so the staleness hint follows
|
||||
// whichever side is freshest.
|
||||
const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount;
|
||||
const bpLastSeenAt = ((): string | null => {
|
||||
const sysAt = sysMeta.lastSeenAt;
|
||||
const diaAt = diaMeta.lastSeenAt;
|
||||
if (!sysAt) return diaAt;
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
@@ -273,7 +352,11 @@ export const GET = apiHandler(async () => {
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,6 +364,7 @@ export const GET = apiHandler(async () => {
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
const meta = metaForType("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
@@ -290,30 +374,43 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Body fat
|
||||
// Body fat — v1.4.33 widens the emit gate from `latestOf` (only
|
||||
// fires when the 7-day window has a reading) to `allTimeCount > 0`
|
||||
// so a tile doesn't disappear during a fortnight without a fresh
|
||||
// reading.
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
if (latest) {
|
||||
const meta = metaForType("BODY_FAT");
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
latestValue: latest.value,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards — only emitted if the user has data for that type.
|
||||
// Optional cards — emitted whenever the user has *ever* logged the
|
||||
// metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires
|
||||
// when the trailing 7-day window has a reading); v1.4.33 widens to
|
||||
// the all-time count so a glucose tile that hasn't been touched in
|
||||
// 10 days still renders with the historical value visible to the
|
||||
// iOS client (paired with the lastSeenAt caption).
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
@@ -323,18 +420,21 @@ export const GET = apiHandler(async () => {
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
if (!latest) continue;
|
||||
const meta = metaForType(type);
|
||||
if (!latest && meta.allTimeCount === 0) continue;
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
latestValue: latest.value,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function BugReportPage() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div className="w-full space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
@@ -112,7 +112,7 @@ export default function BugReportPage() {
|
||||
|
||||
if (status && status.enabled === false) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
@@ -227,9 +227,22 @@ export default function BugReportPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
<Button type="submit" disabled={loading} aria-busy={loading || undefined}>
|
||||
{/*
|
||||
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
|
||||
the same 16x16 slot as the Bug icon) so the button
|
||||
width is stable across the disabled flip. The previous
|
||||
`{loading && <Loader2/>}<Bug/>` rendered both icons
|
||||
during the in-flight request and grew the button by
|
||||
~24 px — the CLS regression flagged in the v1.4.33
|
||||
Lighthouse punch list (0.186 on
|
||||
`div.space-y-2 > Button > Loader-Circle`).
|
||||
*/}
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("bugreport.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function InsightsBlutdruckPage() {
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
yAxisUnit="mmHg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
|
||||
@@ -187,14 +187,24 @@ export default function InsightsMedikamentePage() {
|
||||
<span>{t("insights.compliance7d")}</span>
|
||||
<span className="font-medium">{med.compliance7}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance7} className="h-2" />
|
||||
{/* v1.4.33 IW9 — aria-label so the bar has an
|
||||
accessible name (Lighthouse a11y 91/94). */}
|
||||
<Progress
|
||||
value={med.compliance7}
|
||||
className="h-2"
|
||||
aria-label={t("insights.compliance7d")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance30d")}</span>
|
||||
<span className="font-medium">{med.compliance30}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance30} className="h-2" />
|
||||
<Progress
|
||||
value={med.compliance30}
|
||||
className="h-2"
|
||||
aria-label={t("insights.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>
|
||||
|
||||
+64
-28
@@ -1,24 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
import { TrendsRow } from "@/components/insights/trends-row";
|
||||
import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* v1.4.33 IW2 — defer the three below-the-fold mother-page blocks
|
||||
* behind `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece)
|
||||
* stays an eager import so the initial paint shows the greeting and
|
||||
* health-score badge without a flash; the briefing, correlation row
|
||||
* and trends row each carry their own icon-set + chart wiring (a chart
|
||||
* card alone weighs in at the lucide tree-shake limit) and used to
|
||||
* land on every Insights cold mount. Loader skeletons match the
|
||||
* existing fallback so the layout doesn't shift while the chunks
|
||||
* resolve.
|
||||
*/
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
default: mod.DailyBriefing,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-48 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
const CorrelationRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/correlation-row").then((mod) => ({
|
||||
default: mod.CorrelationRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-32 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
const TrendsRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/trends-row").then((mod) => ({
|
||||
default: mod.TrendsRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-64 animate-pulse rounded-xl border" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
*
|
||||
@@ -95,20 +142,13 @@ export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.28 FB-D3 — mirror `<SubPageShell>`'s deferred scroll-reset on
|
||||
// the mother page so a back-navigation from a sub-page (where
|
||||
// `SubPageShell` already reset the scroll on mount) lands cleanly
|
||||
// at the top of the overview instead of inheriting the sub-page's
|
||||
// vertical position through the cached scroll state. Single
|
||||
// mount-only effect; uses `requestAnimationFrame` so the reset
|
||||
// settles after first paint, same as the sub-page shell.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(handle);
|
||||
}, []);
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
// shared `useScrollResetOnRoute()` hook. The mother page + the
|
||||
// `<SubPageShell>` both consume the same hook; the legacy duplicate
|
||||
// RAF that lived here pre-v1.4.33 produced a visible double-snap on
|
||||
// slow hydrates (chart skeletons inflating between the two
|
||||
// callbacks).
|
||||
useScrollResetOnRoute();
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
|
||||
@@ -134,16 +174,12 @@ export default function InsightsPage() {
|
||||
// beyond the React-state subscription.
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
// v1.4.33 IW2 — the mother page reads `correlations` + `healthScore`
|
||||
// (thick-only fields) so it stays on the default thick slice. The
|
||||
// shared hook still centralises the cache settings so the consumer
|
||||
// dedups with the sub-page mounts that ride the slim slice instead.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -482,6 +482,7 @@ function IntakeImportDialog({
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -505,9 +506,21 @@ function IntakeImportDialog({
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
>
|
||||
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{/*
|
||||
v1.4.33 IW9 — icon swap (not append) on `importing`
|
||||
to keep the button width stable through the
|
||||
in-flight request. Pre-v1.4.33 both icons painted
|
||||
during loading and the button grew by ~24 px, which
|
||||
showed up as a CLS hit on the medications import
|
||||
dialog.
|
||||
*/}
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -720,6 +733,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(endpoint, "url")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -744,6 +758,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(token, "token")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -795,6 +810,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-7 w-7"
|
||||
onClick={() => copyText(selectedExample.value, "example")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -821,6 +837,7 @@ function ApiEndpointDialog({
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loadingStatus || toggling}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
|
||||
import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
@@ -33,6 +33,42 @@ interface PreferencesData {
|
||||
eventTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb strip for the notification surfaces. v1.4.33 IW7 introduced
|
||||
* this so the inbox (`/notifications`) and the channel-config section
|
||||
* (`/settings/notifications`) never read as the same page — the two names
|
||||
* used to collide as plain "Notifications" in both nav labels. The crumb
|
||||
* sits above the h1 on every render branch of this page (loading,
|
||||
* unauthenticated, error, empty, populated) so the user always sees the
|
||||
* disambiguation, even when the body content is a fallback state.
|
||||
*/
|
||||
function NotificationsBreadcrumb({
|
||||
t,
|
||||
}: {
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
|
||||
<ol className="flex items-center gap-1">
|
||||
<li className="text-foreground font-medium">
|
||||
{t("notifications.breadcrumbInbox")}
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("notifications.breadcrumbChannels")}
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
|
||||
function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
@@ -137,6 +173,7 @@ export default function NotificationsPage() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
</h1>
|
||||
@@ -154,6 +191,7 @@ export default function NotificationsPage() {
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
@@ -176,6 +214,7 @@ export default function NotificationsPage() {
|
||||
if (activeChannels.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
@@ -204,6 +243,7 @@ export default function NotificationsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NotificationsBreadcrumb t={t} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("notifications.title")}
|
||||
|
||||
+78
-48
@@ -62,6 +62,7 @@ const MedicationComplianceChart = dynamic(
|
||||
);
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
@@ -202,17 +203,14 @@ export default function DashboardPage() {
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.analytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
});
|
||||
// v1.4.33 IW2 — the dashboard tile-strip reads `bpInTargetPct*` +
|
||||
// `glucoseByContext` (thick-only fields) so it stays on the default
|
||||
// thick slice. The shared hook still centralises the cache settings
|
||||
// (60s staleTime, no refetch on mount / window focus) so the
|
||||
// tile-strip dedups with the Insights mother page on the same
|
||||
// queryKey + slice combination.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const data = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
@@ -321,7 +319,29 @@ export default function DashboardPage() {
|
||||
const hasSleep = (sleepSummary?.count ?? 0) > 0;
|
||||
const hasSteps = (stepsSummary?.count ?? 0) > 0;
|
||||
const hasVo2 = (vo2Summary?.count ?? 0) > 0;
|
||||
const hasBpInTarget = data?.bpInTargetPct != null;
|
||||
/**
|
||||
* v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %"
|
||||
* placeholder doesn't ride on the dashboard when none of the user's
|
||||
* paired readings sit inside the target band. The historical
|
||||
* behaviour (`!= null`) painted the tile whenever the analytics
|
||||
* route emitted a numeric percentage, including the legitimate-but-
|
||||
* misleading zero. The corrected gate requires at least one window
|
||||
* (7d, 30d, all-time) to report a non-zero share. When every
|
||||
* window is zero, the tile is hidden — the user sees the BP charts
|
||||
* + the `/targets` Blutdruck card for the deeper analysis instead.
|
||||
* The audit's F4 reproduction sat on 540 BP samples with all sub-
|
||||
* windows reading 0 % because the seed data straddled the target
|
||||
* ceiling; the tile keeps its place once even one sub-window
|
||||
* crosses zero.
|
||||
*/
|
||||
const hasBpInTarget =
|
||||
data?.bpInTargetPct != null &&
|
||||
[
|
||||
data?.bpInTargetPct,
|
||||
data?.bpInTargetPct7d,
|
||||
data?.bpInTargetPct30d,
|
||||
data?.bpInTargetPctAllTime,
|
||||
].some((pct) => pct != null && pct > 0);
|
||||
|
||||
// Tile (strip) gates — controlled by the new `tileVisible` flag.
|
||||
const showWeightTile = isTileVisible("weight") && hasWeight;
|
||||
@@ -485,13 +505,21 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* WCAG 2.5.5 — touch targets must be ≥44×44 CSS px on mobile.
|
||||
`size="sm"` (h-8 = 32px) was below threshold; the explicit
|
||||
min-h-11 ensures we hit 44px on the Pixel 5 viewport while
|
||||
keeping the desktop visual unchanged. */}
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
across viewports. The v1.4.27 fix pinned a `size="sm"
|
||||
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
|
||||
contract on mobile, but `size="sm"` is h-8 (32 px) and
|
||||
the `min-h-11` override stretched the cap vertically
|
||||
while keeping the small horizontal padding — the
|
||||
button read as klobig on Pixel 5. Switch to
|
||||
`size="default"` (h-10 = 40 px) on mobile with a
|
||||
responsive `min-h-11 sm:min-h-9` so the button is
|
||||
44 px tall under finger pressure and shrinks back to
|
||||
the desktop-friendly 36 px on `sm:` upwards. The icon
|
||||
+ label keep the same visual contract. */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
@@ -1220,44 +1248,46 @@ export default function DashboardPage() {
|
||||
height for any non-zero count. */}
|
||||
{trendCards.length > 0 && (
|
||||
<div
|
||||
// v1.4.27 MB7 / CF-42 — at `<sm` the tile strip
|
||||
// switches to a `flex overflow-x-auto` row so the
|
||||
// tiles scroll horizontally instead of wrapping to
|
||||
// 3-4 rows on Pixel 5 / Galaxy Fold. Each tile keeps
|
||||
// a `min-w-[10rem]` so the user sees ~2.5 tiles per
|
||||
// viewport — enough to read "there's more" without
|
||||
// crowding the headline value on the visible tile.
|
||||
// From `sm:` upwards the strip falls back to the
|
||||
// canonical `grid auto-fit + minmax(9rem, 1fr)`
|
||||
// layout (every tile equal width, wraps to a new
|
||||
// row when the 9 rem floor no longer fits) that the
|
||||
// maintainer pinned in v1.4.4.
|
||||
// v1.4.33 A3 Win 2 + F3 — collapse the bifurcated
|
||||
// mobile-flex / desktop-grid layout into one
|
||||
// responsive grid track. The v1.4.27 MB7 fix pinned a
|
||||
// `flex overflow-x-auto` row at `<sm` so tiles
|
||||
// scrolled horizontally rather than wrapping to 3-4
|
||||
// rows on a 280 px Galaxy Fold; the v1.4.33 audit
|
||||
// (Win 2) called the side-scroll an unwanted
|
||||
// regression for the Pixel 5 / iPhone-13-mini
|
||||
// viewports where two tiles per row fit naturally.
|
||||
// One grid track for every breakpoint:
|
||||
//
|
||||
// `snap-x snap-mandatory` makes the scroll feel
|
||||
// deliberate on touch and is a no-op on the grid
|
||||
// branch above `sm:`.
|
||||
// `repeat(auto-fit, minmax(min(100%, 11rem), 1fr))`
|
||||
//
|
||||
// - Galaxy Fold (280 px) → 1 column, tiles stretch
|
||||
// full width.
|
||||
// - Pixel 5 / iPhone-13-mini (375 px) → 2 columns,
|
||||
// strip wraps to a second row when needed.
|
||||
// - 1440×900 desktop → 6 columns (1440 / 220 ≈ 6.5),
|
||||
// widens the value column from the v1.4.27
|
||||
// `9rem` floor so the headline number stops
|
||||
// truncating to `8…` / `1.` (audit F3). 11 rem
|
||||
// ≈ 176 px gives every tile ~220 px including
|
||||
// gutter — comfortably wider than `text-3xl`
|
||||
// digits plus the unit + arrow.
|
||||
// - 1920+ desktop → 8 columns, no horizontal slack.
|
||||
//
|
||||
// `auto-rows-fr` keeps the row height deterministic
|
||||
// so a 7-tile run still shares one baseline across
|
||||
// the wrap, regardless of which tile renders a
|
||||
// callout.
|
||||
className={cn(
|
||||
"flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2",
|
||||
// v1.4.29 — fixed mobile tile height contract.
|
||||
// At `<sm` every tile pins to 140 px via the
|
||||
// `--tile-h` CSS custom property so divergent
|
||||
// content (callout on / off, sub-row wrap / no
|
||||
// wrap) cannot drift sibling heights. `sm:`
|
||||
// releases to `auto` where `grid auto-rows-fr`
|
||||
// already enforces equal heights.
|
||||
"[--tile-h:140px] sm:[--tile-h:auto]",
|
||||
"sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible",
|
||||
"sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]",
|
||||
"grid auto-rows-fr gap-3",
|
||||
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
|
||||
)}
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tour-id="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex h-[var(--tile-h)] min-w-[10rem] shrink-0 snap-start sm:h-auto sm:min-w-0 sm:shrink"
|
||||
>
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -124,6 +124,13 @@ export default function PrivacyPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
v1.4.33 IW9 — `max-w-3xl` (768 px) is intentional, not a stale
|
||||
token. The legal/long-form column reads better at 70-80 chars
|
||||
per line; widening to the app shell's `max-w-screen-xl` would
|
||||
push line lengths to ~120 chars on a 1440 px laptop and degrade
|
||||
legibility. The same exception applies to `/about`.
|
||||
*/}
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 & 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 & 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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user