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}
-
- )}
-
- ))}
+
+
+
-
-
-
Whelping Calculator
-
Calculate expected whelping dates based on breeding dates
-
Feature coming soon...
+
+
+
+
+
)
}
-export default BreedingCalendar
\ No newline at end of file
+// ─── Cycle Detail Modal ───────────────────────────────────────────────────────
+function CycleDetailModal({ cycle, onClose, onDeleted }) {
+ const [suggestions, setSuggestions] = useState(null)
+ const [breedingDate, setBreedingDate] = useState(cycle.breeding_date || '')
+ const [savingBreed, setSavingBreed] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
+ .then(r => r.json())
+ .then(setSuggestions)
+ .catch(() => {})
+ }, [cycle.id])
+
+ async function saveBreedingDate() {
+ if (!breedingDate) return
+ setSavingBreed(true); setError(null)
+ try {
+ const res = await fetch(`/api/breeding/heat-cycles/${cycle.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...cycle, breeding_date: breedingDate })
+ })
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error) }
+ // Refresh suggestions
+ const s = await fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`).then(r => r.json())
+ setSuggestions(s)
+ } catch (err) { setError(err.message) }
+ finally { setSavingBreed(false) }
+ }
+
+ async function deleteCycle() {
+ if (!window.confirm(`Delete heat cycle for ${cycle.dog_name}? This cannot be undone.`)) return
+ setDeleting(true)
+ try {
+ await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
+ onDeleted()
+ } catch (err) { setError(err.message); setDeleting(false) }
+ }
+
+ const whelp = suggestions?.whelping
+
+ return (
+
e.target === e.currentTarget && onClose()}>
+
+
+
+
+
{cycle.dog_name}
+
+
+
+
+ {error &&
{error}
}
+
+ {/* Cycle meta */}
+
+
+ Started
+ {fmt(cycle.start_date)}
+
+ {cycle.breed && (
+
+ Breed
+ {cycle.breed}
+
+ )}
+
+
+ {/* Breeding date windows */}
+ {suggestions && (
+ <>
+
+ Breeding Date Windows
+
+
+ {suggestions.windows.map(w => (
+
+
+
+
+ {w.label}
+ {fmt(w.start)} – {fmt(w.end)}
+
+
{w.description}
+
+
+ ))}
+
+ >
+ )}
+
+ {/* Log breeding date */}
+
+
+ Log Breeding Date
+
+
+
+
+ setBreedingDate(e.target.value)} />
+
+
+
+
+
+ {/* Whelping estimate */}
+ {whelp && (
+
+
+ Whelping Estimate
+
+
+ {[['Earliest', whelp.earliest], ['Expected', whelp.expected], ['Latest', whelp.latest]].map(([label, date]) => (
+
+
{label}
+
{fmt(date)}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+const infoChip = {
+ display: 'flex', flexDirection: 'column', gap: '0.15rem',
+ padding: '0.5rem 0.875rem',
+ background: 'var(--bg-tertiary)',
+ borderRadius: 'var(--radius-sm)'
+}
+
+// ─── Main Calendar ────────────────────────────────────────────────────────────
+export default function BreedingCalendar() {
+ const now = new Date()
+ const [year, setYear] = useState(now.getFullYear())
+ const [month, setMonth] = useState(now.getMonth()) // 0-indexed
+ const [cycles, setCycles] = useState([])
+ const [females, setFemales] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showStartModal, setShowStartModal] = useState(false)
+ const [selectedCycle, setSelectedCycle] = useState(null)
+ const [selectedDay, setSelectedDay] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [cyclesRes, dogsRes] = await Promise.all([
+ fetch('/api/breeding/heat-cycles'),
+ fetch('/api/dogs')
+ ])
+ const allCycles = await cyclesRes.json()
+ const dogsData = await dogsRes.json()
+ const allDogs = Array.isArray(dogsData) ? dogsData : (dogsData.dogs || [])
+ setCycles(Array.isArray(allCycles) ? allCycles : [])
+ setFemales(allDogs.filter(d => d.sex === 'female'))
+ } catch (e) {
+ console.error(e)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ // ── Build calendar grid ──
+ const firstDay = new Date(year, month, 1)
+ const lastDay = new Date(year, month + 1, 0)
+ const startPad = firstDay.getDay() // 0=Sun
+ const totalCells = startPad + lastDay.getDate()
+ const rows = Math.ceil(totalCells / 7)
+
+ const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']
+ const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
+
+ function prevMonth() {
+ if (month === 0) { setMonth(11); setYear(y => y - 1) }
+ else setMonth(m => m - 1)
+ }
+ function nextMonth() {
+ if (month === 11) { setMonth(0); setYear(y => y + 1) }
+ else setMonth(m => m + 1)
+ }
+
+ // Find cycles that overlap a given date
+ function cyclesForDate(dateStr) {
+ return cycles.filter(c => {
+ const s = c.start_date
+ if (!s) return false
+ const end = c.end_date || addDays(s, 28)
+ return dateStr >= s && dateStr <= end
+ })
+ }
+
+ function handleDayClick(dateStr, dayCycles) {
+ setSelectedDay(dateStr)
+ if (dayCycles.length === 1) {
+ setSelectedCycle(dayCycles[0])
+ } else if (dayCycles.length > 1) {
+ // show first — could be upgraded to a picker
+ setSelectedCycle(dayCycles[0])
+ } else {
+ // Empty day click — open start modal with date pre-filled would be nice
+ // but we just open start modal; user picks date
+ setShowStartModal(true)
+ }
+ }
+
+ // Active cycles (in current month or ongoing)
+ const activeCycles = cycles.filter(c => {
+ const s = c.start_date; if (!s) return false
+ const end = c.end_date || addDays(s, 28)
+ const mStart = toISO(new Date(year, month, 1))
+ const mEnd = toISO(new Date(year, month + 1, 0))
+ return s <= mEnd && end >= mStart
+ })
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Heat Cycle Calendar
+
Track heat cycles and optimal breeding windows
+
+
+
+
+
+ {/* Legend */}
+
+ {Object.entries(WINDOW_STYLES).map(([key, s]) => (
+
+ ))}
+
+
+ {/* Month navigator */}
+
+
+
+
{MONTH_NAMES[month]} {year}
+
+
+
+ {/* Day headers */}
+
+ {DAY_NAMES.map(d => (
+
{d}
+ ))}
+
+
+ {/* Calendar cells */}
+ {loading ? (
+
Loading calendar…
+ ) : (
+
+ {Array.from({ length: rows * 7 }).map((_, idx) => {
+ const dayNum = idx - startPad + 1
+ const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
+ const dateStr = isValid ? toISO(new Date(year, month, dayNum)) : null
+ const dayCycles = dateStr ? cyclesForDate(dateStr) : []
+ const isToday = dateStr === today
+
+ // Pick dominant window color for background
+ let cellBg = 'transparent'
+ let cellBorder = 'var(--border)'
+ if (dayCycles.length > 0) {
+ const win = getWindowForDate(dayCycles[0], dateStr)
+ if (win && WINDOW_STYLES[win]) {
+ cellBg = WINDOW_STYLES[win].bg
+ cellBorder = WINDOW_STYLES[win].border
+ }
+ }
+
+ return (
+
isValid && handleDayClick(dateStr, dayCycles)}
+ style={{
+ minHeight: 72,
+ padding: '0.375rem 0.5rem',
+ borderRight: '1px solid var(--border)',
+ borderBottom: '1px solid var(--border)',
+ background: cellBg,
+ cursor: isValid ? 'pointer' : 'default',
+ position: 'relative',
+ transition: 'filter 0.15s',
+ opacity: isValid ? 1 : 0.3,
+ outline: isToday ? `2px solid var(--primary)` : 'none',
+ outlineOffset: -2,
+ }}
+ onMouseEnter={e => { if (isValid) e.currentTarget.style.filter = 'brightness(1.15)' }}
+ onMouseLeave={e => { e.currentTarget.style.filter = 'none' }}
+ >
+ {isValid && (
+ <>
+
{dayNum}
+ {dayCycles.map((c, i) => {
+ const win = getWindowForDate(c, dateStr)
+ const dot = win ? WINDOW_STYLES[win]?.dot : '#94a3b8'
+ return (
+
+ ♥ {c.dog_name}
+
+ )
+ })}
+ {/* Breeding date marker */}
+ {dayCycles.some(c => c.breeding_date === dateStr) && (
+
+ )}
+ >
+ )}
+
+ )
+ })}
+
+ )}
+
+
+ {/* Active cycles list */}
+
+
+
+ Active Cycles This Month
+ {activeCycles.length}
+
+ {activeCycles.length === 0 ? (
+
+
+
No active heat cycles this month.
+
+
+ ) : (
+
+ {activeCycles.map(c => {
+ const win = getWindowForDate(c, today)
+ const ws = win ? WINDOW_STYLES[win] : null
+ const daysSince = Math.round((new Date(today) - new Date(c.start_date + 'T00:00:00')) / 86400000)
+ return (
+
setSelectedCycle(c)}
+ >
+
+
+
{c.dog_name}
+ {c.breed &&
{c.breed}
}
+
+ {ws &&
{ws.label}}
+
+
+ Started {fmt(c.start_date)}
+ Day {daysSince + 1}
+
+ {c.breeding_date && (
+
+ Bred {fmt(c.breeding_date)}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+ {/* Modals */}
+ {showStartModal && (
+
setShowStartModal(false)}
+ onSaved={() => { setShowStartModal(false); load() }}
+ />
+ )}
+ {selectedCycle && (
+ setSelectedCycle(null)}
+ onDeleted={() => { setSelectedCycle(null); load() }}
+ />
+ )}
+
+ )
+}
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;