mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
7bc8e572b1
commit
dca9af9c71
12 files changed
+208
-45
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user