release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -16,6 +16,27 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const moduleGate = vi.hoisted(() => ({ nutrientsEnabled: true }));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: (moduleKey: string) =>
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
@@ -55,3 +76,30 @@ describe("<DashboardHeader> — greeting", () => {
expect(html).toMatch(/<h1[^>]*>Dashboard<\/h1>/);
});
});
describe("<DashboardHeader> — nutrients module gate", () => {
it("hides only the water quick-add item when nutrients are disabled", () => {
moduleGate.nutrientsEnabled = false;
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log water");
expect(html).toContain("Log measurement");
expect(html).toContain("Log mood");
expect(html).toContain("Log medication intake");
});
it("keeps the water quick-add item available when nutrients are enabled", () => {
moduleGate.nutrientsEnabled = true;
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).toContain("Log water");
});
});
@@ -50,9 +50,9 @@ vi.mock("@/hooks/use-auth", () => ({
import {
MedicationIntakeQuickAdd,
pickDefaultMedicationId,
type MedicationOption,
} from "../medication-intake-quick-add";
import { pickDefaultMedicationId } from "@/lib/medications/default-medication";
function makeMed(
id: string,
@@ -273,6 +273,41 @@ describe("<MedicationIntakeQuickAdd> — SSR contract", () => {
).toBeGreaterThanOrEqual(1);
});
it("renders the current-window medication as selected ahead of array order", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-12T08:00:00.000Z"));
mockUseQuery.mockReturnValue({
data: [
makeMed("first", { name: "Alpha" }),
makeMed("due", {
name: "Zulu",
nextDueAt: "2026-05-12T07:00:00.000Z",
nextDueOverdue: false,
schedules: [
{
id: "due-schedule",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
],
isLoading: false,
});
try {
const html = renderSSR(<MedicationIntakeQuickAdd />);
expect(html).toMatch(
/data-slot="select-value"[^>]*>[\s\S]*?Zulu[\s\S]*?<\/span>/,
);
} finally {
vi.useRealTimers();
}
});
it("does not leak raw i18n keys in either locale", () => {
mockUseQuery.mockReturnValue({ data: [], isLoading: false });
const en = renderSSR(<MedicationIntakeQuickAdd />, "en");
@@ -12,7 +12,7 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Activity, GlassWater, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -24,6 +24,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -35,6 +36,7 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -153,6 +155,12 @@ export function DashboardHeader({
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
{nutrientsEnabled && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -23,10 +23,11 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import type { ScheduleWindowInput } from "@/lib/medications/window-status";
import {
reduceCurrentWindowStatus,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
resolveMedicationSelectionId,
type DefaultMedicationOption,
} from "@/lib/medications/default-medication";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
@@ -73,23 +74,9 @@ interface Schedule extends ScheduleWindowInput {
dose: string | null;
}
export interface MedicationOption {
id: string;
name: string;
export interface MedicationOption extends DefaultMedicationOption {
dose: string;
active: boolean;
schedules: Schedule[];
lastTakenAt: string | null;
todayEventCount: number | null;
/**
* v1.16.9 — the server display-due verdict from `GET /api/medications`
* (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so
* the quick-add gates its "due now" heuristic exactly like the cards:
* a rolling cadence whose next dose is days away must not pre-select.
* Optional for older mocks; absent keeps the legacy band-only pick.
*/
nextDueAt?: string | null;
nextDueOverdue?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -112,67 +99,6 @@ function getDefaultIntakeAtValue(): string {
return local.toISOString().slice(0, 16);
}
/**
* Pick the medication that should be pre-selected when the sheet opens.
*
* Priority:
* 1. The active medication whose schedule window is currently open
* (or late / very_late) — this is the "due now" surface.
* 2. The single active medication when the user has exactly one.
* 3. The first active medication alphabetically (stable fallback).
*
* Returns the medication id or `null` when no active medication exists.
*/
export function pickDefaultMedicationId(
options: MedicationOption[],
now: Date = new Date(),
thresholds: { lateMinutes: number; missedMinutes: number } = {
lateMinutes: 120,
missedMinutes: 240,
},
/**
* v1.16.9 — the profile timezone the "due now" heuristic reasons in
* (the same source the cards use). Berlin stays the last-resort
* fallback for logged-out mounts and legacy fixtures.
*/
tz: string = "Europe/Berlin",
): string | null {
const actives = options.filter((m) => m.active);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
// day-scale dose taken early in its period must not pre-select.
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
now,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
tz,
nextDue:
m.nextDueAt === undefined
? undefined
: Number.isFinite(nextDueMs)
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
return status.status !== null && status.takenEarlyDaysAgo === null;
});
if (due) return due.id;
// Stable alphabetical fallback when nothing is currently due.
const sorted = [...actives].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
);
return sorted[0]?.id ?? null;
}
export function MedicationIntakeQuickAdd({
onSuccess,
onCancel,
@@ -235,16 +161,20 @@ export function MedicationIntakeQuickAdd({
const [takenAt, setTakenAt] = useState<string>(getDefaultIntakeAtValue);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectionNow] = useState(() => new Date());
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const formId = useId();
const defaultMedicationId = useMemo(
() => pickDefaultMedicationId(medications, new Date(), undefined, userTz),
[medications, userTz],
);
const medicationId = medicationOverride ?? defaultMedicationId ?? "";
const medicationId =
resolveMedicationSelectionId(
medications,
medicationOverride,
selectionNow,
undefined,
userTz,
) ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
const dose = doseOverride ?? selectedMedication?.dose ?? "";
@@ -431,7 +361,22 @@ export function MedicationIntakeQuickAdd({
placeholder={t(
"dashboard.medicationIntakeQuickAdd.medicationPlaceholder",
)}
/>
>
{selectedMedication ? (
<span className="flex items-center gap-2">
<Pill
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
<span>{selectedMedication.name}</span>
{selectedMedication.dose ? (
<span className="text-muted-foreground text-xs">
({selectedMedication.dose})
</span>
) : null}
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{sortedMedications.map((m) => (
@@ -1,8 +1,8 @@
"use client";
/**
* Quick-entry sheets for the dashboard: measurement, mood, and
* medication-intake forms behind the shared ResponsiveSheet primitive
* Quick-entry sheets for the dashboard: measurement, mood, medication
* intake, and water behind the shared ResponsiveSheet primitive
* (bottom-sheet on `<md`, centred Dialog on `md+`), plus the
* confirm-before-discard guard for dirty forms.
*
@@ -24,10 +24,11 @@ import {
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
import { useTranslations } from "@/lib/i18n/context";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | null;
"measurement" | "mood" | "medicationIntake" | "water" | null;
/**
* v1.11.3 F3 — guard the quick-entry sheets against an accidental
@@ -173,6 +174,11 @@ export function QuickEntrySheets({
footerSlot={medicationIntakeFooterEl}
/>
</ResponsiveSheet>
<WaterQuickAddSheet
open={open === "water"}
onOpenChange={handleQuickEntryOpenChange}
onSuccess={onClose}
/>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay