feat: Heat Cycle Calendar — month grid, start-cycle modal, breeding date suggestions, whelping estimate #23

Merged
jason merged 2 commits from feat/heat-cycle-calendar into master 2026-03-09 20:33:42 -05:00
2 changed files with 632 additions and 91 deletions

View File

@@ -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 <div className="container loading">Loading breeding calendar...</div>
}
return (
<div className="container">
<h1 style={{ marginBottom: '2rem' }}>Breeding Calendar</h1>
<div className="card" style={{ marginBottom: '2rem' }}>
<h2>Active Heat Cycles</h2>
{heatCycles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<Heart size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<p style={{ color: 'var(--text-secondary)' }}>No active heat cycles</p>
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-content" style={{ maxWidth: '480px' }}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Heart size={18} style={{ color: '#f472b6' }} />
<h2>Start Heat Cycle</h2>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
{heatCycles.map(cycle => (
<div key={cycle.id} className="card" style={{ background: 'var(--bg-secondary)' }}>
<h3>{cycle.dog_name}</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Started: {new Date(cycle.start_date).toLocaleDateString()}
</p>
{cycle.registration_number && (
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Reg: {cycle.registration_number}
</p>
)}
</div>
))}
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
<div className="form-group">
<label className="label">Female Dog *</label>
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
<option value=""> Select Female </option>
{females.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.breed ? ` · ${d.breed}` : ''}
</option>
))}
</select>
{females.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>}
</div>
<div className="form-group">
<label className="label">Heat Start Date *</label>
<input type="date" className="input" value={startDate} onChange={e => setStartDate(e.target.value)} required />
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="label">Notes</label>
<textarea className="input" value={notes} onChange={e => setNotes(e.target.value)} placeholder="Optional notes..." rows={3} />
</div>
</div>
)}
</div>
<div className="card">
<h2>Whelping Calculator</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>Calculate expected whelping dates based on breeding dates</p>
<p style={{ marginTop: '1rem', fontSize: '0.875rem' }}>Feature coming soon...</p>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving || !dogId}>
{saving ? 'Saving…' : <><Heart size={15} /> Start Cycle</>}
</button>
</div>
</form>
</div>
</div>
)
}
export default BreedingCalendar
// ─── 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 (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-content" style={{ maxWidth: '560px' }}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Heart size={18} style={{ color: '#f472b6' }} />
<h2>{cycle.dog_name}</h2>
</div>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<div className="modal-body">
{error && <div className="error">{error}</div>}
{/* Cycle meta */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div style={infoChip}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Started</span>
<span style={{ fontWeight: 600 }}>{fmt(cycle.start_date)}</span>
</div>
{cycle.breed && (
<div style={infoChip}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Breed</span>
<span style={{ fontWeight: 600 }}>{cycle.breed}</span>
</div>
)}
</div>
{/* Breeding date windows */}
{suggestions && (
<>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<FlaskConical size={16} style={{ color: 'var(--accent)' }} /> Breeding Date Windows
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
{suggestions.windows.map(w => (
<div key={w.type} style={{
display: 'flex', alignItems: 'flex-start', gap: '0.75rem',
padding: '0.625rem 0.875rem',
background: WINDOW_STYLES[w.type]?.bg,
border: `1px solid ${WINDOW_STYLES[w.type]?.border}`,
borderRadius: 'var(--radius-sm)'
}}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: WINDOW_STYLES[w.type]?.dot, marginTop: 4, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{w.label}</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{fmt(w.start)} {fmt(w.end)}</span>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', margin: '0.15rem 0 0' }}>{w.description}</p>
</div>
</div>
))}
</div>
</>
)}
{/* Log breeding date */}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1.25rem' }}>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<CalendarDays size={16} style={{ color: 'var(--primary)' }} /> Log Breeding Date
</h3>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<label className="label" style={{ marginBottom: '0.4rem' }}>Breeding Date</label>
<input type="date" className="input" value={breedingDate} onChange={e => setBreedingDate(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={saveBreedingDate} disabled={savingBreed || !breedingDate} style={{ marginBottom: 0 }}>
{savingBreed ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{/* Whelping estimate */}
{whelp && (
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem' }}>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}>
<Baby size={16} /> Whelping Estimate
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', textAlign: 'center' }}>
{[['Earliest', whelp.earliest], ['Expected', whelp.expected], ['Latest', whelp.latest]].map(([label, date]) => (
<div key={label}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }}>{label}</div>
<div style={{ fontWeight: 700, fontSize: '0.9375rem' }}>{fmt(date)}</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="modal-footer" style={{ justifyContent: 'space-between' }}>
<button className="btn btn-danger" onClick={deleteCycle} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete Cycle'}
</button>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
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 (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(244,114,182,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f472b6' }}>
<Heart size={20} />
</div>
<div>
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Heat Cycle Calendar</h1>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Track heat cycles and optimal breeding windows</p>
</div>
</div>
<button className="btn btn-primary" onClick={() => setShowStartModal(true)}>
<Plus size={16} /> Start Heat Cycle
</button>
</div>
{/* Legend */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
{Object.entries(WINDOW_STYLES).map(([key, s]) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: s.dot }} />
{s.label}
</div>
))}
</div>
{/* Month navigator */}
<div className="card" style={{ marginBottom: '1rem', padding: '0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1rem', borderBottom: '1px solid var(--border)' }}>
<button className="btn-icon" onClick={prevMonth}><ChevronLeft size={20} /></button>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>{MONTH_NAMES[month]} {year}</h2>
<button className="btn-icon" onClick={nextMonth}><ChevronRight size={20} /></button>
</div>
{/* Day headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
{DAY_NAMES.map(d => (
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
))}
</div>
{/* Calendar cells */}
{loading ? (
<div className="loading" style={{ minHeight: 280 }}>Loading calendar</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{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 (
<div
key={idx}
onClick={() => 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 && (
<>
<div style={{
fontSize: '0.8125rem', fontWeight: isToday ? 700 : 500,
color: isToday ? 'var(--primary)' : 'var(--text-primary)',
marginBottom: '0.25rem'
}}>{dayNum}</div>
{dayCycles.map((c, i) => {
const win = getWindowForDate(c, dateStr)
const dot = win ? WINDOW_STYLES[win]?.dot : '#94a3b8'
return (
<div key={i} style={{
fontSize: '0.7rem', color: dot, fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
lineHeight: 1.3
}}>
{c.dog_name}
</div>
)
})}
{/* Breeding date marker */}
{dayCycles.some(c => c.breeding_date === dateStr) && (
<div style={{ position: 'absolute', top: 4, right: 4, width: 8, height: 8, borderRadius: '50%', background: 'var(--success)', border: '1.5px solid var(--bg-primary)' }} title="Breeding date logged" />
)}
</>
)}
</div>
)
})}
</div>
)}
</div>
{/* Active cycles list */}
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle size={16} style={{ color: '#f472b6' }} />
Active Cycles This Month
<span className="badge badge-primary">{activeCycles.length}</span>
</h3>
{activeCycles.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
<Heart size={32} style={{ margin: '0 auto 0.75rem', opacity: 0.4 }} />
<p>No active heat cycles this month.</p>
<button className="btn btn-primary" style={{ marginTop: '1rem' }} onClick={() => setShowStartModal(true)}>
<Plus size={15} /> Start First Cycle
</button>
</div>
) : (
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}>
{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 (
<div
key={c.id}
className="card"
style={{ cursor: 'pointer', borderColor: ws?.border || 'var(--border)', background: ws?.bg || 'var(--bg-secondary)' }}
onClick={() => setSelectedCycle(c)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h4 style={{ margin: '0 0 0.2rem', fontSize: '1rem' }}>{c.dog_name}</h4>
{c.breed && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: 0 }}>{c.breed}</p>}
</div>
{ws && <span className="badge" style={{ background: ws.bg, color: ws.dot, border: `1px solid ${ws.border}`, flexShrink: 0 }}>{ws.label}</span>}
</div>
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<span>Started {fmt(c.start_date)}</span>
<span>Day {daysSince + 1}</span>
</div>
{c.breeding_date && (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: 'var(--success)' }}>
<CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Modals */}
{showStartModal && (
<StartCycleModal
females={females}
onClose={() => setShowStartModal(false)}
onSaved={() => { setShowStartModal(false); load() }}
/>
)}
{selectedCycle && (
<CycleDetailModal
cycle={selectedCycle}
onClose={() => setSelectedCycle(null)}
onDeleted={() => { setSelectedCycle(null); load() }}
/>
)}
</div>
)
}

View File

@@ -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;
module.exports = router;