mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:
- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
cross-site flow depends on it; sibling `healthlog_session` stays
`Lax` for the Withings OAuth callback).
- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
`startsWith("/onboarding")` to exact path + subroute guard
(`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
A future `/onboarding-export` route can no longer inherit the
silent-pass-through. Same guard mirrored on the redirect short-
circuit. Two new proxy tests pin the contract.
- Code-MED-3: lift `berlinDayKey()` from
`src/app/api/analytics/route.ts` to a shared
`src/lib/analytics/berlin-day.ts` and route the targets
sparkline-bucketing through it. The targets route used to bucket
by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
edge cases.
- Code-MED (streaming race): on slow connections SSE `done` fires
before the invalidate-refetch resolves, producing a 200-500ms
duplicate-bubble render. Add a 150ms grace window on the
message-thread that suppresses the persisted twin while the
streaming bubble is still in flight.
- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
`insights.coach.settingsTooltip` (W3 removed the cog; the keys
had zero call sites and would carry confusing legacy if v1.4.23's
prompt-tuning panel picks a different key shape).
- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
in `queryKeys.dashboardWidgets()` and migrate the three call
sites (dashboard, insights, settings/dashboard-layout-section).
Same query-key-collision discipline as `queryKeys.analytics()`.
Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1631d8042c
commit
6c0c69cbce
13 files changed
+186
-30
No files matched your search
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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/<x> 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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, { sum: number; count: number }>();
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
@@ -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<string | null>(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) => (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
/>
|
||||
))}
|
||||
{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 (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{streamingActive && streaming && (
|
||||
// role=log + aria-live=polite so screen-reader users hear the
|
||||
// assistant prose announce as tokens land. aria-relevant=text
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+20
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user