Skip to content

Commit 27445c2

Browse files
committed
fix(notifications): moves mutedSources to errors.ts, adds aria attrs, fixes responsive width, caches matchMedia, clears state on logout
1 parent 845b639 commit 27445c2

File tree

10 files changed

+33
-18
lines changed

10 files changed

+33
-18
lines changed

src/app/components/layout/Header.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,12 @@ export default function Header() {
108108

109109
{/* Bell icon with unread badge */}
110110
<button
111+
type="button"
111112
onClick={toggleDrawer}
112113
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 shrink-0 relative"
113-
aria-label="Notifications"
114+
aria-label={unreadCount() > 0 ? `Notifications, ${unreadCount()} unread` : "Notifications"}
115+
aria-expanded={drawerOpen()}
116+
aria-haspopup="dialog"
114117
>
115118
<svg
116119
xmlns="http://www.w3.org/2000/svg"

src/app/components/shared/NotificationDrawer.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import {
44
markAllAsRead,
55
clearNotifications,
66
dismissError,
7+
mutedSources,
78
} from "../../lib/errors";
89
import { relativeTime } from "../../lib/format";
9-
import { severityConfig, mutedSources } from "./ToastContainer";
10+
import { severityConfig } from "./ToastContainer";
1011

1112
interface NotificationDrawerProps {
1213
open: boolean;
@@ -19,8 +20,8 @@ export default function NotificationDrawer(props: NotificationDrawerProps) {
1920
let closeTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
2021
let closeButtonRef: HTMLButtonElement | undefined;
2122

22-
const animDelay = () =>
23-
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
23+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
24+
const animDelay = reducedMotion ? 0 : 300;
2425

2526
createEffect(() => {
2627
if (props.open) {
@@ -38,7 +39,7 @@ export default function NotificationDrawer(props: NotificationDrawerProps) {
3839
closeTimeoutHandle = setTimeout(() => {
3940
setVisible(false);
4041
closeTimeoutHandle = undefined;
41-
}, animDelay());
42+
}, animDelay);
4243
}
4344
}
4445
});

src/app/components/shared/ToastContainer.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createEffect, createSignal, For, onCleanup } from "solid-js";
22
import {
33
getNotifications,
4+
mutedSources,
45
type AppNotification,
56
type NotificationSeverity,
67
} from "../../lib/errors";
@@ -50,10 +51,6 @@ export function severityConfig(severity: NotificationSeverity): SeverityConfig {
5051
}
5152
}
5253

53-
// Module-level muted sources — session only, resets on page reload
54-
// Populated by NotificationDrawer's "Dismiss all" action
55-
export const mutedSources = new Set<string>();
56-
5754
interface ToastItem {
5855
notification: AppNotification;
5956
dismissing: boolean;
@@ -72,8 +69,8 @@ export default function ToastContainer() {
7269
const dismissingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
7370

7471
const COOLDOWN_MS = 60_000;
75-
const animDelay = () =>
76-
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
72+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
73+
const animDelay = reducedMotion ? 0 : 300;
7774

7875
function removeToast(id: string) {
7976
setVisibleToasts((prev) => {
@@ -99,7 +96,7 @@ export default function ToastContainer() {
9996
const t = setTimeout(() => {
10097
dismissingTimeouts.delete(id);
10198
removeToast(id);
102-
}, animDelay());
99+
}, animDelay);
103100
dismissingTimeouts.set(id, t);
104101
}
105102

@@ -178,7 +175,7 @@ export default function ToastContainer() {
178175
});
179176

180177
return (
181-
<div class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 w-96">
178+
<div class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 w-[calc(100vw-2rem)] max-w-96" aria-live="assertive" aria-atomic="false">
182179
<For each={[...visibleToasts().values()]}>
183180
{(item) => {
184181
const cfg = severityConfig(item.notification.severity);

src/app/lib/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,19 @@ export function getErrors(): AppNotification[] {
9595
return notifications();
9696
}
9797

98+
// Muted sources — suppress toasts for these sources after "Dismiss all"
99+
// Session-only, reset on page reload and on logout (via resetNotificationState)
100+
export const mutedSources = new Set<string>();
101+
98102
export function clearNotifications(): void {
99103
setNotifications([]);
100104
}
101105

106+
export function resetNotificationState(): void {
107+
setNotifications([]);
108+
mutedSources.clear();
109+
}
110+
102111
// Backward-compat alias
103112
export function clearErrors(): void {
104113
clearNotifications();

src/app/services/poll.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import {
1313
resetEmptyActionRepos,
1414
} from "./api";
1515
import { detectNewItems, dispatchNotifications, _resetNotificationState } from "../lib/notifications";
16-
import { pushError, pushNotification, getNotifications, dismissNotificationBySource, startCycleTracking, endCycleTracking, clearNotifications } from "../lib/errors";
17-
import { mutedSources } from "../components/shared/ToastContainer";
16+
import { pushError, pushNotification, getNotifications, dismissNotificationBySource, startCycleTracking, endCycleTracking, resetNotificationState } from "../lib/errors";
1817

1918
// ── Types ────────────────────────────────────────────────────────────────────
2019

@@ -45,8 +44,7 @@ function resetPollState(): void {
4544
_notifGateDisabled = false;
4645
_resetNotificationState();
4746
resetEmptyActionRepos();
48-
clearNotifications();
49-
mutedSources.clear();
47+
resetNotificationState();
5048
}
5149

5250
// Auto-reset poll state on logout (avoids circular dep with auth.ts)

tests/components/DashboardPage.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ vi.mock("../../src/app/lib/errors", () => ({
5151
pushNotification: vi.fn(),
5252
clearErrors: vi.fn(),
5353
clearNotifications: vi.fn(),
54+
mutedSources: new Set(),
5455
}));
5556

5657
// capturedFetchAll is populated by the createPollCoordinator mock each time

tests/components/layout/Header.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ vi.mock("../../../src/app/lib/errors", () => ({
4545
dismissNotificationBySource: vi.fn(),
4646
getErrors: vi.fn(() => []),
4747
clearErrors: vi.fn(),
48+
mutedSources: new Set(),
4849
}));
4950

5051
import Header from "../../../src/app/components/layout/Header";

tests/components/shared/NotificationDrawer.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
pushNotification,
66
clearNotifications,
77
markAllAsRead,
8+
mutedSources,
89
} from "../../../src/app/lib/errors";
910
import NotificationDrawer from "../../../src/app/components/shared/NotificationDrawer";
10-
import { mutedSources } from "../../../src/app/components/shared/ToastContainer";
1111

1212
beforeEach(() => {
1313
clearNotifications();

tests/services/poll-fetchAllData.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ vi.mock("../../src/app/lib/errors", () => ({
4949
dismissNotificationBySource: vi.fn(),
5050
startCycleTracking: vi.fn(),
5151
endCycleTracking: vi.fn(() => new Set()),
52+
resetNotificationState: vi.fn(),
53+
mutedSources: new Set(),
5254
}));
5355

5456
// ── Helpers ───────────────────────────────────────────────────────────────────

tests/services/poll.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ vi.mock("../../src/app/lib/errors", () => ({
1818
startCycleTracking: () => mockStartCycleTracking(),
1919
endCycleTracking: () => mockEndCycleTracking(),
2020
pushNotification: vi.fn(),
21+
clearNotifications: vi.fn(),
22+
resetNotificationState: vi.fn(),
23+
mutedSources: new Set(),
2124
}));
2225

2326
// Mock notifications so doFetch doesn't fail on detectNewItems

0 commit comments

Comments
 (0)