From b8633863b0eafbc2d2a15ef9ca1ce38ed9851f8e Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 16 Mar 2026 16:40:28 -0500 Subject: [PATCH] fix: add pagination to unbounded GET endpoints All list endpoints now accept ?page and ?limit (default 50, max 200) and return { data, total, page, limit } instead of a bare array, preventing memory and performance failures at scale. - GET /api/dogs: adds pagination, server-side search (?search) and sex filter (?sex), and a stats aggregate (total/males/females) for the Dashboard to avoid counting from the array - GET /api/litters: adds pagination; also fixes N+1 query by fetching all puppies for the current page in a single query instead of one per litter - DogList: moves search/sex filtering server-side with 300ms debounce; adds Prev/Next pagination controls - LitterList: uses paginated response; adds Prev/Next pagination controls - Dashboard: reads counts from stats/total fields instead of array length - LitterDetail, LitterForm: switch dogs fetch to /api/dogs/all (complete list, no pagination, for sire/dam dropdowns) - DogForm: updates litters fetch to use paginated response shape Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/DogForm.jsx | 4 +- client/src/components/LitterForm.jsx | 2 +- client/src/pages/Dashboard.jsx | 16 ++--- client/src/pages/DogList.jsx | 95 ++++++++++++++++++++-------- client/src/pages/LitterDetail.jsx | 2 +- client/src/pages/LitterList.jsx | 47 ++++++++++++-- server/routes/dogs.js | 52 +++++++++++++-- server/routes/litters.js | 47 ++++++++++---- 8 files changed, 197 insertions(+), 68 deletions(-) diff --git a/client/src/components/DogForm.jsx b/client/src/components/DogForm.jsx index 3eac5be..22bdb83 100644 --- a/client/src/components/DogForm.jsx +++ b/client/src/components/DogForm.jsx @@ -64,8 +64,8 @@ function DogForm({ dog, onClose, onSave, isExternal = false }) { const fetchLitters = async () => { try { - const res = await axios.get('/api/litters') - const data = res.data || [] + const res = await axios.get('/api/litters', { params: { limit: 200 } }) + const data = res.data.data || [] setLitters(data) setLittersAvailable(data.length > 0) if (data.length === 0) setUseManualParents(true) diff --git a/client/src/components/LitterForm.jsx b/client/src/components/LitterForm.jsx index ca921bd..7576dd8 100644 --- a/client/src/components/LitterForm.jsx +++ b/client/src/components/LitterForm.jsx @@ -39,7 +39,7 @@ function LitterForm({ litter, prefill, onClose, onSave }) { const fetchDogs = async () => { try { - const res = await axios.get('/api/dogs') + const res = await axios.get('/api/dogs/all') setDogs(res.data) } catch (error) { console.error('Error fetching dogs:', error) diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index 6f1da89..e197c6c 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -21,21 +21,21 @@ function Dashboard() { const fetchDashboardData = async () => { try { const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([ - axios.get('/api/dogs'), - axios.get('/api/litters'), + axios.get('/api/dogs', { params: { page: 1, limit: 8 } }), + axios.get('/api/litters', { params: { page: 1, limit: 1 } }), axios.get('/api/breeding/heat-cycles/active') ]) - const dogs = dogsRes.data + const { data: recentDogsList, stats: dogStats } = dogsRes.data setStats({ - totalDogs: dogs.length, - males: dogs.filter(d => d.sex === 'male').length, - females: dogs.filter(d => d.sex === 'female').length, - totalLitters: littersRes.data.length, + totalDogs: dogStats?.total ?? 0, + males: dogStats?.males ?? 0, + females: dogStats?.females ?? 0, + totalLitters: littersRes.data.total, activeHeatCycles: heatCyclesRes.data.length }) - setRecentDogs(dogs.slice(0, 8)) + setRecentDogs(recentDogsList) setLoading(false) } catch (error) { console.error('Error fetching dashboard data:', error) diff --git a/client/src/pages/DogList.jsx b/client/src/pages/DogList.jsx index d1f67a4..a9910ce 100644 --- a/client/src/pages/DogList.jsx +++ b/client/src/pages/DogList.jsx @@ -1,57 +1,69 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react' import axios from 'axios' import DogForm from '../components/DogForm' import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge' +const LIMIT = 50 + function DogList() { const [dogs, setDogs] = useState([]) - const [filteredDogs, setFilteredDogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [sexFilter, setSexFilter] = useState('all') const [loading, setLoading] = useState(true) const [showAddModal, setShowAddModal] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) // { id, name } const [deleting, setDeleting] = useState(false) + const searchTimerRef = useRef(null) - useEffect(() => { fetchDogs() }, []) - useEffect(() => { filterDogs() }, [dogs, search, sexFilter]) + useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line - const fetchDogs = async () => { + const fetchDogs = async (p, q, s) => { + setLoading(true) try { - const res = await axios.get('/api/dogs') - setDogs(res.data) - setLoading(false) + const params = { page: p, limit: LIMIT } + if (q) params.search = q + if (s !== 'all') params.sex = s + const res = await axios.get('/api/dogs', { params }) + setDogs(res.data.data) + setTotal(res.data.total) + setPage(p) } catch (error) { console.error('Error fetching dogs:', error) + } finally { setLoading(false) } } - const filterDogs = () => { - let filtered = dogs - if (search) { - filtered = filtered.filter(dog => - dog.name.toLowerCase().includes(search.toLowerCase()) || - (dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase())) - ) - } - if (sexFilter !== 'all') { - filtered = filtered.filter(dog => dog.sex === sexFilter) - } - setFilteredDogs(filtered) + const handleSearchChange = (value) => { + setSearch(value) + if (searchTimerRef.current) clearTimeout(searchTimerRef.current) + searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300) } - const handleSave = () => { fetchDogs() } + const handleSexChange = (value) => { + setSexFilter(value) + fetchDogs(1, search, value) + } + + const handleClearFilters = () => { + setSearch('') + setSexFilter('all') + fetchDogs(1, '', 'all') + } + + const handleSave = () => { fetchDogs(page, search, sexFilter) } const handleDelete = async () => { if (!deleteTarget) return setDeleting(true) try { await axios.delete(`/api/dogs/${deleteTarget.id}`) - setDogs(prev => prev.filter(d => d.id !== deleteTarget.id)) setDeleteTarget(null) + fetchDogs(page, search, sexFilter) } catch (err) { console.error('Delete failed:', err) alert('Failed to delete dog. Please try again.') @@ -60,6 +72,8 @@ function DogList() { } } + const totalPages = Math.ceil(total / LIMIT) + const calculateAge = (birthDate) => { if (!birthDate) return null const today = new Date() @@ -85,7 +99,7 @@ function DogList() {

Dogs

- {filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'} + {total} {total === 1 ? 'dog' : 'dogs'} {search || sexFilter !== 'all' ? ' matching filters' : ' total'}

@@ -105,11 +119,11 @@ function DogList() { className="input" placeholder="Search by name or registration..." value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} style={{ paddingLeft: '2.75rem' }} /> - handleSexChange(e.target.value)} style={{ width: '140px' }}> @@ -117,7 +131,7 @@ function DogList() { {(search || sexFilter !== 'all') && ( + + Page {page} of {totalPages} + + + + )} + {/* Add Dog Modal */} {showAddModal && ( { try { - const res = await axios.get('/api/dogs') + const res = await axios.get('/api/dogs/all') setAllDogs(res.data) } catch (err) { console.error('Error fetching dogs:', err) } } diff --git a/client/src/pages/LitterList.jsx b/client/src/pages/LitterList.jsx index 5d3c709..ec6f324 100644 --- a/client/src/pages/LitterList.jsx +++ b/client/src/pages/LitterList.jsx @@ -4,8 +4,12 @@ import { useNavigate } from 'react-router-dom' import axios from 'axios' import LitterForm from '../components/LitterForm' +const LIMIT = 50 + function LitterList() { const [litters, setLitters] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) const [showForm, setShowForm] = useState(false) const [editingLitter, setEditingLitter] = useState(null) @@ -13,7 +17,7 @@ function LitterList() { const navigate = useNavigate() useEffect(() => { - fetchLitters() + fetchLitters(1) // Auto-open form with prefill from BreedingCalendar "Record Litter" CTA const stored = sessionStorage.getItem('prefillLitter') if (stored) { @@ -27,10 +31,12 @@ function LitterList() { } }, []) - const fetchLitters = async () => { + const fetchLitters = async (p = page) => { try { - const res = await axios.get('/api/litters') - setLitters(res.data) + const res = await axios.get('/api/litters', { params: { page: p, limit: LIMIT } }) + setLitters(res.data.data) + setTotal(res.data.total) + setPage(p) } catch (error) { console.error('Error fetching litters:', error) } finally { @@ -38,6 +44,8 @@ function LitterList() { } } + const totalPages = Math.ceil(total / LIMIT) + const handleCreate = () => { setEditingLitter(null) setPrefill(null) @@ -56,14 +64,14 @@ function LitterList() { if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return try { await axios.delete(`/api/litters/${id}`) - fetchLitters() + fetchLitters(page) } catch (error) { console.error('Error deleting litter:', error) } } const handleSave = () => { - fetchLitters() + fetchLitters(page) } if (loading) { @@ -80,7 +88,7 @@ function LitterList() { - {litters.length === 0 ? ( + {total === 0 ? (

No litters recorded yet

@@ -143,6 +151,31 @@ function LitterList() {
)} + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + {showForm && ( { try { const db = getDatabase(); const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true'; const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true'; + const search = (req.query.search || '').trim(); + const sex = req.query.sex === 'male' || req.query.sex === 'female' ? req.query.sex : ''; + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50)); + const offset = (page - 1) * limit; - let whereClause; + let baseWhere; if (externalOnly) { - whereClause = 'WHERE is_active = 1 AND is_external = 1'; + baseWhere = 'is_active = 1 AND is_external = 1'; } else if (includeExternal) { - whereClause = 'WHERE is_active = 1'; + baseWhere = 'is_active = 1'; } else { - whereClause = 'WHERE is_active = 1 AND is_external = 0'; + baseWhere = 'is_active = 1 AND is_external = 0'; } + const filters = []; + const params = []; + if (search) { + filters.push('(name LIKE ? OR registration_number LIKE ?)'); + params.push(`%${search}%`, `%${search}%`); + } + if (sex) { + filters.push('sex = ?'); + params.push(sex); + } + + const whereClause = 'WHERE ' + [baseWhere, ...filters].join(' AND '); + + const total = db.prepare(`SELECT COUNT(*) as count FROM dogs ${whereClause}`).get(...params).count; + + const statsWhere = externalOnly + ? 'WHERE is_active = 1 AND is_external = 1' + : includeExternal + ? 'WHERE is_active = 1' + : 'WHERE is_active = 1 AND is_external = 0'; + const stats = db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN sex = 'male' THEN 1 ELSE 0 END) as males, + SUM(CASE WHEN sex = 'female' THEN 1 ELSE 0 END) as females + FROM dogs ${statsWhere} + `).get(); + const dogs = db.prepare(` SELECT ${DOG_COLS} FROM dogs ${whereClause} ORDER BY name - `).all(); + LIMIT ? OFFSET ? + `).all(...params, limit, offset); - res.json(attachParents(db, dogs)); + res.json({ data: attachParents(db, dogs), total, page, limit, stats }); } catch (error) { console.error('Error fetching dogs:', error); res.status(500).json({ error: error.message }); diff --git a/server/routes/litters.js b/server/routes/litters.js index c400be3..a53dc44 100644 --- a/server/routes/litters.js +++ b/server/routes/litters.js @@ -2,31 +2,50 @@ const express = require('express'); const router = express.Router(); const { getDatabase } = require('../db/init'); -// GET all litters +// GET all litters (paginated) +// ?page=1&limit=50 +// Response: { data, total, page, limit } router.get('/', (req, res) => { try { const db = getDatabase(); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50)); + const offset = (page - 1) * limit; + + const total = db.prepare('SELECT COUNT(*) as count FROM litters').get().count; + const litters = db.prepare(` - SELECT l.*, + SELECT l.*, s.name as sire_name, s.registration_number as sire_reg, d.name as dam_name, d.registration_number as dam_reg FROM litters l JOIN dogs s ON l.sire_id = s.id JOIN dogs d ON l.dam_id = d.id ORDER BY l.breeding_date DESC - `).all(); - - litters.forEach(litter => { - litter.puppies = db.prepare(` - SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1 - `).all(litter.id); - litter.puppies.forEach(puppy => { - puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : []; + LIMIT ? OFFSET ? + `).all(limit, offset); + + if (litters.length > 0) { + const litterIds = litters.map(l => l.id); + const placeholders = litterIds.map(() => '?').join(','); + const allPuppies = db.prepare(` + SELECT * FROM dogs WHERE litter_id IN (${placeholders}) AND is_active = 1 + `).all(...litterIds); + + const puppiesByLitter = {}; + allPuppies.forEach(p => { + p.photo_urls = p.photo_urls ? JSON.parse(p.photo_urls) : []; + if (!puppiesByLitter[p.litter_id]) puppiesByLitter[p.litter_id] = []; + puppiesByLitter[p.litter_id].push(p); }); - litter.actual_puppy_count = litter.puppies.length; - }); - - res.json(litters); + + litters.forEach(l => { + l.puppies = puppiesByLitter[l.id] || []; + l.actual_puppy_count = l.puppies.length; + }); + } + + res.json({ data: litters, total, page, limit }); } catch (error) { res.status(500).json({ error: error.message }); }