From d60080ef20686ca82f9d4fcc0fa5091daead1e8b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 09:30:54 +0200 Subject: [PATCH] feat(12-04): tender-radar digest-interval selector + Sofort-Alert toggle UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings page: Benachrichtigungen section with Täglich/Wöchentlich/Aus selector, loads via fetchNotificationPref, saves via saveNotificationPref (NOTIFY-01) - SavedSearchBar: per-profile Sofort-Alert checkbox reflecting instantAlert, calls updateSavedSearch({ instantAlert }) + reloads (NOTIFY-02, D-04) - SavedSearchBar.test.tsx: checkbox state + toggle-calls-updateSavedSearch coverage Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/SavedSearchBar.test.tsx | 65 +++++++++++- .../components/SavedSearchBar.tsx | 33 +++++++ .../modules/tender-radar/settings/page.tsx | 99 +++++++++++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx index 0e95175..d2cbd71 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx @@ -49,7 +49,14 @@ afterEach(() => { describe('SavedSearchBar', () => { it('fetches on mount and renders each saved profile by name', async () => { mockListSavedSearches.mockResolvedValue([ - { id: 'ss-1', name: 'Bau NRW', filters: { q: 'Bau' }, createdAt: '', updatedAt: '' }, + { + id: 'ss-1', + name: 'Bau NRW', + filters: { q: 'Bau' }, + instantAlert: false, + createdAt: '', + updatedAt: '', + }, ]); const { SavedSearchBar } = await import('./SavedSearchBar'); @@ -81,6 +88,7 @@ describe('SavedSearchBar', () => { id: 'ss-new', name: 'Mein Profil', filters: {}, + instantAlert: false, createdAt: '', updatedAt: '', }); @@ -136,6 +144,7 @@ describe('SavedSearchBar', () => { id: 'ss-1', name: 'Bau NRW', filters: { q: 'Bau', cpv: ['45', '71'], openOnly: 'false' }, + instantAlert: false, createdAt: '', updatedAt: '', }, @@ -158,15 +167,16 @@ describe('SavedSearchBar', () => { it('renaming a profile calls updateSavedSearch(id, { name }) and reloads the list', async () => { mockListSavedSearches .mockResolvedValueOnce([ - { id: 'ss-1', name: 'Alt', filters: {}, createdAt: '', updatedAt: '' }, + { id: 'ss-1', name: 'Alt', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' }, ]) .mockResolvedValueOnce([ - { id: 'ss-1', name: 'Neu', filters: {}, createdAt: '', updatedAt: '' }, + { id: 'ss-1', name: 'Neu', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' }, ]); mockUpdateSavedSearch.mockResolvedValue({ id: 'ss-1', name: 'Neu', filters: {}, + instantAlert: false, createdAt: '', updatedAt: '', }); @@ -192,7 +202,7 @@ describe('SavedSearchBar', () => { it('deleting a profile calls deleteSavedSearch(id) and reloads the list', async () => { mockListSavedSearches .mockResolvedValueOnce([ - { id: 'ss-1', name: 'Weg damit', filters: {}, createdAt: '', updatedAt: '' }, + { id: 'ss-1', name: 'Weg damit', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' }, ]) .mockResolvedValueOnce([]); mockDeleteSavedSearch.mockResolvedValue(undefined); @@ -208,4 +218,51 @@ describe('SavedSearchBar', () => { expect(screen.getByText(/Noch keine Suchprofile gespeichert/i)).toBeInTheDocument(); }); }); + + // --- Sofort-Alert toggle (Plan 12-04, NOTIFY-02, D-04) -------------------- + + it('renders the Sofort-Alert checkbox reflecting profile.instantAlert', async () => { + mockListSavedSearches.mockResolvedValue([ + { id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: true, createdAt: '', updatedAt: '' }, + { id: 'ss-2', name: 'Straßenbau', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' }, + ]); + + const { SavedSearchBar } = await import('./SavedSearchBar'); + render(); + + const onCheckbox = await screen.findByLabelText('Sofort-Alert für Bau NRW'); + const offCheckbox = await screen.findByLabelText('Sofort-Alert für Straßenbau'); + + expect(onCheckbox).toBeChecked(); + expect(offCheckbox).not.toBeChecked(); + }); + + it('clicking the Sofort-Alert checkbox calls updateSavedSearch(id, { instantAlert: }) and reloads the list', async () => { + mockListSavedSearches + .mockResolvedValueOnce([ + { id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' }, + ]) + .mockResolvedValueOnce([ + { id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: true, createdAt: '', updatedAt: '' }, + ]); + mockUpdateSavedSearch.mockResolvedValue({ + id: 'ss-1', + name: 'Bau NRW', + filters: {}, + instantAlert: true, + createdAt: '', + updatedAt: '', + }); + + const { SavedSearchBar } = await import('./SavedSearchBar'); + render(); + + const checkbox = await screen.findByLabelText('Sofort-Alert für Bau NRW'); + fireEvent.click(checkbox); + + await waitFor(() => { + expect(mockUpdateSavedSearch).toHaveBeenCalledWith('ss-1', { instantAlert: true }); + }); + await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(2)); + }); }); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx index 4e12cb1..e683418 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx @@ -30,6 +30,12 @@ import { * Per-user, NOT tenant-wide (D-11): the backend derives userId from the * auth cookie; this component never sends a userId. * + * Plan 12-04 (NOTIFY-02, D-04) adds a Sofort-Alert toggle per profile chip: + * a checkbox reflecting `profile.instantAlert` (default false) that calls + * updateSavedSearch(profile.id, { instantAlert: next }) on change. This is + * a profile field alongside name/filters, not a filter key — it never + * touches the URL searchParams filters contract above. + * * Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Question 3, RESOLVED), same convention as FilterPanel/ResultsList. */ @@ -200,6 +206,20 @@ export function SavedSearchBar() { } }; + const handleToggleInstantAlert = async (profile: SavedSearch) => { + setError(null); + try { + await updateSavedSearch(profile.id, { instantAlert: !profile.instantAlert }); + await load(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Sofort-Alert konnte nicht umgeschaltet werden.', + ); + } + }; + return (

Suchprofile

@@ -281,6 +301,19 @@ export function SavedSearchBar() { > {profile.name} +