import { useEffect, useState, useCallback } from 'react'
import {
Heart, ChevronLeft, ChevronRight, Plus, X,
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2, Activity
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
// ─── 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())
// ─── 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 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 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)
}
}
return (
e.target === e.currentTarget && onClose()}>
)
}
// ─── Cycle Detail Modal ───────────────────────────────────────────────────────
function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
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
const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date)
return (
e.target === e.currentTarget && onClose()}>
{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 */}
{/* Whelping estimate */}
{whelp && (
Whelping Estimate
{[['Earliest', whelp.earliest], ['Expected', whelp.expected], ['Latest', whelp.latest]].map(([label, date]) => (
))}
)}
{/* Record Litter CTA — shown when breeding date is saved */}
{hasBreedingDate && (
🐾 Ready to record the litter?
Breeding date logged on {fmt(cycle.breeding_date)}. Create a litter record to track puppies.
)}
)
}
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 [pendingLitterCycle, setPendingLitterCycle] = useState(null)
const navigate = useNavigate()
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])
// When user clicks Record Litter from cycle detail, create litter and navigate
const handleRecordLitter = useCallback(async (cycle) => {
try {
// We need sire_id — navigate to litters page with pre-filled dam
// Store cycle info in sessionStorage so LitterList can pre-fill
sessionStorage.setItem('prefillLitter', JSON.stringify({
dam_id: cycle.dog_id,
dam_name: cycle.dog_name,
breeding_date: cycle.breeding_date,
whelping_date: cycle.whelping_date || ''
}))
navigate('/litters')
} catch (err) {
console.error(err)
}
}, [navigate])
// ── 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)
}
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) {
setSelectedCycle(dayCycles[0])
} else {
setShowStartModal(true)
}
}
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
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() }}
onRecordLitter={handleRecordLitter}
/>
)}
)
}