mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
30ab2112db
commit
f92e644154
87 files changed
+7939
-2950
No files matched your search
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user