diff --git a/messages/de.json b/messages/de.json index 18c580a18..84abc175d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -853,8 +853,6 @@ "errorProvider": "Der Coach konnte gerade keinen KI-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", "newChat": "Neuer Chat", - "settings": "Coach-Einstellungen", - "settingsTooltip": "Coach-Einstellungen kommen mit v1.4.21.", "send": "Senden", "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", diff --git a/messages/en.json b/messages/en.json index d6456bcc5..18686dd38 100644 --- a/messages/en.json +++ b/messages/en.json @@ -853,8 +853,6 @@ "errorProvider": "The Coach could not reach an AI provider just now. Please try again in a moment.", "tagline": "Personal health coach, grounded in your data.", "newChat": "New chat", - "settings": "Coach settings", - "settingsTooltip": "Coach settings arrive in v1.4.21.", "send": "Send", "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", diff --git a/src/__tests__/proxy-onboarding-redirect.test.ts b/src/__tests__/proxy-onboarding-redirect.test.ts index bd8edb9ae..3e7a61dc4 100644 --- a/src/__tests__/proxy-onboarding-redirect.test.ts +++ b/src/__tests__/proxy-onboarding-redirect.test.ts @@ -101,4 +101,33 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => { expect([307, 308]).toContain(res.status); expect(res.headers.get("location")).toMatch(/\/auth\/login$/); }); + + /** + * v1.4.22 W5 reconcile (Sec-MED-2) — exact-match `/onboarding` + * plus subroutes. A hypothetical `/onboarding-export` route must + * NOT inherit the public-pass-through that the literal + * `/onboarding` carries. + */ + it("does NOT treat /onboarding-export as the onboarding surface (exact-match guard)", () => { + const res = proxy(makeRequest("/onboarding-export")); + // Unauthenticated request to a non-public route: redirects to + // /auth/login. If the loose `startsWith("/onboarding")` matcher + // were still in place it would short-circuit through public-path + // handling and 200 instead. + expect([307, 308]).toContain(res.status); + expect(res.headers.get("location")).toMatch(/\/auth\/login$/); + }); + + it("still treats subroutes /onboarding/ as the onboarding surface", () => { + const res = proxy( + makeRequest("/onboarding/step-2", { + healthlog_session: "sess-1", + hl_onboarding: "pending", + }), + ); + // /onboarding/step-2 is part of the onboarding surface so the + // pending redirect short-circuits. + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + }); }); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 38e16e5cc..e382ef10e 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target"; +import { berlinDayKey } from "@/lib/analytics/berlin-day"; import { calculateCompliance } from "@/lib/analytics/compliance"; import { computeHealthScore, @@ -333,6 +334,11 @@ async function computeCorrelationHypotheses(userId: string): Promise<{ return { bpCompliance, moodPulse, weightWeekday }; } +// v1.4.22 W5 reconcile (Code-MED-3) — `berlinDayKey()` lifted to +// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline +// bucketing shares the same Europe/Berlin contract. The +// `weekday: "short"` formatter still lives here because it's only +// used by `berlinIsoWeekday()` below. const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", @@ -341,14 +347,6 @@ const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", { weekday: "short", }); -function berlinDayKey(d: Date): string { - const parts = BERLIN_DATE_PARTS.formatToParts(d); - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const m = parts.find((p) => p.type === "month")?.value ?? "01"; - const day = parts.find((p) => p.type === "day")?.value ?? "01"; - return `${y}-${m}-${day}`; -} - function dateFromBerlinKey(key: string): Date { // Anchor to UTC midnight — the date is a sortable bucket label rather // than a wall-clock timestamp, so DST drift is irrelevant. diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 68f7696b6..7c77c54aa 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -14,6 +14,7 @@ import { import { calculateCompliance } from "@/lib/analytics/compliance"; import { pairByTimestamp } from "@/lib/analytics/correlations"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; +import { berlinDayKey } from "@/lib/analytics/berlin-day"; import { classifyPulseByTarget, getPersonalizedPulseTarget, @@ -202,9 +203,14 @@ export const GET = apiHandler(async () => { .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); if (data.length < 3) return null; + // v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar + // day, not UTC. A 23:30-Berlin reading on Tuesday otherwise + // lands in Wednesday's UTC bucket and the sparkline drifts a + // day on cross-DST boundaries. Aligns with `berlinDayKey()` + // already used by the dashboard analytics route. const byDay = new Map(); for (const m of data) { - const day = m.measuredAt.toISOString().slice(0, 10); + const day = berlinDayKey(m.measuredAt); const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; bucket.sum += m.value; bucket.count += 1; diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a59f2b19f..bb09f4f7e 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -561,7 +562,7 @@ export default function InsightsPage() { // preference so a toggle flip in Settings → Dashboard updates both // surfaces atomically. const { data: layoutData } = useQuery({ - queryKey: ["user", "dashboardWidgets"], + queryKey: queryKeys.dashboardWidgets(), queryFn: async () => { const res = await fetch("/api/dashboard/widgets"); if (!res.ok) throw new Error("Failed"); diff --git a/src/app/page.tsx b/src/app/page.tsx index 345e16476..9d8de9d24 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -208,7 +208,7 @@ export default function DashboardPage() { }); const { data: layoutData } = useQuery({ - queryKey: ["user", "dashboardWidgets"], + queryKey: queryKeys.dashboardWidgets(), queryFn: async () => { const res = await fetch("/api/dashboard/widgets"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 014a383be..e003ddfa2 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Bot, ChevronRight, Sparkles, User } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -63,7 +63,33 @@ export function MessageThread({ // persisted twin lands — comparing on `messageId` keeps the // transition seamless while never accidentally hiding an in-flight // bubble (during streaming `messageId` is null). + // v1.4.22 W5 reconcile (Code-MED-3) — streaming/persisted-twin + // race. On slow connections SSE `done` fires before the + // invalidate-refetch resolves; the persisted twin lands while the + // streaming bubble is still painted, producing a 200-500ms + // duplicate render. Hide the persisted twin (matched on + // streaming.messageId) for a 150ms grace window after it first + // appears so the streaming bubble stays alone until the streaming + // state cleans up naturally on the next `send`. + const [graceWindow, setGraceWindow] = useState(false); + const lastPersistedIdRef = useRef(null); + useEffect(() => { + const persistedId = streaming?.messageId ?? null; + if ( + persistedId && + persistedId !== lastPersistedIdRef.current && + messages.some((m) => m.id === persistedId) + ) { + lastPersistedIdRef.current = persistedId; + setGraceWindow(true); + const handle = setTimeout(() => setGraceWindow(false), 150); + return () => clearTimeout(handle); + } + }, [streaming?.messageId, messages]); + const suppressedTwinId = graceWindow ? streaming?.messageId : null; + const streamingPersisted = + !graceWindow && streaming?.messageId != null && messages.some((m) => m.id === streaming.messageId); const streamingActive = @@ -120,15 +146,21 @@ export function MessageThread({ "scroll-smooth", )} > - {messages.map((m) => ( - - ))} + {messages.map((m) => { + // v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted + // twin during the 150ms grace window so the streaming bubble + // stays alone on slow connections. + if (m.id === suppressedTwinId) return null; + return ( + + ); + })} {streamingActive && streaming && ( // role=log + aria-live=polite so screen-reader users hear the // assistant prose announce as tokens land. aria-relevant=text diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index fec709d69..35c297105 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type DashboardLayout, type DashboardWidgetId, @@ -54,7 +55,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const queryClient = useQueryClient(); const { data: remote, isLoading } = useQuery({ - queryKey: ["user", "dashboardWidgets"], + queryKey: queryKeys.dashboardWidgets(), queryFn: async () => { const res = await fetch("/api/dashboard/widgets"); if (!res.ok) throw new Error("failed"); @@ -81,7 +82,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { return (await res.json()).data as DashboardLayout; }, onSuccess: (saved) => { - queryClient.setQueryData(["user", "dashboardWidgets"], saved); + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); setDraft(null); toast.success(t("dashboard.layoutSaveSuccess")); }, @@ -95,7 +96,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { return (await res.json()).data as DashboardLayout; }, onSuccess: (saved) => { - queryClient.setQueryData(["user", "dashboardWidgets"], saved); + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); setDraft(null); toast.success(t("dashboard.layoutResetSuccess")); }, diff --git a/src/lib/analytics/__tests__/berlin-day.test.ts b/src/lib/analytics/__tests__/berlin-day.test.ts new file mode 100644 index 000000000..2ea78ae06 --- /dev/null +++ b/src/lib/analytics/__tests__/berlin-day.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { berlinDayKey } from "../berlin-day"; + +/** + * v1.4.22 W5 reconcile (Code-MED-3) — pin that the helper buckets a + * 23:30-Berlin reading on Tuesday into the Tuesday key, regardless of + * UTC offset (CET / CEST, DST). + */ +describe("berlinDayKey", () => { + it("buckets a 23:30-Berlin reading on Tuesday into Tuesday's key (CEST)", () => { + // 2025-10-25T21:30:00Z = 2025-10-25T23:30 CEST (Saturday before DST end) + expect(berlinDayKey(new Date("2025-10-25T21:30:00Z"))).toBe("2025-10-25"); + }); + + it("buckets a 23:30-Berlin reading after DST end into the right Berlin day", () => { + // 2025-10-26 is the DST end day in Europe/Berlin (CET takes over). + // 2025-10-26T22:30:00Z = 2025-10-26T23:30 CET — still Sunday in Berlin. + expect(berlinDayKey(new Date("2025-10-26T22:30:00Z"))).toBe("2025-10-26"); + }); + + it("crosses the UTC-midnight boundary in Berlin's favour", () => { + // 2026-05-09T22:30:00Z falls on 2026-05-10T00:30 CEST — Berlin says + // Sunday, UTC says Saturday. The helper must follow Berlin. + expect(berlinDayKey(new Date("2026-05-09T22:30:00Z"))).toBe("2026-05-10"); + }); + + it("returns YYYY-MM-DD format (sortable, padded)", () => { + expect(berlinDayKey(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09"); + expect(berlinDayKey(new Date("2026-12-09T12:00:00Z"))).toBe("2026-12-09"); + }); +}); diff --git a/src/lib/analytics/berlin-day.ts b/src/lib/analytics/berlin-day.ts new file mode 100644 index 000000000..639022be9 --- /dev/null +++ b/src/lib/analytics/berlin-day.ts @@ -0,0 +1,35 @@ +/** + * v1.4.22 W5 reconcile (Code-MED-1) — shared Berlin-day-key helper. + * + * The CLAUDE.md timezone convention is "Europe/Berlin for display, + * UTC in database". The dashboard analytics route used a private + * `berlinDayKey()` (defined in `src/app/api/analytics/route.ts:330`) + * to bucket measurements by Berlin calendar day. The targets route's + * `sparklinePoints()` was bucketing by UTC `YYYY-MM-DD` — a 23:30 + * Berlin reading on Tuesday landed in Wednesday's bucket, drifting + * the sparkline 1 day on cross-DST boundaries. + * + * Lift the helper into `src/lib/analytics/` so every display surface + * shares the same bucket contract. + */ + +const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +/** + * Format a `Date` as a `YYYY-MM-DD` string anchored to Europe/Berlin + * calendar day. Used as a stable key for daily aggregation so a + * 23:30-Berlin reading on Tuesday lands in Tuesday's bucket + * regardless of UTC offset (CET / CEST, DST). + */ +export function berlinDayKey(d: Date): string { + const parts = BERLIN_DATE_PARTS.formatToParts(d); + const y = parts.find((p) => p.type === "year")?.value ?? "1970"; + const m = parts.find((p) => p.type === "month")?.value ?? "01"; + const day = parts.find((p) => p.type === "day")?.value ?? "01"; + return `${y}-${m}-${day}`; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 6b818aca8..0d67bd100 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -74,6 +74,15 @@ export const queryKeys = { adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const, bugreportStatus: () => ["bugreport", "status"] as const, + + /** + * v1.4.22 W5 reconcile (Code-LOW-5) — `["user", "dashboardWidgets"]` + * was duplicated as a literal at three call sites (dashboard, + * insights, settings/dashboard-layout). One typo turns into a + * silent cache miss + extra fetch; the centralised key defends + * against the same query-key-collision class as `analytics()`. + */ + dashboardWidgets: () => ["user", "dashboardWidgets"] as const, }; /** diff --git a/src/proxy.ts b/src/proxy.ts index a4f4a3e90..4d358cc21 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -25,11 +25,24 @@ const PUBLIC_PATHS = [ "/api/integrations/moodlog/webhook", "/api/auth/codex/callback", "/api/ingest/", - "/onboarding", + // `/onboarding` itself + its subroutes are matched exactly via + // `isPublicPath()` so we don't admit `/onboarding-export` etc. "/robots.txt", ]; +/** + * v1.4.22 W5 reconcile (Sec-MED-2) — `/onboarding` matches exact + * page + subroutes (`/onboarding`, `/onboarding/*`) only, NOT the + * loose `pathname.startsWith("/onboarding")` family that would admit + * `/onboarding-export`, `/onboarding.json`, etc. Every other entry + * is either a literal terminal path (`/api/version`, `/robots.txt`) + * or already trailing-slashed (`/auth/`, `/api/auth/`); the lone + * exception was the unsegmented `/onboarding` literal. + */ function isPublicPath(pathname: string): boolean { + if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { + return true; + } return PUBLIC_PATHS.some((p) => pathname.startsWith(p)); } @@ -149,7 +162,12 @@ export function proxy(request: NextRequest) { // for the surfaces that need to redirect away. const onboardingPending = request.cookies.get("hl_onboarding")?.value === "pending"; - if (onboardingPending && !pathname.startsWith("/onboarding")) { + // v1.4.22 W5 reconcile (Sec-MED-2) — exact match the redirect + // short-circuit so a hypothetical `/onboarding-export` route can + // never inherit the silent-pass-through. + const isOnboardingSurface = + pathname === "/onboarding" || pathname.startsWith("/onboarding/"); + if (onboardingPending && !isOnboardingSurface) { return NextResponse.redirect(new URL("/onboarding", request.url)); } }