mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-tz
This commit is contained in:
commit
ada490c7e9
57 files changed
+1485
-99
No files matched your search
@@ -98,7 +98,9 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
|
||||
it("returns null when no active medications exist", () => {
|
||||
expect(pickDefaultMedicationId([])).toBeNull();
|
||||
expect(
|
||||
pickDefaultMedicationId([makeMed("a", { active: false })]),
|
||||
pickDefaultMedicationId([
|
||||
makeMed("a", { active: false, intakeActionable: false }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
|
||||
@@ -122,6 +123,9 @@ export function MedicationIntakeQuickAdd({
|
||||
// logged-out mounts behave unchanged.
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
// The account's own late / missed thresholds decide which dose counts as
|
||||
// due for the preselection, the same tiers the medication cards show.
|
||||
const dueThresholds = useMedicationDueThresholds();
|
||||
// The record this dose lands in, or null in the caller's own.
|
||||
const recordName = useActiveRecordName();
|
||||
|
||||
@@ -188,7 +192,7 @@ export function MedicationIntakeQuickAdd({
|
||||
medications,
|
||||
medicationOverride,
|
||||
selectionNow,
|
||||
undefined,
|
||||
dueThresholds,
|
||||
userTz,
|
||||
) ?? "";
|
||||
const selectedMedication = medications.find((m) => m.id === medicationId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
extractErrorMessage,
|
||||
handleFilePickerChange,
|
||||
OcrPageCoverageNote,
|
||||
OcrReviewDialog,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
@@ -201,3 +202,24 @@ describe("extractErrorMessage", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OcrPageCoverageNote> — a long PDF read from its first pages", () => {
|
||||
it("says how many of the pages were read", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OcrPageCoverageNote coverage={{ read: 10, total: 23 }} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="ocr-page-coverage"');
|
||||
expect(html).toContain("Only the first 10 of 23 pages were read.");
|
||||
});
|
||||
|
||||
it("renders nothing when the whole document was read", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OcrPageCoverageNote coverage={null} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,11 @@ export function OcrReviewDialog({
|
||||
// because the person has not filled it in yet. Once shown they follow the
|
||||
// edits, so fixing a field clears its mark at once.
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
// Set when the server read only the first pages of a long PDF.
|
||||
const [pageCoverage, setPageCoverage] = useState<{
|
||||
read: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
const reviewListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -184,6 +189,7 @@ export function OcrReviewDialog({
|
||||
function reset() {
|
||||
setStage("pick");
|
||||
setShowErrors(false);
|
||||
setPageCoverage(null);
|
||||
setRows([]);
|
||||
setPickedFile(null);
|
||||
setVisitId(null);
|
||||
@@ -207,6 +213,7 @@ export function OcrReviewDialog({
|
||||
return;
|
||||
}
|
||||
setRows(seeded);
|
||||
setPageCoverage(data.pageCoverage ?? null);
|
||||
setStage("review");
|
||||
},
|
||||
onError: (err) => toast.error(extractErrorMessage(err, t)),
|
||||
@@ -353,6 +360,8 @@ export function OcrReviewDialog({
|
||||
{t("labs.ocr.foundCount", { count: rows.length })}
|
||||
</p>
|
||||
|
||||
<OcrPageCoverageNote coverage={pageCoverage} />
|
||||
|
||||
{showErrors && rowErrors.size > 0 ? (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -389,3 +398,26 @@ export function OcrReviewDialog({
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server read only the first pages of a long PDF (`pageCoverage` on the
|
||||
* extract response). Said on the review screen, beside the rows, because the
|
||||
* person checking the list is the one who would otherwise assume the whole
|
||||
* report was read. Renders nothing when the whole document was read.
|
||||
*/
|
||||
export function OcrPageCoverageNote({
|
||||
coverage,
|
||||
}: {
|
||||
coverage: { read: number; total: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (!coverage) return null;
|
||||
return (
|
||||
<p data-slot="ocr-page-coverage" className="text-warning text-sm">
|
||||
{t("labs.ocr.pagesCapped", {
|
||||
read: coverage.read,
|
||||
total: coverage.total,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,15 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The reminder-thresholds read: `undefined` = not loaded (the 120 / 240
|
||||
// defaults apply), otherwise the account's own tiers.
|
||||
let mockThresholds:
|
||||
| { lateMinutes: number; missedMinutes: number; lowStockRunwayDays: null }
|
||||
| undefined;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useQuery: () => ({ data: mockThresholds }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -49,6 +56,7 @@ function medication(
|
||||
name: `Medication ${id}`,
|
||||
dose: "5 mg",
|
||||
active: true,
|
||||
intakeActionable: true,
|
||||
schedules: [],
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
@@ -82,6 +90,37 @@ describe("<LogIntakeDialog> medication default", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockThresholds = undefined;
|
||||
});
|
||||
|
||||
it("grades the due dose by the account's own missed threshold", () => {
|
||||
// 10:00 in Berlin, five hours after a 04:00–05:00 window closed. Under the
|
||||
// 240-minute default the dose is past missed and the alphabetical
|
||||
// fallback wins; an account that keeps a six-hour missed threshold still
|
||||
// sees it as very late, so it is the default.
|
||||
const meds = [
|
||||
medication("first", { name: "Alpha" }),
|
||||
medication("late", {
|
||||
name: "Zulu",
|
||||
nextDueAt: undefined,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "04:00",
|
||||
windowEnd: "05:00",
|
||||
daysOfWeek: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
expectSelected(renderDialog(meds), "first");
|
||||
mockThresholds = {
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 360,
|
||||
lowStockRunwayDays: null,
|
||||
};
|
||||
expectSelected(renderDialog(meds), "late");
|
||||
});
|
||||
it("selects an overdue current-window medication ahead of array order", () => {
|
||||
const html = renderDialog([
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
|
||||
|
||||
interface LogIntakeSchedule extends ScheduleWindowInput {
|
||||
label: string | null;
|
||||
@@ -122,6 +123,9 @@ export function LogIntakeDialog({
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
// The account's own late / missed thresholds decide which dose counts as
|
||||
// due for the preselection, the same tiers the medication cards show.
|
||||
const dueThresholds = useMedicationDueThresholds();
|
||||
const formId = useId();
|
||||
|
||||
const [medicationOverride, setMedicationOverride] = useState<string | null>(
|
||||
@@ -133,7 +137,7 @@ export function LogIntakeDialog({
|
||||
medications,
|
||||
medicationOverride,
|
||||
selectionNow,
|
||||
undefined,
|
||||
dueThresholds,
|
||||
userTz,
|
||||
) ?? "";
|
||||
const [slot, setSlot] = useState<string>(NO_SLOT);
|
||||
|
||||
@@ -63,6 +63,10 @@ import {
|
||||
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import {
|
||||
droppedAnyLinkKeys,
|
||||
type DroppedLinkKeysWire,
|
||||
} from "@/lib/mood/dropped-link-keys";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
// One catalogue for the chip strip and for every server-side reader of what it
|
||||
// writes. Split apart, the write side gained a locale and the read side did
|
||||
@@ -219,7 +223,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
await apiPost("/api/mood-entries", {
|
||||
const saved = await apiPost<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
@@ -243,6 +247,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("common.saved"));
|
||||
// The entry landed, but a tag picked here may have been archived or
|
||||
// deleted elsewhere since the picker loaded; the server says so.
|
||||
if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(localizedApiError(err, t, "mood.saveError"));
|
||||
|
||||
@@ -112,6 +112,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
droppedAnyLinkKeys,
|
||||
type DroppedLinkKeysWire,
|
||||
} from "@/lib/mood/dropped-link-keys";
|
||||
|
||||
// Re-export the score map under the legacy local name to keep the
|
||||
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
|
||||
@@ -471,7 +475,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
await apiPut(`/api/mood-entries/${id}`, {
|
||||
return apiPut<DroppedLinkKeysWire>(`/api/mood-entries/${id}`, {
|
||||
mood,
|
||||
tags,
|
||||
tagKeys,
|
||||
@@ -488,7 +492,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
moodLoggedAt,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (saved) => {
|
||||
// A tag picked in the dialog may have been archived or deleted
|
||||
// elsewhere since it opened; the server names what it did not store.
|
||||
if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
setEditSeed({
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: authState.disableCoach,
|
||||
// The server's module map mirrors the record's opt-out.
|
||||
modules: { coach: !authState.disableCoach },
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: !coach.available,
|
||||
// The record's module map, which the section's gate reads.
|
||||
modules: { coach: coach.available },
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
|
||||
@@ -14,6 +14,13 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/settings/coach",
|
||||
}));
|
||||
|
||||
// Past hydration only where a test says so: the gate reads the account once
|
||||
// mounted, and the pre-hydration pass shows every card.
|
||||
const mountState = { mounted: false };
|
||||
vi.mock("@/hooks/use-mounted", () => ({
|
||||
useMounted: () => mountState.mounted,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
|
||||
useInfiniteQuery: () => ({ data: undefined, isLoading: false }),
|
||||
@@ -31,10 +38,18 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const authState: { disableCoach: boolean } = { disableCoach: false };
|
||||
const authState: {
|
||||
disableCoach: boolean;
|
||||
modules: { coach?: boolean } | undefined;
|
||||
} = { disableCoach: false, modules: undefined };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER", disableCoach: authState.disableCoach },
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
disableCoach: authState.disableCoach,
|
||||
modules: authState.modules,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -83,3 +98,24 @@ describe("<CoachSection> — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.coach.activate.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachSection> — the tuning follows the record, not the account", () => {
|
||||
it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => {
|
||||
// A guardian with the Coach on, inside a managed record that has it off:
|
||||
// `/me` publishes the viewer's own `disableCoach` and the RECORD's module
|
||||
// map. The record's answer decides.
|
||||
mountState.mounted = true;
|
||||
authState.disableCoach = false;
|
||||
authState.modules = { coach: false };
|
||||
try {
|
||||
expect(render()).not.toContain('id="coach-nudge"');
|
||||
authState.disableCoach = true;
|
||||
authState.modules = { coach: true };
|
||||
expect(render()).toContain('id="coach-nudge"');
|
||||
} finally {
|
||||
mountState.mounted = false;
|
||||
authState.disableCoach = false;
|
||||
authState.modules = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,12 @@
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge cards
|
||||
* keep their own content gate on `!user.disableCoach`: hiding the Coach hides
|
||||
* its tuning. The memory (facts, stored conversations and reminders) is never
|
||||
* keep their own content gate on the same published module state
|
||||
* (`user.modules.coach`, resolved by the server for the record on screen
|
||||
* from that record's opt-out and the operator's switches): hiding the Coach
|
||||
* hides its tuning. The raw `disableCoach` column is the signed-in person's
|
||||
* own, so inside a managed or shared record it answered for the wrong
|
||||
* person. The memory (facts, stored conversations and reminders) is never
|
||||
* gated: it is the person's own record, readable and deletable whatever the
|
||||
* Coach's state. The reminders card turns read-only while the Coach is
|
||||
* unavailable and, with the Coach hidden, shows only when rows exist.
|
||||
@@ -39,6 +43,7 @@ import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function CoachSection() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
@@ -47,11 +52,13 @@ export function CoachSection() {
|
||||
// `useMounted()` to keep the hydration render matching the SSR HTML.
|
||||
const mounted = useMounted();
|
||||
const authed = mounted && isAuthenticated;
|
||||
// The preference + memory cards ride the same content gate as the rest
|
||||
// of the Coach surface: when the user has hidden the Coach there is no
|
||||
// assistant to tune or remember anything. (`mounted`-gated so the SSR +
|
||||
// hydration renders agree.)
|
||||
const coachEnabled = !mounted || !user?.disableCoach;
|
||||
// The preference + nudge cards ride the same content gate as the rest of
|
||||
// the Coach surface: when the record has the Coach hidden there is no
|
||||
// assistant to tune. Read from the server's module map for the record,
|
||||
// never recomputed from the account's own columns. (`mounted`-gated so the
|
||||
// SSR + hydration renders agree.)
|
||||
const coachEnabled =
|
||||
!mounted || isSurfaceVisible("settings:coach", user?.modules);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in new issue
Block a user