chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix

Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 12:51:41 +02:00
1 parent 30ab2112db
commit f92e644154
87 files changed
+7939 -2950

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ export function LayoutCoachFab() {
// FAB returns null when the master OR the coach sub-flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
// A privacy-leaning user who toggles "Hide Coach" in Settings → AI
// A privacy-leaning user who toggles "Hide Coach" in Settings → Insights
// gets the FAB short-circuited here, identical to the global-off
// branch above. The hook defaults to `false` in the absence of a
// QueryClient (legacy SSR-only snapshot tests).
@@ -130,8 +130,10 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("hint string localises to German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
// Anchor on a stable German fragment from the hint copy.
expect(html).toContain("Ziehe den Griff");
// v1.4.48 L9 — copy trimmed to ~12 words so screen readers do
// not spend ~6 s reading the hint on focus. Anchor on the
// shortened phrase.
expect(html).toContain("Pfeiltasten Hoch/Runter");
});
});
@@ -61,7 +61,7 @@ function buildUser(disableCoach: boolean): AuthUser {
import { AiSection } from "../ai-section";
function makeFetch(ok: boolean = true) {
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
@@ -69,7 +69,7 @@ function makeFetch(ok: boolean = true) {
data: { disableCoach: body.disableCoach ?? false },
}),
{
status: ok ? 200 : 500,
status: 200,
headers: { "Content-Type": "application/json" },
},
);
@@ -125,31 +125,6 @@ describe("Settings — DisableCoachCard", () => {
expect(html).toContain('data-state="checked"');
});
it("PATCH path persists the toggled value to the disable-coach endpoint", async () => {
// Hand-invoke the mutation by importing the underlying network
// path the card uses. Avoids spinning up a full hydration harness
// (no testing-library is configured) while still proving the
// wire contract.
const fetchSpy = makeFetch();
vi.stubGlobal("fetch", fetchSpy);
const res = await fetch("/api/auth/me/disable-coach", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disableCoach: true }),
});
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/me/disable-coach",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ disableCoach: true }),
}),
);
const json = (await res.json()) as { data: { disableCoach: boolean } };
expect(json.data.disableCoach).toBe(true);
});
it("Switch description string matches the QoL audit (M2) copy", () => {
const html = render();
// The Marc-voice contract: "Hides the Coach button and drawer
@@ -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)");
});
});
+25 -46
View File
@@ -14,9 +14,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
interface VersionPayload {
@@ -148,53 +148,32 @@ export function AboutSection() {
// sources/docs links. Settings → Account still has its own
// "Restart onboarding tour" — both buttons share the same flow.
const [replayingTour, setReplayingTour] = useState(false);
const [tourMessage, setTourMessage] = useState<string | null>(null);
const [tourMessageType, setTourMessageType] = useState<
"success" | "error" | null
>(null);
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (a "success" message rendered in the
// destructive colour was the latent bug class).
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourMessage(null);
setTourMessageType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. The helper returns a discriminated result; this surface
// only owns the translation + feedback rendering.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
if (!res.ok) {
setTourMessage(t("settings.savingError"));
setTourMessageType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker into
// sessionStorage so the next dashboard mount opens the tour
// even when the 24 h auto-launch gate would otherwise suppress
// it (e.g. a first-day user who clicked "Replay the tour"
// from About 30 min after finishing the wizard). The marker is
// session-scoped — a page reload clears it — and consumed
// exactly once by the launcher on its next mount.
if (user?.id) setTourForceLaunch(user.id);
// Fire the same window event Settings → Account uses; a
// dashboard already mounted in another tab / background reopens
// the spotlight immediately. When the user lands on the
// dashboard next, the launcher reads the freshly-flipped
// `onboardingTourCompleted = false` from `/api/auth/me` and the
// force-launch marker set above; either path lands on the tour.
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
setTourMessage(t("onboarding.tour.restartConfirmation"));
setTourMessageType("success");
} catch {
setTourMessage(t("common.networkError"));
setTourMessageType("error");
} finally {
setReplayingTour(false);
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setReplayingTour(false);
}
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
@@ -423,16 +402,16 @@ export function AboutSection() {
{t("settings.about.tourReplay")}
</Button>
</div>
{tourMessage && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMessageType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMessage}
{tourFeedback.text}
</p>
)}
</div>
+29 -44
View File
@@ -39,11 +39,11 @@ import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { TimezonePicker } from "@/components/settings/timezone-picker";
@@ -135,12 +135,15 @@ export function AccountSection() {
// dispatches a `healthlog:tour-restart` window event so the dashboard's
// <TourLauncher> picks it up immediately on the user's next nav. The
// confirmation message goes through the same announce channel as
// every other settings save (status: "success" | "error" | null).
// every other settings save.
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (same shape as `<AboutSection>`'s
// replay button so the surface stays mirrored).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourMsg, setTourMsg] = useState<string | null>(null);
const [tourMsgType, setTourMsgType] = useState<"success" | "error" | null>(
null,
);
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -280,43 +283,25 @@ export function AccountSection() {
async function handleRestartTour() {
setTourRestarting(true);
setTourMsg(null);
setTourMsgType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
setTourMsg(t("settings.savingError"));
setTourMsgType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker in
// sessionStorage so the next dashboard mount launches the tour
// even when the new 24 h auto-launch gate would otherwise
// suppress it (e.g. user just finished the wizard 5 min ago).
// The launcher reads-and-clears the marker on mount.
if (user?.id) setTourForceLaunch(user.id);
// Refetch the auth payload so `<TourLauncher>` re-evaluates
// with the new flag, AND fire a window event so a launcher
// already mounted on the dashboard reopens immediately
// without waiting for a navigation.
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. Account additionally refetches the auth payload so the
// `onboardingTourCompleted` flag the launcher reads matches the
// server flip immediately — About has no auth handle to refetch
// and relies on the next navigation re-running `/api/auth/me`.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
await refetch();
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if the dashboard is already mounted */
}
setTourMsg(t("onboarding.tour.restartConfirmation"));
setTourMsgType("success");
} catch {
setTourMsg(t("common.networkError"));
setTourMsgType("error");
} finally {
setTourRestarting(false);
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setTourRestarting(false);
}
async function handleChangePassword(e: React.FormEvent) {
@@ -631,16 +616,16 @@ export function AccountSection() {
{t("onboarding.tour.restart")}
</Button>
</div>
{tourMsg && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMsgType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMsg}
{tourFeedback.text}
</p>
)}
</div>
+35 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -198,6 +198,34 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.48 M2 — 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 "Coach hidden"
// banner. 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 checked = optimistic ?? user?.disableCoach ?? false;
const mutation = useMutation({
@@ -227,6 +255,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// re-fetches /api/auth/me with the updated `disableCoach` field.
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
@@ -236,6 +265,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
: t("settings.ai.disableCoach.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
@@ -243,6 +273,10 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -29,6 +29,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -52,6 +53,10 @@ import {
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
* and either input mode flushes via the same Save mutation. Exported so
* the unit test can pin the contract without spinning up a DndContext.
*
* v1.4.48 M6a — also drives the arrow-button `move()` handler below;
* the previous in-file swap-and-renumber implementation was a second
* copy of the same logic.
*/
export function reorderWidgets(
widgets: readonly { id: string; order: number }[],
@@ -70,6 +75,43 @@ export function reorderWidgets(
return next.map((w, i) => ({ ...w, order: i }));
}
/**
* v1.4.48 M6a — merge the `{ id, order }` shape returned by
* `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so
* the section's draft state keeps every per-row flag (`visible`,
* `tileVisible`) while the order is rewritten. The arrow buttons and
* the @dnd-kit drag-end handler both flow through this helper so
* neither surface can silently drop a flag.
*/
function mergeReorderIntoLayout(
widgets: DashboardLayout["widgets"],
reordered: readonly { id: string; order: number }[],
): DashboardLayout["widgets"] {
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) {
// 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 };
});
}
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
weight: "dashboard.weight",
bp: "dashboard.bloodPressure",
@@ -199,16 +241,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
* neighbour id is derived from the sorted layout index + delta;
* out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown)
* short-circuit before the helper sees them so the button disabled
* state stays the single source of truth for the boundary.
*/
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
if (!layout) return;
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((w) => w.id === widgetId);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
[sorted[idx], sorted[targetIdx]] = [sorted[targetIdx], sorted[idx]];
const neighbourId = sorted[targetIdx].id;
const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId);
setDraft({
...layout,
widgets: sorted.map((w, i) => ({ ...w, order: i })),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -229,17 +280,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
String(active.id),
String(over.id),
);
// `reorderWidgets` operates on the pure { id, order } shape; map back
// to the full DashboardWidgetConfig so `visible` / `tileVisible` are
// preserved on every row.
const byId = new Map(layout.widgets.map((w) => [w.id, w]));
setDraft({
...layout,
widgets: reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) return { ...r, order: i } as never;
return { ...original, order: i };
}),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -410,10 +453,14 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.47 W4 — single shared aria-describedby target for all
drag handles. Screen readers read this once per focused
handle; sighted users see it in the native browser tooltip
via the matching `title` attribute on each handle. */}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
via the matching `title` attribute on each handle.
v1.4.48 L8 — gate on widget count so an empty layout never
orphans the paragraph (no handles to describe). */}
{layout.widgets.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
)}
@@ -509,9 +556,15 @@ function SortableWidgetRow({
isDragging,
} = useSortable({ id: widget.id });
// v1.4.48 L7 — honour the OS `prefers-reduced-motion` preference. The
// rest of HealthLog pairs every transition with a `motion-reduce`
// companion; dnd-kit's default `transform 250ms ease` was the lone
// surface that ignored it. Short-circuit to `none` when reduced
// motion is requested so dragged rows snap to place instead of
// sliding through the list.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
transition: prefersReducedMotion() ? "none" : transition,
};
const tileChecked =
+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);
}
@@ -63,20 +63,6 @@ describe("<Textarea>", () => {
expect(html).toContain("sm:text-sm");
});
it("is wrapped in React.forwardRef so refs reach the <textarea>", () => {
// React tags forwardRef components with a sentinel symbol on
// `$$typeof`. Verifying the sentinel keeps the assertion
// dependency-free (no testing-library / DOM env needed) while
// still catching a regression that strips the wrapper.
const forwardRefSymbol = Symbol.for("react.forward_ref");
const componentMeta = Textarea as unknown as {
$$typeof: symbol;
render?: unknown;
};
expect(componentMeta.$$typeof).toBe(forwardRefSymbol);
expect(typeof componentMeta.render).toBe("function");
});
it("forwards rows / placeholder / maxLength to the underlying element", () => {
const html = renderToStaticMarkup(
<Textarea