From d7bad192755df2f9572642515ce94ee6fcaa5bf6 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 20:30:42 -0500 Subject: [PATCH 1/2] feat: Add breeding date suggestion window endpoint --- server/routes/breeding.js | 157 ++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 39 deletions(-) diff --git a/server/routes/breeding.js b/server/routes/breeding.js index 0bec351..32efd36 100644 --- a/server/routes/breeding.js +++ b/server/routes/breeding.js @@ -11,55 +11,149 @@ router.get('/heat-cycles/dog/:dogId', (req, res) => { WHERE dog_id = ? ORDER BY start_date DESC `).all(req.params.dogId); - res.json(cycles); } catch (error) { res.status(500).json({ error: error.message }); } }); -// GET all active heat cycles +// GET all active heat cycles (with dog info) router.get('/heat-cycles/active', (req, res) => { try { const db = getDatabase(); const cycles = db.prepare(` - SELECT hc.*, d.name as dog_name, d.registration_number + SELECT hc.*, d.name as dog_name, d.registration_number, d.breed, d.birth_date FROM heat_cycles hc JOIN dogs d ON hc.dog_id = d.id WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days') ORDER BY hc.start_date DESC `).all(); - res.json(cycles); } catch (error) { res.status(500).json({ error: error.message }); } }); +// GET all heat cycles (all dogs, for calendar population) +router.get('/heat-cycles', (req, res) => { + try { + const db = getDatabase(); + const { year, month } = req.query; + let query = ` + SELECT hc.*, d.name as dog_name, d.registration_number, d.breed + FROM heat_cycles hc + JOIN dogs d ON hc.dog_id = d.id + `; + const params = []; + if (year && month) { + query += ` WHERE strftime('%Y', hc.start_date) = ? AND strftime('%m', hc.start_date) = ?`; + params.push(year, month.toString().padStart(2, '0')); + } + query += ' ORDER BY hc.start_date DESC'; + const cycles = db.prepare(query).all(...params); + res.json(cycles); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// GET breeding date suggestions for a heat cycle +// Returns optimal breeding window based on start_date (days 9-15 of cycle) +router.get('/heat-cycles/:id/suggestions', (req, res) => { + try { + const db = getDatabase(); + const cycle = db.prepare(` + SELECT hc.*, d.name as dog_name + FROM heat_cycles hc + JOIN dogs d ON hc.dog_id = d.id + WHERE hc.id = ? + `).get(req.params.id); + + if (!cycle) return res.status(404).json({ error: 'Heat cycle not found' }); + + const start = new Date(cycle.start_date); + + const addDays = (d, n) => { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r.toISOString().split('T')[0]; + }; + + // Standard canine heat cycle windows + res.json({ + cycle_id: cycle.id, + dog_name: cycle.dog_name, + start_date: cycle.start_date, + windows: [ + { + label: 'Proestrus', + description: 'Bleeding begins, not yet receptive', + start: addDays(start, 0), + end: addDays(start, 8), + color: 'pink', + type: 'proestrus' + }, + { + label: 'Optimal Breeding Window', + description: 'Estrus — highest fertility, best time to breed', + start: addDays(start, 9), + end: addDays(start, 15), + color: 'green', + type: 'optimal' + }, + { + label: 'Late Estrus', + description: 'Fertility declining but breeding still possible', + start: addDays(start, 16), + end: addDays(start, 21), + color: 'yellow', + type: 'late' + }, + { + label: 'Diestrus', + description: 'Cycle ending, not receptive', + start: addDays(start, 22), + end: addDays(start, 28), + color: 'gray', + type: 'diestrus' + } + ], + // If a breeding_date was logged, compute whelping estimate + whelping: cycle.breeding_date ? { + breeding_date: cycle.breeding_date, + earliest: addDays(new Date(cycle.breeding_date), 58), + expected: addDays(new Date(cycle.breeding_date), 63), + latest: addDays(new Date(cycle.breeding_date), 68) + } : null + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // POST create heat cycle router.post('/heat-cycles', (req, res) => { try { - const { dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body; - + const { dog_id, start_date, end_date, breeding_date, breeding_successful, notes } = req.body; + if (!dog_id || !start_date) { return res.status(400).json({ error: 'Dog ID and start date are required' }); } - + const db = getDatabase(); - + // Verify dog is female const dog = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dog_id); if (!dog || dog.sex !== 'female') { return res.status(400).json({ error: 'Dog must be female' }); } - + const result = db.prepare(` - INSERT INTO heat_cycles (dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful || 0, notes); - + INSERT INTO heat_cycles (dog_id, start_date, end_date, breeding_date, breeding_successful, notes) + VALUES (?, ?, ?, ?, ?, ?) + `).run(dog_id, start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null); + const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json(cycle); } catch (error) { res.status(500).json({ error: error.message }); @@ -69,16 +163,13 @@ router.post('/heat-cycles', (req, res) => { // PUT update heat cycle router.put('/heat-cycles/:id', (req, res) => { try { - const { start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body; - + const { start_date, end_date, breeding_date, breeding_successful, notes } = req.body; const db = getDatabase(); db.prepare(` UPDATE heat_cycles - SET start_date = ?, end_date = ?, progesterone_peak_date = ?, - breeding_date = ?, breeding_successful = ?, notes = ? + SET start_date = ?, end_date = ?, breeding_date = ?, breeding_successful = ?, notes = ? WHERE id = ? - `).run(start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes, req.params.id); - + `).run(start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null, req.params.id); const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(req.params.id); res.json(cycle); } catch (error) { @@ -97,32 +188,20 @@ router.delete('/heat-cycles/:id', (req, res) => { } }); -// GET calculate expected whelping date +// GET whelping calculator (standalone) router.get('/whelping-calculator', (req, res) => { try { const { breeding_date } = req.query; - if (!breeding_date) { return res.status(400).json({ error: 'Breeding date is required' }); } - const breedDate = new Date(breeding_date); - - // Average gestation: 63 days, range 58-68 days - const expectedDate = new Date(breedDate); - expectedDate.setDate(expectedDate.getDate() + 63); - - const earliestDate = new Date(breedDate); - earliestDate.setDate(earliestDate.getDate() + 58); - - const latestDate = new Date(breedDate); - latestDate.setDate(latestDate.getDate() + 68); - + const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r.toISOString().split('T')[0]; }; res.json({ - breeding_date: breeding_date, - expected_whelping_date: expectedDate.toISOString().split('T')[0], - earliest_date: earliestDate.toISOString().split('T')[0], - latest_date: latestDate.toISOString().split('T')[0], + breeding_date, + expected_whelping_date: addDays(breedDate, 63), + earliest_date: addDays(breedDate, 58), + latest_date: addDays(breedDate, 68), gestation_days: 63 }); } catch (error) { @@ -130,4 +209,4 @@ router.get('/whelping-calculator', (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; From 202c634df6d4a13e70d4ec3d09ec68fc735da723 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 20:32:21 -0500 Subject: [PATCH 2/2] feat: Full heat cycle calendar with month grid, start-cycle modal, and breeding date suggestions --- client/src/pages/BreedingCalendar.jsx | 566 +++++++++++++++++++++++--- 1 file changed, 514 insertions(+), 52 deletions(-) diff --git a/client/src/pages/BreedingCalendar.jsx b/client/src/pages/BreedingCalendar.jsx index 17c08bb..ccd33c0 100644 --- a/client/src/pages/BreedingCalendar.jsx +++ b/client/src/pages/BreedingCalendar.jsx @@ -1,67 +1,529 @@ -import { useEffect, useState } from 'react' -import { Heart } from 'lucide-react' -import axios from 'axios' +import { useEffect, useState, useCallback } from 'react' +import { + Heart, ChevronLeft, ChevronRight, Plus, X, + CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2 +} from 'lucide-react' -function BreedingCalendar() { - const [heatCycles, setHeatCycles] = useState([]) - const [loading, setLoading] = useState(true) +// ─── Date helpers ──────────────────────────────────────────────────────────── +const toISO = d => d.toISOString().split('T')[0] +const addDays = (dateStr, n) => { + const d = new Date(dateStr); d.setDate(d.getDate() + n); return toISO(d) +} +const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—' +const today = toISO(new Date()) - useEffect(() => { - fetchHeatCycles() - }, []) +// ─── Cycle window classifier ───────────────────────────────────────────────── +function getWindowForDate(cycle, dateStr) { + if (!cycle?.start_date) return null + const start = new Date(cycle.start_date + 'T00:00:00') + const check = new Date(dateStr + 'T00:00:00') + const day = Math.round((check - start) / 86400000) + if (day < 0 || day > 28) return null + if (day <= 8) return 'proestrus' + if (day <= 15) return 'optimal' + if (day <= 21) return 'late' + return 'diestrus' +} - const fetchHeatCycles = async () => { +const WINDOW_STYLES = { + proestrus: { bg: 'rgba(244,114,182,0.18)', border: '#f472b6', label: 'Proestrus', dot: '#f472b6' }, + optimal: { bg: 'rgba(16,185,129,0.22)', border: '#10b981', label: 'Optimal Breeding', dot: '#10b981' }, + late: { bg: 'rgba(245,158,11,0.18)', border: '#f59e0b', label: 'Late Estrus', dot: '#f59e0b' }, + diestrus: { bg: 'rgba(148,163,184,0.12)', border: '#64748b', label: 'Diestrus', dot: '#64748b' }, +} + +// ─── Start Heat Cycle Modal ─────────────────────────────────────────────────── +function StartCycleModal({ females, onClose, onSaved }) { + const [dogId, setDogId] = useState('') + const [startDate, setStartDate] = useState(today) + const [notes, setNotes] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e) { + e.preventDefault() + if (!dogId || !startDate) return + setSaving(true); setError(null) try { - const res = await axios.get('/api/breeding/heat-cycles/active') - setHeatCycles(res.data) - setLoading(false) - } catch (error) { - console.error('Error fetching heat cycles:', error) - setLoading(false) + const res = await fetch('/api/breeding/heat-cycles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dog_id: parseInt(dogId), start_date: startDate, notes: notes || null }) + }) + if (!res.ok) { const e = await res.json(); throw new Error(e.error || 'Failed to save') } + onSaved() + } catch (err) { + setError(err.message) + setSaving(false) } } - if (loading) { - return
Loading breeding calendar...
- } - return ( -
-

Breeding Calendar

- -
-

Active Heat Cycles

- {heatCycles.length === 0 ? ( -
- -

No active heat cycles

+
e.target === e.currentTarget && onClose()}> +
+
+
+ +

Start Heat Cycle

- ) : ( -
- {heatCycles.map(cycle => ( -
-

{cycle.dog_name}

-

- Started: {new Date(cycle.start_date).toLocaleDateString()} -

- {cycle.registration_number && ( -

- Reg: {cycle.registration_number} -

- )} -
- ))} + +
+
+
+ {error &&
{error}
} +
+ + + {females.length === 0 &&

No female dogs registered.

} +
+
+ + setStartDate(e.target.value)} required /> +
+
+ +