Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/components/Notifications/NotificationPreferencePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { Bell } from 'lucide-react';
import { NotificationPreferences } from '@/types/notification';
import { buildNotificationPreview } from '@/utils/notificationPreview';

interface NotificationPreferencePreviewProps {
/** The owner's current (not-yet-saved) notification preferences. */
preferences: NotificationPreferences;
/** Optional id so the section can be referenced by aria-labelledby. */
id?: string;
}

/**
* Renders an accessible preview of exactly what the owner will receive given the
* current selections — the channels a message travels on and the event
* categories that trigger delivery — before they hit "Save".
*
* Keyboard / screen-reader friendly: a labelled <section>, real <ul> lists, a
* live `role="status"` summary, and status text that does not rely on colour
* alone. It only reflects local preference selections; the backend keeps
* ownership of authorization and consent.
*/
export function NotificationPreferencePreview({
preferences,
id,
}: NotificationPreferencePreviewProps) {
const preview = buildNotificationPreview(preferences);
const enabledChannels = preview.channels.filter((c) => c.enabled);

return (
<section
id={id}
aria-label="Notification delivery preview"
className="bg-white border border-blue-100 rounded-2xl p-4 shadow-sm"
>
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-wide flex items-center gap-2">
<Bell size={16} className="text-blue-600" aria-hidden="true" />
Preview before you save
</h2>
<p className="text-xs text-gray-500 mt-1" role="status">
{preview.deliverySummary}
</p>

{preview.isEmpty ? (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 mt-3">
Turn on at least one event type below to start receiving notifications.
</p>
) : (
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Channels
</h3>
<ul role="list" className="mt-2 space-y-1">
{enabledChannels.map((c) => (
<li key={c.key} className="text-sm text-gray-800">
<span className="font-medium">{c.label}:</span>{' '}
<span className="text-gray-600">{c.sample}</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
You&apos;ll be notified about
</h3>
<ul role="list" className="mt-2 space-y-1">
{preview.categories.map((cat) => (
<li key={cat.key} className="text-sm text-gray-800">
{cat.label}
</li>
))}
</ul>
</div>
</div>
)}
</section>
);
}

export default NotificationPreferencePreview;
3 changes: 3 additions & 0 deletions src/components/Notifications/NotificationPreferencesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { useNotifications } from '@/contexts/NotificationContext';
import { CATEGORY_LABELS, CATEGORY_ICONS, type NotificationCategory } from '@/types/notification';
import { getSupportedTimezones } from '@/utils/dndSchedule';
import { NotificationPreferencePreview } from '@/components/Notifications/NotificationPreferencePreview';

function Toggle({
checked,
Expand Down Expand Up @@ -237,6 +238,8 @@ export default function NotificationPreferencesPanel() {
</div>
</section>

<NotificationPreferencePreview preferences={preferences} />

<button
onClick={syncPreferences}
disabled={preferencesSyncStatus === 'saving'}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { NotificationPreferencePreview } from '../NotificationPreferencePreview';
import {
DEFAULT_PREFERENCES,
NotificationCategory,
NotificationPreferences,
} from '@/types/notification';

function withCategories(enabled: NotificationCategory[]): NotificationPreferences {
const categories = Object.fromEntries(
(Object.keys(DEFAULT_PREFERENCES.categories) as NotificationCategory[]).map(
(cat) => [cat, enabled.includes(cat)]
)
) as Record<NotificationCategory, boolean>;
return { ...DEFAULT_PREFERENCES, categories };
}

describe('NotificationPreferencePreview', () => {
it('renders a heading describing the preview', () => {
render(<NotificationPreferencePreview preferences={DEFAULT_PREFERENCES} />);
expect(
screen.getByRole('heading', { name: /preview before you save/i })
).toBeInTheDocument();
});

it('announces a delivery summary for the default preferences', () => {
render(<NotificationPreferencePreview preferences={DEFAULT_PREFERENCES} />);
expect(
screen.getByText(/you will receive 9 event types/i)
).toBeInTheDocument();
});

it('lists the enabled channels with representative samples', () => {
render(<NotificationPreferencePreview preferences={DEFAULT_PREFERENCES} />);
// in-app, sound, vibration are on by default; browser push is off.
expect(screen.getByText(/In-app:/i)).toBeInTheDocument();
expect(screen.getByText(/Sound:/i)).toBeInTheDocument();
expect(screen.getByText(/Vibration:/i)).toBeInTheDocument();
expect(screen.queryByText(/Browser push:/i)).not.toBeInTheDocument();
});

it('lists the exact event categories that will trigger delivery', () => {
render(
<NotificationPreferencePreview
preferences={withCategories(['APPOINTMENT', 'LOST_PET', 'MEDICATION'])}
/>
);
expect(screen.getByText('Appointments')).toBeInTheDocument();
expect(screen.getByText('Lost Pet')).toBeInTheDocument();
expect(screen.getByText('Medications')).toBeInTheDocument();
expect(screen.queryByText('System')).not.toBeInTheDocument();
});

it('shows an empty-state message when no event type is enabled (denied/empty)', () => {
render(<NotificationPreferencePreview preferences={withCategories([])} />);
expect(
screen.getByText(/turn on at least one event type/i)
).toBeInTheDocument();
expect(
screen.getByText(/no event types are enabled/i)
).toBeInTheDocument();
});

it('reflects Do Not Disturb quiet hours in the live summary', () => {
render(
<NotificationPreferencePreview
preferences={{
...DEFAULT_PREFERENCES,
doNotDisturb: true,
dndStart: '22:00',
dndEnd: '08:00',
timezone: 'America/New_York',
}}
/>
);
expect(screen.getByText(/quiet hours/i)).toBeInTheDocument();
expect(screen.getByText(/22:00/i)).toBeInTheDocument();
});

it('has no detectable accessibility violations', async () => {
const { container } = render(
<NotificationPreferencePreview preferences={DEFAULT_PREFERENCES} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
108 changes: 108 additions & 0 deletions src/utils/__tests__/notificationPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
buildNotificationPreview,
ChannelKey,
NotificationPreview,
} from '../notificationPreview';
import {
DEFAULT_PREFERENCES,
NotificationCategory,
NotificationPreferences,
} from '@/types/notification';

function withPrefs(overrides: Partial<NotificationPreferences>): NotificationPreferences {
return { ...DEFAULT_PREFERENCES, ...overrides };
}

function withCategories(
enabled: NotificationCategory[]
): NotificationPreferences {
const categories = Object.fromEntries(
(Object.keys(DEFAULT_PREFERENCES.categories) as NotificationCategory[]).map(
(cat) => [cat, enabled.includes(cat)]
)
) as Record<NotificationCategory, boolean>;
return withPrefs({ categories });
}

describe('buildNotificationPreview', () => {
it('reports the in-app channel as always enabled and reflects the sound/vibration/browserPush toggles', () => {
const p = buildNotificationPreview(DEFAULT_PREFERENCES);
const enabled = p.channels.filter((c) => c.enabled).map((c) => c.key);
expect(enabled).toEqual<ChannelKey[]>(['inApp', 'sound', 'vibration']);
expect(p.enabledChannelCount).toBe(3);
// browserPush is off by default:
expect(p.channels.find((c) => c.key === 'browserPush')?.enabled).toBe(false);
});

it('lists every enabled event category as a delivery trigger (primary flow)', () => {
const p = buildNotificationPreview(DEFAULT_PREFERENCES);
expect(p.enabledCategoryCount).toBe(9);
expect(p.categories.map((c) => c.key)).toEqual(Object.keys(DEFAULT_PREFERENCES.categories));
expect(p.isEmpty).toBe(false);
});

it('produces a representative sample message per enabled channel', () => {
const p = buildNotificationPreview(DEFAULT_PREFERENCES);
const byKey = Object.fromEntries(p.channels.map((c) => [c.key, c]));
expect(byKey.inApp.sample).toMatch(/notification center/i);
expect(byKey.sound.sample).toMatch(/chime/i);
expect(byKey.vibration.sample).toMatch(/vibrates/i);
// browserPush is disabled, so its sample still describes the channel but it is off:
expect(byKey.browserPush.enabled).toBe(false);
expect(byKey.browserPush.sample).toMatch(/lock screen/i);
});

it('treats "no event categories enabled" as the empty / denied state', () => {
const p = buildNotificationPreview(withCategories([]));
expect(p.isEmpty).toBe(true);
expect(p.categories).toEqual([]);
expect(p.enabledCategoryCount).toBe(0);
expect(p.deliverySummary).toMatch(/no event types/i);
});

it('reflects Do Not Disturb quiet hours in the delivery summary (privacy mode)', () => {
const p = buildNotificationPreview(
withPrefs({
doNotDisturb: true,
dndStart: '22:00',
dndEnd: '08:00',
timezone: 'America/New_York',
})
);
expect(p.quietHoursEnabled).toBe(true);
expect(p.deliverySummary).toMatch(/quiet hours/i);
expect(p.deliverySummary).toMatch(/22:00/);
expect(p.deliverySummary).toMatch(/America\/New_York/);
expect(p.deliverySummary).toMatch(/urgent alerts/i);
});

it('summarizes delivery as events happen when quiet hours are off', () => {
const p = buildNotificationPreview(withPrefs({ doNotDisturb: false }));
expect(p.quietHoursEnabled).toBe(false);
expect(p.deliverySummary).toMatch(/as they happen/i);
});

it('drops channels when their toggles are switched off', () => {
const p = buildNotificationPreview(
withPrefs({ sound: false, vibration: false, browserPush: false })
);
// in-app remains, the three selectable channels are now off:
expect(p.enabledChannelCount).toBe(1);
expect(p.channels.filter((c) => c.enabled).map((c) => c.key)).toEqual<ChannelKey[]>(['inApp']);
});

it('reports exactly the selected event categories (boundary: single category)', () => {
const p = buildNotificationPreview(withCategories(['APPOINTMENT']));
expect(p.categories).toEqual([{ key: 'APPOINTMENT', label: 'Appointments' }]);
expect(p.enabledCategoryCount).toBe(1);
expect(p.isEmpty).toBe(false);
});

it('returns a complete, well-formed preview object for the default preferences', () => {
const p: NotificationPreview = buildNotificationPreview(DEFAULT_PREFERENCES);
expect(typeof p.deliverySummary).toBe('string');
expect(p.channels.length).toBe(4);
expect(p.categories.length).toBe(9);
expect(p.isEmpty).toBe(false);
});
});
Loading