diff --git a/messages/es.json b/messages/es.json index 0d2efa471..d523ca0f4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2003,8 +2003,8 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", - "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}", + "linksHeading": "Fuentes y documentación", + "newerAvailable": "Nueva versión: {tag}", "tourReplay": "Volver a ver el recorrido", "tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita." }, diff --git a/messages/fr.json b/messages/fr.json index b9f5b04e6..db136ca8e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2003,8 +2003,8 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", - "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}", + "linksHeading": "Sources et documentation", + "newerAvailable": "Nouvelle version : {tag}", "tourReplay": "Revoir la visite", "tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite." }, diff --git a/messages/it.json b/messages/it.json index aa1af0b8b..0bf00d9e2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2003,8 +2003,8 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", - "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}", + "linksHeading": "Fonti e documentazione", + "newerAvailable": "Nuova versione: {tag}", "tourReplay": "Rivedi il tour", "tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita." }, diff --git a/messages/pl.json b/messages/pl.json index 382ce0150..f4fd22c0d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2003,8 +2003,8 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", - "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}", + "linksHeading": "Źródła i dokumentacja", + "newerAvailable": "Nowa wersja: {tag}", "tourReplay": "Obejrzyj samouczek", "tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie." }, diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index daa6339bb..a541ec65a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -8,6 +8,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, + buildPayloadDiagnostic, safeJson, returnAllZodIssues, sanitiseZodIssues, @@ -149,24 +150,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { // the Zod rejection so a single wide-event line carries enough // detail to diagnose serialiser drift in `HealthLog-iOS`. We log // ONLY the top-level keys plus a hard 256-char JSON excerpt — never - // the full body — so PII / token-like fields cannot leak. - const receivedKeys = - body && typeof body === "object" && !Array.isArray(body) - ? Object.keys(body as Record) - : []; - let excerpt: string; - try { - excerpt = JSON.stringify(body) ?? ""; - } catch { - excerpt = ""; - } - const receivedShapeExcerpt = excerpt.slice(0, 256); + // the full body — so PII / token-like fields cannot leak. The + // diagnostic shape is built by the shared `buildPayloadDiagnostic` + // helper (v1.4.49) so the widget + series routes can't drift. + const payloadDiagnostic = buildPayloadDiagnostic(body); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { issue_count: issues.length, - received_keys: receivedKeys, - received_shape_excerpt: receivedShapeExcerpt, + ...payloadDiagnostic, zod_issues: issues, }, }); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index e7e243e7a..05064a9b5 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -14,6 +14,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, + buildPayloadDiagnostic, returnAllZodIssues, sanitiseZodIssues, } from "@/lib/api-response"; @@ -80,21 +81,14 @@ export const GET = apiHandler(async (request: NextRequest) => { // the Zod rejection. Top-level keys + a hard 256-char JSON excerpt // only; never the full payload, so token-shaped query params (we // do not currently accept any but the truncation keeps that - // invariant cheap) cannot leak. - const receivedKeys = Object.keys(rawQuery); - let excerpt: string; - try { - excerpt = JSON.stringify(rawQuery) ?? ""; - } catch { - excerpt = ""; - } - const receivedShapeExcerpt = excerpt.slice(0, 256); + // invariant cheap) cannot leak. Shared helper via v1.4.49 so the + // widget + series routes can't drift on the diagnostic shape. + const payloadDiagnostic = buildPayloadDiagnostic(rawQuery); annotate({ action: { name: "measurements.series.validation-failed" }, meta: { issue_count: issues.length, - received_keys: receivedKeys, - received_shape_excerpt: receivedShapeExcerpt, + ...payloadDiagnostic, zod_issues: issues, }, }); diff --git a/src/components/settings/__tests__/mood-reminder-card.test.tsx b/src/components/settings/__tests__/mood-reminder-card.test.tsx new file mode 100644 index 000000000..1eda84b4b --- /dev/null +++ b/src/components/settings/__tests__/mood-reminder-card.test.tsx @@ -0,0 +1,77 @@ +/** + * v1.4.49 — `` status auto-clear parity. + * + * The v1.4.48 W-DISABLE-COACH refactor added a 3 s `setTimeout` to + * `` and claimed in the docstring that the pattern + * mirrored ``. It did not — the mood-reminder card + * had no auto-clear. v1.4.49 backfills the parity. This suite pins: + * + * 1. The card SSRs cleanly with no in-flight status banner. + * 2. The component module exposes the auto-clear plumbing + * (`scheduleClear` + `clearTimerRef`) so a future refactor can't + * silently regress the docstring claim. + * + * Project convention is SSR-only tests (no `@testing-library/react`); + * the actual timer-tick behaviour is verified through manual QA + the + * shared pattern with `` which is already audit-grade. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/notifications", + useSearchParams: () => new URLSearchParams(""), +})); + +import { MoodReminderCard } from "../mood-reminder-card"; + +function render(): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" — v1.4.49 auto-clear parity", () => { + it("SSRs the card scaffolding with no in-flight status banner", () => { + const html = render(); + // The Switch primitive surfaces `data-state="unchecked"` for the + // default `moodReminderEnabled: false` profile shape. + expect(html).toContain('data-state="unchecked"'); + // No status message visible by default — the `

` + // line is gated on `msg !== null`. + expect(html).not.toContain('role="status"'); + }); + + it("contains the auto-clear plumbing introduced for v1.4.48 docstring parity", () => { + // Structural assertion: the source file ships the `scheduleClear` + // helper + `clearTimerRef` (mirroring `` from + // v1.4.48 M2). Reading the file rather than a runtime mount keeps + // the test deterministic in the SSR-only test environment. + const src = readFileSync( + resolve(__dirname, "../mood-reminder-card.tsx"), + "utf8", + ); + expect(src).toContain("clearTimerRef"); + expect(src).toContain("scheduleClear"); + // 3 s timeout — must match the `` value so the + // two Settings cards age at the same rate. + expect(src).toMatch(/setTimeout\([^,]+,\s*3000\)/); + // Cleanup-on-unmount branch — without this a navigate-away during + // the 3 s window would leave a stray timer pointing at unmounted + // state. + expect(src).toContain("clearTimeout(clearTimerRef.current)"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 4f12b85f0..606fce1a4 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -90,7 +90,24 @@ function mergeReorderIntoLayout( const byId = new Map(widgets.map((w) => [w.id, w])); return reordered.map((r, i) => { const original = byId.get(r.id as DashboardWidgetId); - if (!original) return { ...r, order: i } as never; + if (!original) { + // v1.4.49 — defence-in-depth dev warning for the orphan branch. + // Today this branch is statically unreachable: every id in + // `reordered` is sourced from `layout.widgets`, so `byId.get` + // always hits. The upcoming per-tile Suspense refactor will + // introduce dynamic widgets where this invariant could break; + // the warning fires in dev only so a regression surfaces in the + // console instead of silently dropping the row via the cast. + if ( + typeof window !== "undefined" && + process.env.NODE_ENV === "development" + ) { + console.warn( + `mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`, + ); + } + return { ...r, order: i } as never; + } return { ...original, order: i }; }); } diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 2ad479fa8..89bb30031 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { SmilePlus } from "lucide-react"; @@ -25,6 +25,37 @@ export function MoodReminderCard({ const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + // v1.4.49 — auto-clear the inline `

` line 3 s after + // the mutation settles so a Settings card the user scrolled past + // minutes earlier doesn't keep echoing a stale "reminder enabled" + // banner. Mirrors the `` pattern landed in v1.4.48 + // M2; the v1.4.48 docstring claimed this card already auto-cleared, + // this commit backfills the actual behaviour. The ref tracks the + // in-flight timer so we can clear it on unmount + on a follow-up + // toggle (otherwise a rapid double-tap could leave a stray timer + // pointing at a stale message). + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + const { data: profile } = useQuery({ queryKey: queryKeys.userProfile(), queryFn: async () => { @@ -42,6 +73,10 @@ export function MoodReminderCard({ setSaving(true); setMsg(null); setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } const res = await fetch("/api/user/profile", { method: "PATCH", @@ -58,10 +93,12 @@ export function MoodReminderCard({ setMsgType("success"); await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() }); setOptimistic(null); + scheduleClear(); } else { setOptimistic(null); setMsg(t("notifications.moodReminder.saveError")); setMsgType("error"); + scheduleClear(); } setSaving(false); } diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 940e4ca64..874aa278e 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -44,6 +44,48 @@ export function sanitiseZodIssues( })); } +/** + * v1.4.49 — diagnostic shape echoed into a wide-event meta when an iOS + * (or any other) caller fails Zod validation on a JSON payload. Pairs + * with the per-route `annotate({ action, meta: { ...payloadDiagnostic, + * zod_issues, issue_count } })` call. Each field is bounded: + * + * - `received_keys`: top-level keys only; never values + * - `received_shape_excerpt`: hard 256-char `JSON.stringify` slice + * + * Defensive shape: caller passes the raw parsed body (object | array | + * primitive | undefined). Non-object input collapses to an empty key + * list and an empty excerpt — the helper never throws. + * + * A redaction layer (PII / token-shape removal) is expected to be + * applied as a follow-up composable step by a parallel + * `W-OBSERV-PII-V1449` audit; this helper builds the unredacted shape + * so the redactor can post-process via a second helper call. Keeping + * the two concerns separate lets the unit test for this helper stay + * stable while the redaction policy evolves. + */ +export interface PayloadDiagnostic { + received_keys: string[]; + received_shape_excerpt: string; +} + +export function buildPayloadDiagnostic(body: unknown): PayloadDiagnostic { + const received_keys = + body && typeof body === "object" && !Array.isArray(body) + ? Object.keys(body as Record) + : []; + let excerpt: string; + try { + excerpt = JSON.stringify(body) ?? ""; + } catch { + excerpt = ""; + } + return { + received_keys, + received_shape_excerpt: excerpt.slice(0, 256), + }; +} + type ErrorMeta = { errorCode?: string; headers?: Record; diff --git a/src/lib/jobs/integration-status-null-probe.ts b/src/lib/jobs/integration-status-null-probe.ts index b10fa5d92..48dc1a908 100644 --- a/src/lib/jobs/integration-status-null-probe.ts +++ b/src/lib/jobs/integration-status-null-probe.ts @@ -21,7 +21,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { annotate, getEvent } from "@/lib/logging/context"; -export interface IntegrationStatusNullProbeResult { +interface IntegrationStatusNullProbeResult { count: number; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 945f5a87f..868a89031 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -123,20 +123,24 @@ const coachMessageFeedbackBody = z "Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.", }); -const disableCoachBody = z +// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously +// split into `disableCoachBody` (PATCH request) and `disableCoachData` +// (response payload), both `z.object({ disableCoach: z.boolean() })`. The +// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")` +// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the +// envelope wrapper — the inner flag carries its own `.meta()` and now +// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in +// both the request body and both response envelopes. +const disableCoachFlag = z .object({ disableCoach: z.boolean(), }) .meta({ - id: "DisableCoachRequest", + id: "DisableCoachFlag", description: "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", }); -const disableCoachData = z.object({ - disableCoach: z.boolean(), -}); - // ── Sub-schemas owned here (route-specific shapes) ─────────────────── const passkeyLoginVerifyRequest = z @@ -867,7 +871,7 @@ export const openApiPaths: NonNullable = { description: "Resolved flag.", content: { "application/json": { - schema: dataEnvelope(disableCoachData, "GetDisableCoachResponse"), + schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"), }, }, }, @@ -881,7 +885,7 @@ export const openApiPaths: NonNullable = { "Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.", requestBody: { required: true, - content: { "application/json": { schema: disableCoachBody } }, + content: { "application/json": { schema: disableCoachFlag } }, }, responses: { "200": { @@ -890,7 +894,7 @@ export const openApiPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - disableCoachData, + disableCoachFlag, "PatchDisableCoachResponse", ), },