fix: harden dashboard notification focus rendering
This commit is contained in:
@@ -46,6 +46,10 @@ function getMedicationIdFromNotificationDoseId(doseId: string | null): string |
|
||||
}
|
||||
|
||||
function findFocusTargetElement(doseId: string | null, medId: string | null): HTMLElement | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (doseId) {
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-dose-id]"));
|
||||
const doseElement = elements.find((element) => element.dataset.doseId === doseId);
|
||||
@@ -67,6 +71,8 @@ function getDosePeople(takenBy: unknown): Array<string | null> {
|
||||
return takenByArray.length > 0 ? takenByArray : [null];
|
||||
}
|
||||
|
||||
const EMPTY_DOSE_SET = new Set<string>();
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
@@ -115,6 +121,9 @@ export function DashboardPage() {
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||
const notificationFocusAppliedRef = useRef<string | null>(null);
|
||||
const effectiveSkippedDoses =
|
||||
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
|
||||
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
|
||||
|
||||
const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
|
||||
|
||||
@@ -182,16 +191,20 @@ export function DashboardPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetDayState.section === "past" && !showPastDays) {
|
||||
setShowPastDays(true);
|
||||
}
|
||||
try {
|
||||
if (targetDayState.section === "past" && !showPastDays) {
|
||||
setShowPastDays(true);
|
||||
}
|
||||
|
||||
if (targetDayState.section === "future" && !showFutureDays) {
|
||||
setShowFutureDays(true);
|
||||
}
|
||||
if (targetDayState.section === "future" && !showFutureDays) {
|
||||
setShowFutureDays(true);
|
||||
}
|
||||
|
||||
if (targetDayState.isCollapsed) {
|
||||
toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed);
|
||||
if (targetDayState.isCollapsed) {
|
||||
toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed);
|
||||
}
|
||||
} catch {
|
||||
notificationFocusAppliedRef.current = null;
|
||||
}
|
||||
}, [
|
||||
notificationTarget,
|
||||
@@ -224,14 +237,18 @@ export function DashboardPage() {
|
||||
let correctionTimerId: number | null = null;
|
||||
|
||||
const scrollTargetIntoView = () => {
|
||||
const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId);
|
||||
try {
|
||||
const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId);
|
||||
|
||||
if (!targetElement) {
|
||||
if (!targetElement || typeof targetElement.scrollIntoView !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return true;
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
@@ -364,6 +381,10 @@ export function DashboardPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!canManageSkippedDoses) {
|
||||
return takeButton;
|
||||
}
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
@@ -1071,7 +1092,7 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
@@ -1395,7 +1416,7 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
@@ -1659,7 +1680,7 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
|
||||
@@ -584,6 +584,53 @@ describe("DashboardPage interactions", () => {
|
||||
expect(setScheduleDays).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
it("renders today doses when skip state is missing from an older app context shape", () => {
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
skippedDoses: undefined,
|
||||
markDoseSkipped: undefined,
|
||||
undoDoseSkipped: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
expect(document.querySelector(".day-block.today .dose-btn.take")).toBeInTheDocument();
|
||||
expect(document.querySelector(".day-block.today .dose-btn.skip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the dashboard rendered when notification focus scrolling fails", async () => {
|
||||
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn(() => {
|
||||
throw new Error("scroll failed");
|
||||
});
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||
expect(targetDose).toHaveClass("notification-focus-target");
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights and scrolls to the notification-linked dashboard dose", async () => {
|
||||
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||
mockContextValue = createMockAppContext({
|
||||
|
||||
Reference in New Issue
Block a user