From 6e37abf6e8f37f5ae5bab76fde595f2d0beb1004 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 21:02:15 -0500 Subject: [PATCH 1/2] feat: add puppy weight/health log endpoints to litters router --- server/routes/litters.js | 103 ++++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/server/routes/litters.js b/server/routes/litters.js index e8cf492..c400be3 100644 --- a/server/routes/litters.js +++ b/server/routes/litters.js @@ -16,17 +16,13 @@ router.get('/', (req, res) => { ORDER BY l.breeding_date DESC `).all(); - // Get puppies for each litter using litter_id 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) : []; }); - - // Update puppy_count based on actual puppies litter.actual_puppy_count = litter.puppies.length; }); @@ -36,14 +32,14 @@ router.get('/', (req, res) => { } }); -// GET single litter +// GET single litter with puppies router.get('/:id', (req, res) => { try { const db = getDatabase(); const litter = db.prepare(` - SELECT l.*, - s.*, s.name as sire_name, - d.*, d.name as dam_name + SELECT l.*, + s.name as sire_name, s.registration_number as sire_reg, s.breed as sire_breed, + d.name as dam_name, d.registration_number as dam_reg, d.breed as dam_breed FROM litters l JOIN dogs s ON l.sire_id = s.id JOIN dogs d ON l.dam_id = d.id @@ -54,7 +50,6 @@ router.get('/:id', (req, res) => { return res.status(404).json({ error: 'Litter not found' }); } - // Get puppies using litter_id litter.puppies = db.prepare(` SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1 `).all(litter.id); @@ -74,7 +69,7 @@ router.get('/:id', (req, res) => { // POST create new litter router.post('/', (req, res) => { try { - const { sire_id, dam_id, breeding_date, whelping_date, notes } = req.body; + const { sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes } = req.body; if (!sire_id || !dam_id || !breeding_date) { return res.status(400).json({ error: 'Sire, dam, and breeding date are required' }); @@ -82,7 +77,6 @@ router.post('/', (req, res) => { const db = getDatabase(); - // Verify sire is male and dam is female const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id); const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id); @@ -94,12 +88,11 @@ router.post('/', (req, res) => { } const result = db.prepare(` - INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, notes) - VALUES (?, ?, ?, ?, ?) - `).run(sire_id, dam_id, breeding_date, whelping_date, notes); + INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes) + VALUES (?, ?, ?, ?, ?, ?) + `).run(sire_id, dam_id, breeding_date, whelping_date || null, puppy_count || 0, notes || null); const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json(litter); } catch (error) { res.status(500).json({ error: error.message }); @@ -110,13 +103,12 @@ router.post('/', (req, res) => { router.put('/:id', (req, res) => { try { const { breeding_date, whelping_date, puppy_count, notes } = req.body; - const db = getDatabase(); db.prepare(` UPDATE litters SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ? WHERE id = ? - `).run(breeding_date, whelping_date, puppy_count, notes, req.params.id); + `).run(breeding_date, whelping_date || null, puppy_count || 0, notes || null, req.params.id); const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id); res.json(litter); @@ -131,22 +123,14 @@ router.post('/:id/puppies/:puppyId', (req, res) => { const { id: litterId, puppyId } = req.params; const db = getDatabase(); - // Verify litter exists const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId); - if (!litter) { - return res.status(404).json({ error: 'Litter not found' }); - } + if (!litter) return res.status(404).json({ error: 'Litter not found' }); - // Verify puppy exists const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId); - if (!puppy) { - return res.status(404).json({ error: 'Puppy not found' }); - } + if (!puppy) return res.status(404).json({ error: 'Puppy not found' }); - // Link puppy to litter db.prepare('UPDATE dogs SET litter_id = ? WHERE id = ?').run(litterId, puppyId); - // Also update parent relationships if not set const existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId); const hasSire = existingParents.some(p => p.parent_type === 'sire'); const hasDam = existingParents.some(p => p.parent_type === 'dam'); @@ -169,26 +153,77 @@ router.delete('/:id/puppies/:puppyId', (req, res) => { try { const { puppyId } = req.params; const db = getDatabase(); - db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId); - res.json({ message: 'Puppy removed from litter' }); } catch (error) { res.status(500).json({ error: error.message }); } }); +// ─── Puppy Weight / Health Log ─────────────────────────────────────────────── + +// GET weight/health logs for a puppy +router.get('/:litterId/puppies/:puppyId/logs', (req, res) => { + try { + const db = getDatabase(); + // Use health_records table with note field to store weight logs + const logs = db.prepare(` + SELECT * FROM health_records + WHERE dog_id = ? AND record_type = 'weight_log' + ORDER BY record_date ASC + `).all(req.params.puppyId); + res.json(logs); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// POST add weight/health log entry for a puppy +router.post('/:litterId/puppies/:puppyId/logs', (req, res) => { + try { + const { puppyId } = req.params; + const { record_date, weight_oz, weight_lbs, notes, record_type } = req.body; + + if (!record_date) return res.status(400).json({ error: 'record_date is required' }); + + const db = getDatabase(); + + // Store weight as notes JSON in health_records + const description = JSON.stringify({ + weight_oz: weight_oz || null, + weight_lbs: weight_lbs || null, + notes: notes || '' + }); + + const result = db.prepare(` + INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name) + VALUES (?, ?, ?, ?, ?) + `).run(puppyId, record_type || 'weight_log', record_date, description, null); + + const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(log); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// DELETE weight/health log entry +router.delete('/:litterId/puppies/:puppyId/logs/:logId', (req, res) => { + try { + const db = getDatabase(); + db.prepare('DELETE FROM health_records WHERE id = ? AND dog_id = ?').run(req.params.logId, req.params.puppyId); + res.json({ message: 'Log entry deleted' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // DELETE litter router.delete('/:id', (req, res) => { try { const db = getDatabase(); - - // Remove litter_id from associated puppies db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id); - - // Delete the litter db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id); - res.json({ message: 'Litter deleted successfully' }); } catch (error) { res.status(500).json({ error: error.message }); From 421b875661d44143094888dfd4273ceaee0427a1 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 21:06:03 -0500 Subject: [PATCH 2/2] feat: add PuppyLogPanel weight/health logs + projected whelping window to LitterDetail --- client/src/pages/LitterDetail.jsx | 385 ++++++++++++++++++++++-------- 1 file changed, 292 insertions(+), 93 deletions(-) diff --git a/client/src/pages/LitterDetail.jsx b/client/src/pages/LitterDetail.jsx index ee58a88..0023c01 100644 --- a/client/src/pages/LitterDetail.jsx +++ b/client/src/pages/LitterDetail.jsx @@ -1,9 +1,254 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { ArrowLeft, Plus, X, ExternalLink, Dog } from 'lucide-react' +import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import axios from 'axios' import LitterForm from '../components/LitterForm' +// ─── Puppy Log Panel ──────────────────────────────────────────────────────────── +function PuppyLogPanel({ litterId, puppy, whelpingDate }) { + const [open, setOpen] = useState(false) + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(false) + const [showAdd, setShowAdd] = useState(false) + const [form, setForm] = useState({ + record_date: whelpingDate || '', + weight_oz: '', + weight_lbs: '', + notes: '', + record_type: 'weight_log' + }) + const [saving, setSaving] = useState(false) + + useEffect(() => { if (open) fetchLogs() }, [open]) + + const fetchLogs = async () => { + setLoading(true) + try { + const res = await axios.get(`/api/litters/${litterId}/puppies/${puppy.id}/logs`) + const parsed = res.data.map(l => { + try { return { ...l, _data: JSON.parse(l.description) } } catch { return { ...l, _data: {} } } + }) + setLogs(parsed) + } catch (e) { console.error(e) } + finally { setLoading(false) } + } + + const handleAdd = async () => { + if (!form.record_date) return + setSaving(true) + try { + await axios.post(`/api/litters/${litterId}/puppies/${puppy.id}/logs`, form) + setShowAdd(false) + setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' })) + fetchLogs() + } catch (e) { console.error(e) } + finally { setSaving(false) } + } + + const handleDelete = async (logId) => { + if (!window.confirm('Delete this log entry?')) return + try { + await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`) + fetchLogs() + } catch (e) { console.error(e) } + } + + const TYPES = [ + { value: 'weight_log', label: '⚖️ Weight Check' }, + { value: 'health_note', label: '📝 Health Note' }, + { value: 'deworming', label: '🐛 Deworming' }, + { value: 'vaccination', label: '💉 Vaccination' }, + ] + + return ( +
+ + + {open && ( +
+ {loading ? ( +

Loading...

+ ) : logs.length === 0 ? ( +

No logs yet.

+ ) : ( +
+ {logs.map(l => ( +
+
+ + {new Date(l.record_date + 'T00:00:00').toLocaleDateString()} + + {' • '} + + {TYPES.find(t => t.value === l.record_type)?.label || l.record_type} + + {l._data?.weight_oz && — {l._data.weight_oz} oz} + {l._data?.weight_lbs && ({l._data.weight_lbs} lbs)} + {l._data?.notes && ( +
+ {l._data.notes} +
+ )} +
+ +
+ ))} +
+ )} + + {showAdd ? ( +
+
+ setForm(f => ({ ...f, record_date: e.target.value }))} + /> + +
+ {form.record_type === 'weight_log' && ( +
+ setForm(f => ({ ...f, weight_oz: e.target.value }))} + /> + setForm(f => ({ ...f, weight_lbs: e.target.value }))} + /> +
+ )} + setForm(f => ({ ...f, notes: e.target.value }))} + /> +
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ) +} + +// ─── Whelping Window Banner ─────────────────────────────────────────────── +function addDays(dateStr, n) { + const d = new Date(dateStr + 'T00:00:00') + d.setDate(d.getDate() + n) + return d +} +function fmt(d) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) } + +function WhelpingBanner({ breedingDate, whelpingDate }) { + if (whelpingDate) return null // already whelped, no need for estimate + if (!breedingDate) return null + + const earliest = addDays(breedingDate, 58) + const expected = addDays(breedingDate, 63) + const latest = addDays(breedingDate, 68) + const today = new Date() + const daysUntil = Math.ceil((expected - today) / 86400000) + + let urgency = 'var(--success)' + let urgencyBg = 'rgba(16,185,129,0.06)' + let statusLabel = `~${daysUntil} days away` + if (daysUntil <= 7 && daysUntil > 0) { + urgency = '#d97706'; urgencyBg = 'rgba(217,119,6,0.08)' + statusLabel = `⚠️ ${daysUntil} days — prepare whelping area!` + } else if (daysUntil <= 0) { + urgency = '#e53e3e'; urgencyBg = 'rgba(229,62,62,0.08)' + statusLabel = '🔴 Expected date has passed — confirm or update whelping date' + } + + return ( +
+
+ 💕 Projected Whelping Window + + {statusLabel} + +
+
+
+ Earliest (Day 58) +
{fmt(earliest)} +
+
+ Expected (Day 63) +
{fmt(expected)} +
+
+ Latest (Day 68) +
{fmt(latest)} +
+
+
+ ) +} + +// ─── Main LitterDetail ───────────────────────────────────────────────────────── function LitterDetail() { const { id } = useParams() const navigate = useNavigate() @@ -14,14 +259,11 @@ function LitterDetail() { const [allDogs, setAllDogs] = useState([]) const [selectedPuppyId, setSelectedPuppyId] = useState('') const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' }) - const [addMode, setAddMode] = useState('existing') // 'existing' | 'new' + const [addMode, setAddMode] = useState('existing') const [error, setError] = useState('') const [saving, setSaving] = useState(false) - useEffect(() => { - fetchLitter() - fetchAllDogs() - }, [id]) + useEffect(() => { fetchLitter(); fetchAllDogs() }, [id]) const fetchLitter = async () => { try { @@ -38,9 +280,7 @@ function LitterDetail() { try { const res = await axios.get('/api/dogs') setAllDogs(res.data) - } catch (err) { - console.error('Error fetching dogs:', err) - } + } catch (err) { console.error('Error fetching dogs:', err) } } const unlinkedDogs = allDogs.filter(d => { @@ -52,47 +292,31 @@ function LitterDetail() { const handleLinkPuppy = async () => { if (!selectedPuppyId) return - setSaving(true) - setError('') + setSaving(true); setError('') try { await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`) - setSelectedPuppyId('') - setShowAddPuppy(false) - fetchLitter() + setSelectedPuppyId(''); setShowAddPuppy(false); fetchLitter() } catch (err) { setError(err.response?.data?.error || 'Failed to link puppy') - } finally { - setSaving(false) - } + } finally { setSaving(false) } } const handleCreateAndLink = async () => { - if (!newPuppy.name) { - setError('Puppy name is required') - return - } - setSaving(true) - setError('') + if (!newPuppy.name) { setError('Puppy name is required'); return } + setSaving(true); setError('') try { const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date const res = await axios.post('/api/dogs', { - name: newPuppy.name, - sex: newPuppy.sex, - color: newPuppy.color, - date_of_birth: dob, + name: newPuppy.name, sex: newPuppy.sex, + color: newPuppy.color, date_of_birth: dob, breed: litter.dam_breed || '', }) - const createdDog = res.data - await axios.post(`/api/litters/${id}/puppies/${createdDog.id}`) + await axios.post(`/api/litters/${id}/puppies/${res.data.id}`) setNewPuppy({ name: '', sex: 'male', color: '', dob: '' }) - setShowAddPuppy(false) - fetchLitter() - fetchAllDogs() + setShowAddPuppy(false); fetchLitter(); fetchAllDogs() } catch (err) { setError(err.response?.data?.error || 'Failed to create puppy') - } finally { - setSaving(false) - } + } finally { setSaving(false) } } const handleUnlinkPuppy = async (puppyId) => { @@ -100,9 +324,7 @@ function LitterDetail() { try { await axios.delete(`/api/litters/${id}/puppies/${puppyId}`) fetchLitter() - } catch (err) { - console.error('Error unlinking puppy:', err) - } + } catch (err) { console.error('Error unlinking puppy:', err) } } if (loading) return
Loading litter...
@@ -120,13 +342,11 @@ function LitterDetail() {

🐾 {litter.sire_name} × {litter.dam_name}

- Bred: {new Date(litter.breeding_date).toLocaleDateString()} - {litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date).toLocaleDateString()}`} + Bred: {new Date(litter.breeding_date + 'T00:00:00').toLocaleDateString()} + {litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}`}

