Files
GerbilManager/gerbil-manager-web/src/pages/WuerfeListPage.tsx
Gulum b0e92c363a fix(ux): zuverlässige Scroll-Wiederherstellung für Infinite-Scroll-Listen
Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach
Zurück-Navigation behoben:

1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per
   ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann
   zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener
   Scroll-Eingabe ab; Sicherheitsnetz nach 15 s.

2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler
   restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück-
   Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position
   unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge.

tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:01:28 +02:00

270 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
import type { Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import { useGerbilName } from '../components/breederSuffix'
const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc'
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
function yearOptions(): number[] {
const current = new Date().getFullYear()
return Array.from({ length: 30 }, (_, i) => current - i)
}
interface Pair {
fatherId: string
motherId: string
count: number
lastDate: string
}
/** Group litters (with both parents known) by (father, mother). */
function derivePairs(litters: Litter[]): Pair[] {
const map = new Map<string, Pair>()
for (const l of litters) {
if (!l.fatherId || !l.motherId) continue
const key = `${l.fatherId}|${l.motherId}`
const existing = map.get(key)
if (existing) {
existing.count += 1
if (l.date > existing.lastDate) existing.lastDate = l.date
} else {
map.set(key, { fatherId: l.fatherId, motherId: l.motherId, count: 1, lastDate: l.date })
}
}
return Array.from(map.values()).sort((a, b) => b.lastDate.localeCompare(a.lastDate))
}
export default function WuerfeListPage() {
const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters')
const [search, setSearch] = useState('')
const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc')
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
const gerbilName = useGerbilName()
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const gerbilById = useMemo(() => {
const map = new Map<string, Gerbil>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
return map
}, [gerbils.data])
const parentName = useCallback((id: string | null): string => {
if (!id) return '—'
const gp = gerbilById.get(id)
return gp ? gerbilName(gp) || de.pages.gerbils.nameless : t.detail.unknownParent
}, [gerbilById, gerbilName, t.detail.unknownParent])
// Litters tab (paged, filtered).
const filter = andFilter(
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
)
const orderBy = SORT_ORDER_BY[sort]
const litters = useInfiniteList(
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
`${filter}|${orderBy}`,
'wuerfe',
)
const littersSentinelRef = useInfiniteSentinel(litters)
// Pairs tab: all litters, grouped client-side.
const allLitters = useApi(
() => (tab === 'pairs' ? listLitters({ page: 1, pageSize: 1000, orderBy: 'date desc' }) : Promise.resolve(null)),
[tab],
)
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
// Search-enabled pairs list (client-side search by parent name)
const filteredPairs = useMemo(() => {
if (!search.trim()) return pairs
const term = search.toLowerCase()
return pairs.filter((p) =>
parentName(p.fatherId).toLowerCase().includes(term) ||
parentName(p.motherId).toLowerCase().includes(term)
)
}, [pairs, search, parentName])
const items = litters.items
return (
<section className="page">
<header className="page-head">
<h2>{t.title}</h2>
<Link to="/wuerfe/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'litters'}
className={tab === 'litters' ? 'tab tab--active' : 'tab'}
onClick={() => setTab('litters')}
>
{t.tabs.litters}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'pairs'}
className={tab === 'pairs' ? 'tab tab--active' : 'tab'}
onClick={() => setTab('pairs')}
>
{t.tabs.pairs}
</button>
</div>
{tab === 'litters' && (
<>
<FilterPanel
searchField={
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Wurf suchen"
/>
}
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
onReset={() => {
setSearch('')
setYear('')
setSort('dateDesc')
}}
>
<label className="field">
<span>{t.filterYear}</span>
<select
value={year}
onChange={(e) => setYear(e.target.value)}
>
<option value="">{t.allYears}</option>
{yearOptions().map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</label>
<label className="field">
<span>{de.pages.gerbils.filters.sortBy}</span>
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
<option value="dateDesc">{t.sort.dateDesc}</option>
<option value="dateAsc">{t.sort.dateAsc}</option>
</select>
</label>
</FilterPanel>
{litters.loading && <p className="muted">{de.common.loading}</p>}
{litters.error && (
<div className="alert alert--error">
<span>{litters.error}</span>
<button type="button" className="btn" onClick={litters.reload}>
{de.common.retry}
</button>
</div>
)}
{!litters.loading && !litters.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="gerbil-card">
<span className="gerbil-card__name">{l.name}</span>
<span className="gerbil-card__meta">{formatDate(l.date)}</span>
<span className="gerbil-card__meta">
{parentName(l.fatherId)} × {parentName(l.motherId)}
</span>
<span className="gerbil-card__meta">
{l.totalBorn != null ? `${l.totalBorn} ${t.fields.totalBorn}` : '—'}
</span>
</Link>
</li>
))}
</ul>
)}
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{litters.hasMore && (
<div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
)}
{litters.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</>
)}
{tab === 'pairs' && (
<>
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<input
type="search"
className="input"
style={{ maxWidth: '320px' }}
placeholder="Elternteil suchen …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Elternteil suchen"
/>
{search && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => setSearch('')}
>
{de.pages.gerbils.filters.reset}
</button>
)}
</div>
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{filteredPairs.length > 0 && (
<ul className="card-list">
{filteredPairs.map((p) => (
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
<span className="gerbil-card__name">
{parentName(p.fatherId)} × {parentName(p.motherId)}
</span>
<span className="gerbil-card__meta">
{p.count} {t.pairs.litterCount}
</span>
<span className="gerbil-card__meta">
{t.pairs.lastLitter}: {formatDate(p.lastDate)}
</span>
<Link to={`/genetik?vater=${p.fatherId}&mutter=${p.motherId}`} className="link-btn">
{t.pairs.testMating}
</Link>
</li>
))}
</ul>
)}
</>
)}
</section>
)
}