chore(polish): v1.4.49 minor polish bundle

- MoodReminderCard auto-clear parity with DisableCoachCard (3 s
  setTimeout + useRef cleanup) — closes the v1.4.48 docstring
  parity gap
- mergeReorderIntoLayout dev-only console.warn on silent orphan
  drop — defence-in-depth for the upcoming per-tile Suspense
  refactor
- buildPayloadDiagnostic helper extracted from widget + series
  routes — single source of truth for the iOS payload-diff
  wide-event meta
- disableCoachBody + disableCoachData collapsed in OpenAPI routes
- IntegrationStatusNullProbeResult export dropped (local-only type)
- newerAvailable + linksHeading native translations for es / fr /
  it / pl
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 11:07:45 +02:00
1 parent 7bc8e572b1
commit dca9af9c71
12 files changed
+208 -45

No files matched your search

+2 -2
View File
@@ -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."
},
+2 -2
View File
@@ -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."
},
+2 -2
View File
@@ -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."
},
+2 -2
View File
@@ -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."
},
+6 -14
View File
@@ -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<string, unknown>)
: [];
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,
},
});
+5 -11
View File
@@ -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,
},
});
@@ -0,0 +1,77 @@
/**
* v1.4.49 — `<MoodReminderCard>` status auto-clear parity.
*
* The v1.4.48 W-DISABLE-COACH refactor added a 3 s `setTimeout` to
* `<DisableCoachCard>` and claimed in the docstring that the pattern
* mirrored `<MoodReminderCard>`. 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 `<DisableCoachCard>` 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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<MoodReminderCard isAuthenticated={true} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<MoodReminderCard> — 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 `<p role="status">`
// 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 `<DisableCoachCard>` 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 `<DisableCoachCard>` 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)");
});
});
@@ -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 };
});
}
+38 -1
View File
@@ -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<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.49 — auto-clear the inline `<p role="status">` 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 `<DisableCoachCard>` 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<ReturnType<typeof setTimeout> | 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);
}
+42
View File
@@ -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<string, unknown>)
: [];
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<string, string>;
@@ -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;
}
+13 -9
View File
@@ -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<ZodOpenApiObject["paths"]> = {
description: "Resolved flag.",
content: {
"application/json": {
schema: dataEnvelope(disableCoachData, "GetDisableCoachResponse"),
schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"),
},
},
},
@@ -881,7 +885,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"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<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
disableCoachData,
disableCoachFlag,
"PatchDisableCoachResponse",
),
},