- + {/* Stats row */} @@ -155,6 +375,9 @@ function LitterDetail() { )} + {/* Projected whelping window */} + + {/* Notes */} {litter.notes && (
@@ -177,7 +400,7 @@ function LitterDetail() {

No puppies linked yet. Add puppies to this litter.

) : ( -
+
{litter.puppies.map(puppy => (
+ + {/* Weight / Health Log collapsible */} +
))}
@@ -223,37 +453,22 @@ function LitterDetail() {
{error &&
{error}
} - - {/* Mode toggle */}
- - + +
{addMode === 'existing' ? (
- setSelectedPuppyId(e.target.value)}> {unlinkedDogs.map(d => ( ))} @@ -267,46 +482,33 @@ function LitterDetail() {
- setNewPuppy(p => ({ ...p, name: e.target.value }))} - placeholder="e.g. Blue Collar" - /> + placeholder="e.g. Blue Collar" />
- setNewPuppy(p => ({ ...p, sex: e.target.value }))}>
- setNewPuppy(p => ({ ...p, color: e.target.value }))} - placeholder="e.g. Black & Tan" - /> + placeholder="e.g. Black & Tan" />
- setNewPuppy(p => ({ ...p, dob: e.target.value }))} - /> + setNewPuppy(p => ({ ...p, dob: e.target.value }))} /> {litter.whelping_date && !newPuppy.dob && (

- Will default to whelping date: {new Date(litter.whelping_date).toLocaleDateString()} + Will default to whelping date: {new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}

)}
@@ -314,9 +516,7 @@ function LitterDetail() { )}
- +