From c3dff2c3881a5ead731a701f0e0e5c8eb2f84619 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 9 Oct 2026 08:54:11 +0200
Subject: [PATCH] feat(day): collapse the docked day to an edge instead of
closing it
From 1280 px the day sits beside the page, and its control at the top
left took it away entirely: after a page change there was no way back to
the day short of finding it again. The control now hides the day the way
a sidebar collapses. A narrow edge stays on the right, holding the date,
with a button that opens the last day again on this page or any other.
Focus lands on that button when the day collapses and on the day's
heading when it comes back.
The last open day is remembered per browser, bound to the account and
the record it was read in, and removed at every session end. The Coach
button keeps clear of the edge as it does of the open day. Sheets below
1280 px and on phones close as before, and Back still closes them.
---
e2e/day-doors.spec.ts | 12 +-
e2e/day-view.spec.ts | 67 ++++++++-
messages/de.json | 2 +
messages/en.json | 2 +
messages/es.json | 2 +
messages/fr.json | 2 +
messages/it.json | 2 +
messages/ko.json | 2 +
messages/pl.json | 2 +
.../day/__tests__/day-view.test.tsx | 16 +-
src/components/day/day-layer.tsx | 140 +++++++++++++++++-
src/components/day/day-view.tsx | 22 +--
src/components/insights/layout-coach-fab.tsx | 3 +
.../__tests__/use-auth-session-clear.test.ts | 12 ++
src/hooks/use-auth.ts | 3 +
src/lib/day/__tests__/last-day.test.ts | 75 ++++++++++
src/lib/day/last-day.ts | 113 ++++++++++++++
17 files changed, 457 insertions(+), 20 deletions(-)
create mode 100644 src/lib/day/__tests__/last-day.test.ts
create mode 100644 src/lib/day/last-day.ts
diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts
index 357e2773c..c650f5c81 100644
--- a/e2e/day-doors.spec.ts
+++ b/e2e/day-doors.spec.ts
@@ -266,7 +266,7 @@ test.describe("the dashboard and the list of all values", () => {
);
});
- test("the docked day moves the Coach button off its footer", async ({
+ test("the docked day, open or collapsed, moves the Coach button off it", async ({
page,
}, testInfo) => {
test.skip(isPhone(testInfo), "docked from 1280 px");
@@ -290,6 +290,16 @@ test.describe("the dashboard and the list of all values", () => {
return b.x + b.width;
})
.toBeLessThanOrEqual(panelBox.x);
+
+ // Collapsed, the day keeps a narrow edge; the launcher stays clear of it.
+ await page.locator('[data-slot="day-close"]').click();
+ const edge = (await page.locator('[data-slot="day-rail"]').boundingBox())!;
+ await expect
+ .poll(async () => {
+ const b = (await fab.boundingBox())!;
+ return b.x + b.width;
+ })
+ .toBeLessThanOrEqual(edge.x);
});
});
diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts
index 6a04b16f0..679e57884 100644
--- a/e2e/day-view.spec.ts
+++ b/e2e/day-view.spec.ts
@@ -21,11 +21,14 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
* the future is dropped;
* - stepping to the neighbouring days never adds history entries;
+ * - docked, the day collapses to a narrow edge that brings it back on any
+ * page; a sheet closes for good;
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
- * `day-next` / `day-close`.
+ * `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`)
+ * with its `day-expand`.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
@@ -117,6 +120,68 @@ test.describe("the day view", () => {
await expect(page).toHaveURL(/\/mood$/);
});
+ test("the docked day collapses to an edge and comes back on another page", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "docked from 1280 px",
+ );
+ const day = isoDaysAgo(3);
+ await page.goto(`/mood?day=${day}`);
+ await expect(panel(page)).toHaveAttribute("data-shell", "docked");
+
+ await page.locator('[data-slot="day-close"]').click();
+ await expect(panel(page)).toHaveCount(0);
+ await expect(page).toHaveURL(/\/mood$/);
+ // A narrow edge stays, holding the day, and focus waits on its button.
+ const rail = page.locator('[data-slot="day-rail"]');
+ const expand = page.locator('[data-slot="day-expand"]');
+ await expect(rail).toHaveAttribute("data-day", day);
+ await expect(expand).toBeFocused();
+ await expect(expand).toHaveAttribute("aria-expanded", "false");
+ expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
+ expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(48);
+
+ // Another page: the edge is still there and brings the same day back,
+ // from the keyboard too.
+ await page.goto("/labs");
+ await expect(rail).toHaveAttribute("data-day", day);
+ await expand.focus();
+ await page.keyboard.press("Enter");
+ await expect(panel(page)).toHaveAttribute("data-shell", "docked");
+ await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
+ "data-day",
+ day,
+ );
+ await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
+ await expect(panel(page).locator("h2").first()).toBeFocused();
+ await expect(rail).toHaveCount(0);
+ });
+
+ test("a sheet closes for good and leaves no edge behind", async ({
+ page,
+ }, testInfo) => {
+ const desktop = testInfo.project.name === "chromium-desktop";
+ // Below 1280 px the day is a sheet from the right; on a phone, from the
+ // bottom. Neither collapses.
+ if (desktop) await page.setViewportSize({ width: 1024, height: 800 });
+ const day = isoDaysAgo(3);
+ await page.goto(`/mood?day=${day}`);
+ await expect(panel(page)).toHaveAttribute(
+ "data-shell",
+ desktop ? "sheet" : "bottom",
+ );
+ await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute(
+ "aria-label",
+ "Close day",
+ );
+ await page.locator('[data-slot="day-close"]').click();
+ await expect(panel(page)).toHaveCount(0);
+ await expect(page).toHaveURL(/\/mood$/);
+ await expect(page.locator('[data-slot="day-rail"]')).toHaveCount(0);
+ });
+
test("a future or malformed ?day= is dropped without a word", async ({
page,
}) => {
diff --git a/messages/de.json b/messages/de.json
index b7cc77b50..84b126d51 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -12207,6 +12207,8 @@
"previousDay": "Vortag",
"nextDay": "Folgetag",
"close": "Tag schließen",
+ "hidePanel": "Tag ausblenden",
+ "showPanel": "{date} einblenden",
"openDay": "Tag öffnen",
"viewWholeDay": "Ganzen Tag ansehen",
"captureForDay": "Für diesen Tag erfassen",
diff --git a/messages/en.json b/messages/en.json
index bdea7e6a7..ad96b368e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12207,6 +12207,8 @@
"previousDay": "Previous day",
"nextDay": "Next day",
"close": "Close day",
+ "hidePanel": "Hide day",
+ "showPanel": "Show {date}",
"openDay": "Open day",
"viewWholeDay": "View the whole day",
"captureForDay": "Add for this day",
diff --git a/messages/es.json b/messages/es.json
index 3e9806ff9..349a397ba 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -12207,6 +12207,8 @@
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"close": "Cerrar día",
+ "hidePanel": "Ocultar día",
+ "showPanel": "Mostrar {date}",
"openDay": "Abrir día",
"viewWholeDay": "Ver el día completo",
"captureForDay": "Registrar para este día",
diff --git a/messages/fr.json b/messages/fr.json
index 4ffa3c91a..9e484dfbf 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -12207,6 +12207,8 @@
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"close": "Fermer le jour",
+ "hidePanel": "Masquer le jour",
+ "showPanel": "Afficher {date}",
"openDay": "Ouvrir le jour",
"viewWholeDay": "Voir toute la journée",
"captureForDay": "Saisir pour ce jour",
diff --git a/messages/it.json b/messages/it.json
index 7fb859048..3cd58750b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -12207,6 +12207,8 @@
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"close": "Chiudi giorno",
+ "hidePanel": "Nascondi giorno",
+ "showPanel": "Mostra {date}",
"openDay": "Apri giorno",
"viewWholeDay": "Vedi l’intera giornata",
"captureForDay": "Registra per questo giorno",
diff --git a/messages/ko.json b/messages/ko.json
index 3405b2f39..5d5956d00 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -12207,6 +12207,8 @@
"previousDay": "전날",
"nextDay": "다음 날",
"close": "하루 닫기",
+ "hidePanel": "하루 숨기기",
+ "showPanel": "{date} 보기",
"openDay": "하루 열기",
"viewWholeDay": "하루 전체 보기",
"captureForDay": "이 날에 기록하기",
diff --git a/messages/pl.json b/messages/pl.json
index 7100d719b..c8d6bc240 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -12207,6 +12207,8 @@
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"close": "Zamknij dzień",
+ "hidePanel": "Ukryj dzień",
+ "showPanel": "Pokaż {date}",
"openDay": "Otwórz dzień",
"viewWholeDay": "Zobacz cały dzień",
"captureForDay": "Dodaj dla tego dnia",
diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx
index 310db7133..038aa97ee 100644
--- a/src/components/day/__tests__/day-view.test.tsx
+++ b/src/components/day/__tests__/day-view.test.tsx
@@ -146,6 +146,7 @@ async function render(
date: string;
today: string;
focus: unknown;
+ shell: "docked" | "sheet" | "bottom";
}> = {},
) {
const { I18nProvider } = await import("@/lib/i18n/context");
@@ -156,7 +157,7 @@ async function render(
date={props.date ?? "2026-01-03"}
today={props.today ?? "2026-10-08"}
focus={(props.focus as never) ?? null}
- shell="docked"
+ shell={props.shell ?? "docked"}
onClose={() => undefined}
onStep={() => undefined}
Title={(p) => }
@@ -232,6 +233,19 @@ describe("", () => {
expect(leave).not.toContain("FAMILY");
});
+ it("collapses the docked day and closes a sheet, and says which", async () => {
+ const docked = await render();
+ const hide = docked.match(/
+ {/* The top bar's band, so the two bottom borders draw one line;
+ the control sits where the open day keeps its own. */}
+
+
+
+
+
+ {/* The rest of the edge takes a click too and names the day it
+ holds; the keyboard has the button above. */}
+
+
+ {shortLabel(railDay)}
+
+
+
+
+ >
+ );
+ }
const view = (
Title: Parameters[0]["Title"],
@@ -224,7 +341,7 @@ function DayLayer() {
today={today}
focus={focus}
shell={shell}
- onClose={closeDay}
+ onClose={shell === "docked" ? collapse : closeDay}
onStep={onStep}
Title={Title}
titleId={titleId}
@@ -334,13 +451,20 @@ function DockedTitle({
return ;
}
-/** Return focus to whatever opened the day, or to the page. */
-function returnFocus() {
+/**
+ * Return focus to whatever opened the day; failing that, to `fallback` (the
+ * collapsed edge's control), or to the page.
+ */
+function returnFocus(fallback?: HTMLElement | null) {
const trigger = takeDayTrigger();
if (trigger && trigger.isConnected) {
trigger.focus({ preventScroll: true });
return;
}
+ if (fallback && fallback.isConnected) {
+ fallback.focus({ preventScroll: true });
+ return;
+ }
document.getElementById("main-content")?.focus({ preventScroll: true });
}
diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx
index 14ee0b7ac..dbe4c8baa 100644
--- a/src/components/day/day-view.tsx
+++ b/src/components/day/day-view.tsx
@@ -96,14 +96,15 @@ const ICON_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
/**
- * The close control is the Coach panel's own toggle, in the same place: the
- * panel's top-left corner, the panel icon mirrored so it points the way the
- * panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as a
- * copy so the day layer does not pull the Coach's panel into the shell).
+ * The collapse control is the Coach panel's own toggle, in the same place:
+ * the panel's top-left corner, the panel icon mirrored so it points the way
+ * the panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as
+ * a copy so the day layer does not pull the Coach's panel into the shell).
+ * The collapsed edge's control in `day-layer.tsx` takes the same pair.
*/
-const PANEL_TOGGLE =
+export const PANEL_TOGGLE =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
-const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
+export const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
export function DayView({
date,
@@ -178,6 +179,7 @@ export function DayView({
].join(" · ")
: null;
const compact = shell === "bottom";
+ const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
return (
- {/* Top left, where the Coach panel keeps its toggle: the way out. */}
+ {/* Top left, where the Coach panel keeps its toggle: the way out.
+ Docked, it collapses the day to the edge; a sheet closes. */}
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index 7d6bbae8e..bd8f981dc 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -261,6 +261,9 @@ export function LayoutCoachFab() {
// the page and never over the day's footer actions. The `:has()`
// gate keys off the docked panel's slot, like the yields below.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
+ // Collapsed, the day keeps a 3 rem edge on the right; the launcher
+ // stays clear of it the same way.
+ "[body:has([data-slot=day-rail])_&]:right-[calc(3rem+2rem)]",
// Yield to the data-list selection bar: its delete action lands
// in the same lower-right band, and the destructive control
// wins. The `:has()` gate keys off the bar's `data-slot`.
diff --git a/src/hooks/__tests__/use-auth-session-clear.test.ts b/src/hooks/__tests__/use-auth-session-clear.test.ts
index 1d86926b9..e98968f80 100644
--- a/src/hooks/__tests__/use-auth-session-clear.test.ts
+++ b/src/hooks/__tests__/use-auth-session-clear.test.ts
@@ -23,6 +23,12 @@ vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(),
}));
+// The day layer's remembered date is a date of the ending account's record.
+const clearLastDay = vi.fn();
+vi.mock("@/lib/day/last-day", () => ({
+ clearLastDay: () => clearLastDay(),
+}));
+
// `use-auth` pulls in the typed fetch wrapper, the router, and the i18n
// context at module load. None of them matter for the pure session-end
// helper, so stub them so the import is side-effect free in the test env.
@@ -41,6 +47,7 @@ import { clearCachesForSessionEnd } from "../use-auth";
afterEach(() => {
clearOfflineCachesForSessionEnd.mockClear();
+ clearLastDay.mockClear();
});
describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => {
@@ -68,4 +75,9 @@ describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => {
// in-memory clear — the existing offline-leak guard stays intact.
expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1);
});
+
+ it("forgets the day the layer last showed", () => {
+ clearCachesForSessionEnd(new QueryClient());
+ expect(clearLastDay).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 09b6c7f1a..a8830df66 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -27,6 +27,7 @@ import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
+import { clearLastDay } from "@/lib/day/last-day";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
setRecordScope,
@@ -621,6 +622,8 @@ export function clearCachesForSessionEnd(queryClient: QueryClient): void {
// end the next cold offline launch must land on the login gate, not the
// cached shell.
clearWasAuthenticated();
+ // The day the layer last showed is a date of this account's record.
+ clearLastDay();
void clearOfflineCachesForSessionEnd();
}
diff --git a/src/lib/day/__tests__/last-day.test.ts b/src/lib/day/__tests__/last-day.test.ts
new file mode 100644
index 000000000..44ac147ba
--- /dev/null
+++ b/src/lib/day/__tests__/last-day.test.ts
@@ -0,0 +1,75 @@
+/**
+ * The remembered last day of the day layer: bound to its account and record,
+ * gone at a session end, and never a reason to throw when storage refuses.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ clearLastDay,
+ LAST_DAY_STORAGE_KEY,
+ readLastDay,
+ writeLastDay,
+} from "../last-day";
+
+function memoryStorage() {
+ const map = new Map();
+ return {
+ map,
+ getItem: (key: string) => map.get(key) ?? null,
+ setItem: (key: string, value: string) => void map.set(key, value),
+ removeItem: (key: string) => void map.delete(key),
+ };
+}
+
+const throwing = {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {
+ throw new Error("SecurityError");
+ },
+};
+
+describe("last day", () => {
+ it("gives the day back to the account and record that opened it", () => {
+ const storage = memoryStorage();
+ writeLastDay("u1:own", "2026-10-05", storage);
+ expect(readLastDay("u1:own", storage)).toBe("2026-10-05");
+ });
+
+ it("gives nothing to another account, another record or no session", () => {
+ const storage = memoryStorage();
+ writeLastDay("u1:own", "2026-10-05", storage);
+ expect(readLastDay("u2:own", storage)).toBeNull();
+ expect(readLastDay("u1:rec-9", storage)).toBeNull();
+ expect(readLastDay(null, storage)).toBeNull();
+ });
+
+ it("is gone after a session end", () => {
+ const storage = memoryStorage();
+ writeLastDay("u1:own", "2026-10-05", storage);
+ clearLastDay(storage);
+ expect(storage.map.has(LAST_DAY_STORAGE_KEY)).toBe(false);
+ expect(readLastDay("u1:own", storage)).toBeNull();
+ });
+
+ it("reads a damaged value as nothing", () => {
+ const storage = memoryStorage();
+ storage.setItem(LAST_DAY_STORAGE_KEY, "{not json");
+ expect(readLastDay("u1:own", storage)).toBeNull();
+ storage.setItem(
+ LAST_DAY_STORAGE_KEY,
+ JSON.stringify({ owner: "u1:own", day: "yesterday" }),
+ );
+ expect(readLastDay("u1:own", storage)).toBeNull();
+ });
+
+ it("never throws when storage refuses", () => {
+ expect(readLastDay("u1:own", throwing)).toBeNull();
+ expect(() => writeLastDay("u1:own", "2026-10-05", throwing)).not.toThrow();
+ expect(() => clearLastDay(throwing)).not.toThrow();
+ });
+});
diff --git a/src/lib/day/last-day.ts b/src/lib/day/last-day.ts
new file mode 100644
index 000000000..02e4f6300
--- /dev/null
+++ b/src/lib/day/last-day.ts
@@ -0,0 +1,113 @@
+/**
+ * The last day the layer showed, per browser, so the docked day can be shut
+ * to a narrow edge and brought back on another page (v1.42).
+ *
+ * A convenience, not a record: it lives in `localStorage`, every read and
+ * write is wrapped because storage can throw (private windows, blocked site
+ * data), and an unreadable store means "nothing remembered". The value is
+ * bound to the account and the record it was read in (`owner`), so another
+ * account on the same browser, or the same person reading a shared record,
+ * never gets it back; a session end removes it outright
+ * (`clearCachesForSessionEnd`).
+ *
+ * Client-only and free of Zod and server imports, like `contract.ts`.
+ */
+import type { DateKey } from "@/lib/day/contract";
+
+export const LAST_DAY_STORAGE_KEY = "healthlog.day.last";
+
+const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
+
+/** Same-tab change signal; the `storage` event only fires in other tabs. */
+export const LAST_DAY_CHANGE_EVENT = "healthlog:day-last";
+
+interface Stored {
+ owner: string;
+ day: DateKey;
+}
+
+function safeLocalStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** The remembered day for `owner`, or null (nothing, someone else's, unreadable). */
+export function readLastDay(
+ owner: string | null,
+ storage: Pick | null = safeLocalStorage(),
+): DateKey | null {
+ if (owner === null || !storage) return null;
+ try {
+ const raw = storage.getItem(LAST_DAY_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial | null;
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ parsed.owner !== owner ||
+ typeof parsed.day !== "string" ||
+ !DATE_KEY.test(parsed.day)
+ ) {
+ return null;
+ }
+ return parsed.day;
+ } catch {
+ return null;
+ }
+}
+
+function notify() {
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(LAST_DAY_CHANGE_EVENT));
+ }
+}
+
+/** Remember `day` as the last one `owner` opened. Best effort. */
+export function writeLastDay(
+ owner: string,
+ day: DateKey,
+ storage: Pick | null = safeLocalStorage(),
+): void {
+ if (!storage) return;
+ try {
+ if (readLastDay(owner, storage) === day) return;
+ storage.setItem(
+ LAST_DAY_STORAGE_KEY,
+ JSON.stringify({ owner, day } satisfies Stored),
+ );
+ } catch {
+ return;
+ }
+ notify();
+}
+
+/** Forget the remembered day, whoever it belonged to (a session end). */
+export function clearLastDay(
+ storage: Pick | null = safeLocalStorage(),
+): void {
+ if (!storage) return;
+ try {
+ storage.removeItem(LAST_DAY_STORAGE_KEY);
+ } catch {
+ return;
+ }
+ notify();
+}
+
+/** For `useSyncExternalStore`: storage changes here and in other tabs. */
+export function subscribeLastDay(callback: () => void): () => void {
+ if (typeof window === "undefined") return () => {};
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === null || event.key === LAST_DAY_STORAGE_KEY) callback();
+ };
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(LAST_DAY_CHANGE_EVENT, callback);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(LAST_DAY_CHANGE_EVENT, callback);
+ };
+}