feat(11-05): read/favorite triage toggles + Merklisten-Filter in the UI

Adds fetchTriage()/setTriage() to tender-radar-api.ts (plain fetch,
consistent with the existing client). ResultsList batch-fetches the
current user's triage state for the visible ids and merges it into a
local per-tenderId map; a failed triage fetch never blocks rendering the
list itself. Each row gets a Gelesen/Ungelesen and a Favorit toggle
(optimistic update with revert-on-failure, event.stopPropagation() so the
row's own click-to-open-detail doesn't fire); read rows render dimmed.
FilterPanel gains a "Nur Favoriten/Merkliste" checkbox writing favOnly
into the URL, which ResultsList already forwards generically to the
backend. ResultsList.test.tsx extended (Rule 3 — required to keep the
component test green with the new triage batch call) with coverage for
batch-merge, both toggles, optimistic revert, and graceful degradation
when the triage fetch fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:37:30 +02:00
parent 5f97eca804
commit 1eb9e4f567
4 changed files with 344 additions and 25 deletions
@@ -128,6 +128,10 @@ export function FilterPanel() {
// includeNullValue defaults to true (D-05 Pflichtkriterium) — only an
// explicit "false" excludes rows without an estimatedValue.
const includeNullValue = searchParams.get('includeNullValue') !== 'false';
// Merklisten-Filter (UI-04, D-10). Default off — only an explicit "true"
// restricts the list to this user's favorited tenders (backend derives
// the favorite tenderIds from the auth context, never from this param).
const favOnly = searchParams.get('favOnly') === 'true';
const setParam = (key: string, value: string | null) => {
const next = new URLSearchParams(searchParams.toString());
@@ -220,6 +224,19 @@ export function FilterPanel() {
Nur noch offene
</label>
</div>
<div className="flex items-center gap-2 pb-2">
<input
id="tr-filter-fav-only"
type="checkbox"
checked={favOnly}
onChange={(e) => setParam('favOnly', e.target.checked ? 'true' : null)}
className="h-4 w-4"
/>
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
Nur Favoriten/Merkliste
</label>
</div>
</div>
<div className="flex flex-wrap items-end gap-4">
@@ -1,18 +1,23 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05).
* Mirrors the SourceConfigForm.test.tsx pattern: mock the api client
* module, render, assert via waitFor. next/navigation is mocked because
* ResultsList reads/writes URL searchParams for filter + sort state
* (Research: URL-Param-getriebener Client).
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05)
* plus Plan 11-05 (UI-03/04: gelesen/ungelesen + Favorit toggles, batch
* triage merge). Mirrors the SourceConfigForm.test.tsx pattern: mock the
* api client module, render, assert via waitFor. next/navigation is mocked
* because ResultsList reads/writes URL searchParams for filter + sort
* state (Research: URL-Param-getriebener Client).
*/
const mockListTenders = vi.fn();
const mockFetchTriage = vi.fn();
const mockSetTriage = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
listTenders: (...args: unknown[]) => mockListTenders(...args),
fetchTriage: (...args: unknown[]) => mockFetchTriage(...args),
setTriage: (...args: unknown[]) => mockSetTriage(...args),
}));
const mockReplace = vi.fn();
@@ -26,6 +31,8 @@ vi.mock('next/navigation', () => ({
afterEach(() => {
cleanup();
mockListTenders.mockReset();
mockFetchTriage.mockReset();
mockSetTriage.mockReset();
mockReplace.mockReset();
mockSearchParams = new URLSearchParams();
vi.restoreAllMocks();
@@ -48,6 +55,7 @@ describe('ResultsList', () => {
page: 1,
limit: 20,
});
mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
@@ -75,6 +83,7 @@ describe('ResultsList', () => {
page: 1,
limit: 20,
});
mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
@@ -86,6 +95,7 @@ describe('ResultsList', () => {
it('renders an empty-state message when listTenders returns no items', async () => {
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
@@ -95,3 +105,110 @@ describe('ResultsList', () => {
});
});
});
describe('ResultsList — triage (UI-03/04, Plan 11-05: batch-merge + read/favorite toggles)', () => {
const oneItem = {
items: [
{
id: 't1',
title: 'Sanierung Rathaus',
buyerName: 'Stadt Musterhausen',
deadlineAt: '2026-09-01T00:00:00.000Z',
estimatedValue: '150000.00',
publishedAt: '2026-07-01T00:00:00.000Z',
},
],
total: 1,
page: 1,
limit: 20,
};
it('batch-fetches triage for the visible ids and merges the returned isRead/isFavorite state in', async () => {
mockListTenders.mockResolvedValue(oneItem);
mockFetchTriage.mockResolvedValue([
{ tenderId: 't1', isRead: true, isFavorite: true },
]);
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
await waitFor(() => {
expect(mockFetchTriage).toHaveBeenCalledWith(['t1']);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Als ungelesen markieren' })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
});
it('clicking the read toggle calls setTriage(tenderId, isRead: true) and flips the button label optimistically', async () => {
mockListTenders.mockResolvedValue(oneItem);
mockFetchTriage.mockResolvedValue([]);
mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: true, isFavorite: false });
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
const readButton = await screen.findByRole('button', {
name: 'Als gelesen markieren',
});
fireEvent.click(readButton);
expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isRead: true });
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Als ungelesen markieren' }),
).toBeInTheDocument();
});
});
it('clicking the favorite toggle calls setTriage(tenderId, isFavorite: true) and does not trigger the row click (openDetail)', async () => {
mockListTenders.mockResolvedValue(oneItem);
mockFetchTriage.mockResolvedValue([]);
mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: false, isFavorite: true });
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
const favButton = await screen.findByRole('button', { name: 'Favorit setzen' });
fireEvent.click(favButton);
expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isFavorite: true });
expect(mockReplace).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
});
});
it('reverts the optimistic update when setTriage rejects', async () => {
mockListTenders.mockResolvedValue(oneItem);
mockFetchTriage.mockResolvedValue([]);
mockSetTriage.mockRejectedValue(new Error('network error'));
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
const readButton = await screen.findByRole('button', {
name: 'Als gelesen markieren',
});
fireEvent.click(readButton);
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Als gelesen markieren' }),
).toBeInTheDocument();
});
});
it('a failed batch triage fetch does not block rendering the tender list', async () => {
mockListTenders.mockResolvedValue(oneItem);
mockFetchTriage.mockRejectedValue(new Error('network error'));
const { ResultsList } = await import('./ResultsList');
render(<ResultsList />);
await waitFor(() => {
expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument();
});
});
});
@@ -2,7 +2,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { listTenders, type Tender } from '@/lib/tender-radar-api';
import {
fetchTriage,
listTenders,
setTriage,
type Tender,
} from '@/lib/tender-radar-api';
/**
* ResultsList — the Trefferliste replacing the Phase 10 module placeholder
@@ -24,6 +29,12 @@ const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: stri
const DEFAULT_LIMIT = 20;
/** Per-tenderId triage state merged in from the batch GET /triage fetch. */
interface TriageState {
isRead: boolean;
isFavorite: boolean;
}
function formatDate(value: string | null): string {
if (!value) return '–';
const d = new Date(value);
@@ -57,6 +68,12 @@ export function ResultsList() {
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// UI-03/04: per-tenderId triage overlay (gelesen/ungelesen, Favorit),
// merged in from a batch GET /triage fetch of the visible ids. Keyed by
// tenderId, not persisted beyond this component's lifetime — always
// re-fetched per page/filter change (D-11: per-user, never trusted from
// any other source).
const [triage, setTriageMap] = useState<Record<string, TriageState>>({});
const load = useCallback(async () => {
setIsLoading(true);
@@ -68,6 +85,29 @@ export function ResultsList() {
setItems(data.items);
setTotal(data.total);
setPage(data.page);
// Batch-merge this user's triage state for the visible ids (UI-03/04,
// key_link: "GET /triage batch → merge in ResultsList"). Triage is a
// supplementary per-user overlay — a failed batch-load must not block
// rendering the tender list itself.
const ids = data.items.map((t) => t.id);
try {
const rows = await fetchTriage(ids);
if (Array.isArray(rows)) {
setTriageMap((prev) => {
const next = { ...prev };
for (const row of rows) {
next[row.tenderId] = {
isRead: row.isRead,
isFavorite: row.isFavorite,
};
}
return next;
});
}
} catch {
// Swallow — the list itself is not blocked by a triage load failure.
}
} catch (err) {
setError(
err instanceof Error
@@ -107,6 +147,45 @@ export function ResultsList() {
router.replace(`?${next.toString()}`);
};
/**
* Gelesen/Ungelesen-Toggle (D-09, UI-03). Optimistic update — reverts on
* a failed PUT so the UI never claims a state the backend didn't accept.
* `stopPropagation` prevents the row's own onClick (openDetail) firing.
*/
const toggleRead = async (e: React.MouseEvent, tender: Tender) => {
e.stopPropagation();
const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
const nextRead = !previous.isRead;
setTriageMap((prev) => ({
...prev,
[tender.id]: { ...previous, isRead: nextRead },
}));
try {
await setTriage({ tenderId: tender.id, isRead: nextRead });
} catch {
setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
}
};
/**
* Favorit-Toggle (D-10, UI-04). Same optimistic-with-revert pattern as
* toggleRead.
*/
const toggleFavorite = async (e: React.MouseEvent, tender: Tender) => {
e.stopPropagation();
const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
const nextFavorite = !previous.isFavorite;
setTriageMap((prev) => ({
...prev,
[tender.id]: { ...previous, isFavorite: nextFavorite },
}));
try {
await setTriage({ tenderId: tender.id, isFavorite: nextFavorite });
} catch {
setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
}
};
const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT);
const totalPages = Math.max(1, Math.ceil(total / limit));
@@ -132,13 +211,14 @@ export function ResultsList() {
{currentSort === h.key ? ' ▾' : ''}
</th>
))}
<th className={thStaticCls}>Triage</th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-0">
{Array.from({ length: 5 }).map((_, j) => (
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 rounded bg-muted animate-pulse" />
</td>
@@ -147,13 +227,13 @@ export function ResultsList() {
))
) : error ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-sm text-destructive">
<td colSpan={6} className="px-4 py-10 text-center text-sm text-destructive">
{error}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center">
<td colSpan={6} className="px-4 py-10 text-center">
<p className="font-medium text-foreground">
Keine Ausschreibungen gefunden.
</p>
@@ -164,21 +244,73 @@ export function ResultsList() {
</td>
</tr>
) : (
items.map((tender) => (
<tr
key={tender.id}
onClick={() => openDetail(tender.id)}
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
>
<td className="px-4 py-3 font-medium text-foreground">
{tender.title}
</td>
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
</tr>
))
items.map((tender) => {
const rowTriage = triage[tender.id] ?? {
isRead: false,
isFavorite: false,
};
return (
<tr
key={tender.id}
onClick={() => openDetail(tender.id)}
className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors ${
rowTriage.isRead ? 'opacity-60' : ''
}`}
>
<td className="px-4 py-3 font-medium text-foreground">
{tender.title}
</td>
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => toggleRead(e, tender)}
aria-pressed={rowTriage.isRead}
aria-label={
rowTriage.isRead
? 'Als ungelesen markieren'
: 'Als gelesen markieren'
}
title={
rowTriage.isRead
? 'Als ungelesen markieren'
: 'Als gelesen markieren'
}
className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
>
{rowTriage.isRead ? 'Gelesen' : 'Ungelesen'}
</button>
<button
type="button"
onClick={(e) => toggleFavorite(e, tender)}
aria-pressed={rowTriage.isFavorite}
aria-label={
rowTriage.isFavorite
? 'Favorit entfernen'
: 'Favorit setzen'
}
title={
rowTriage.isFavorite
? 'Favorit entfernen'
: 'Favorit setzen'
}
className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
rowTriage.isFavorite
? 'border-amber-400 text-amber-500'
: 'border-border text-foreground'
}`}
>
{rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'}
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>