Merge remote-tracking branch 'origin/main' into fix/v1397-tz

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:59:51 +02:00
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("");
});
});
+32
View File
@@ -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);
+8 -1
View File
@@ -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"));
+9 -2
View File
@@ -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;
}
});
});
+14 -7
View File
@@ -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">