mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user