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())
// ─── Canine gestation constants (days from breeding date) ─────────────────────
const GESTATION_EARLIEST = 58
const GESTATION_EXPECTED = 63
const GESTATION_LATEST = 65
/** Returns { earliest, expected, latest } ISO date strings, or null if no breeding_date */
function getWhelpDates(cycle) {
if (!cycle?.breeding_date) return null
return {
earliest: addDays(cycle.breeding_date, GESTATION_EARLIEST),
expected: addDays(cycle.breeding_date, GESTATION_EXPECTED),
latest: addDays(cycle.breeding_date, GESTATION_LATEST),
}
}
// ─── 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' },
}
// Whelp window style (used in legend + calendar marker)
const WHELP_STYLE = {
bg: 'rgba(99,102,241,0.15)',
border: '#6366f1',
label: 'Projected Whelp',
dot: '#6366f1',
}
// ─── 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)
// Client-side projected whelp dates (immediate, before API suggestions load)
const projectedWhelp = getWhelpDates({ breeding_date: breedingDate })
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 */}
Log Breeding Date
{/* Live projected whelp preview — shown as soon as a breeding date is entered */}
{projectedWhelp && (
Projected Whelp:
{fmt(projectedWhelp.earliest)} – {fmt(projectedWhelp.latest)}
(expected {fmt(projectedWhelp.expected)})
)}
{/* Whelping estimate (from API suggestions) */}
{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.
{
onClose()
onRecordLitter(cycle)
}}
>
Record Litter
)}
{deleting ? 'Deleting…' : 'Delete Cycle'}
Close
)
}
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])
// ── Navigate to a specific year/month ──
function goToMonth(y, m) {
setYear(y)
setMonth(m)
}
// ── 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
})
}
/** Returns array of cycles whose projected whelp expected date is this dateStr */
function whelpingCyclesForDate(dateStr) {
return cycles.filter(c => {
const wd = getWhelpDates(c)
if (!wd) return false
return dateStr >= wd.earliest && dateStr <= wd.latest
})
}
/** Returns true if this dateStr is the exact expected whelp date for any cycle */
function isExpectedWhelpDate(dateStr) {
return cycles.some(c => {
const wd = getWhelpDates(c)
return wd?.expected === dateStr
})
}
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
})
// Cycles that have a whelp window overlapping current month view
const whelpingThisMonth = cycles.filter(c => {
const wd = getWhelpDates(c)
if (!wd) return false
const mStart = toISO(new Date(year, month, 1))
const mEnd = toISO(new Date(year, month + 1, 0))
return wd.earliest <= mEnd && wd.latest >= mStart
})
return (
{/* Header */}
Heat Cycle Calendar
Track heat cycles, optimal breeding windows, and projected whelping dates
setShowStartModal(true)}>
Start Heat Cycle
{/* Legend */}
{Object.entries(WINDOW_STYLES).map(([key, s]) => (
))}
{/* Whelp legend entry */}
{WHELP_STYLE.label}
{/* 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
// Whelp window cycles for this day
const whelpCycles = dateStr ? whelpingCyclesForDate(dateStr) : []
const isExpectedWhelp = dateStr ? isExpectedWhelpDate(dateStr) : false
const hasWhelpActivity = whelpCycles.length > 0
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
}
} else if (hasWhelpActivity) {
// Only color whelp window if not already in a heat window
cellBg = WHELP_STYLE.bg
cellBorder = WHELP_STYLE.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}
)
})}
{/* Projected whelp window indicator */}
{hasWhelpActivity && (
{whelpCycles.map((c, i) => (
{isExpectedWhelp && getWhelpDates(c)?.expected === dateStr
? `${c.dog_name} due`
: c.dog_name
}
))}
)}
{/* Breeding date marker dot */}
{dayCycles.some(c => c.breeding_date === dateStr) && (
)}
{/* Expected whelp date ring marker */}
{isExpectedWhelp && (
c.breeding_date === dateStr) ? 14 : 4,
width: 8, height: 8,
borderRadius: '50%',
background: WHELP_STYLE.dot,
border: '1.5px solid var(--bg-primary)'
}} title="Projected whelp date" />
)}
>
)}
)
})}
)}
{/* Active cycles list */}
Active Cycles This Month
{activeCycles.length}
{activeCycles.length === 0 ? (
No active heat cycles this month.
setShowStartModal(true)}>
Start First Cycle
) : (
{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)
const projWhelp = getWhelpDates(c)
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)}
)}
{/* Projected whelp date on card */}
{projWhelp && (
Whelp est. {fmt(projWhelp.expected)}
({fmt(projWhelp.earliest)}–{fmt(projWhelp.latest)})
{/* Jump-to-month button if whelp month differs from current view */}
{(() => {
const wd = new Date(projWhelp.expected + 'T00:00:00')
const wdY = wd.getFullYear()
const wdM = wd.getMonth()
if (wdY !== year || wdM !== month) {
return (
{ e.stopPropagation(); goToMonth(wdY, wdM) }}
>
View {MONTH_NAMES[wdM].slice(0,3)} {wdY}
)
}
return null
})()}
)}
)
})}
)}
{/* Whelping cycles banner — shown if any projected whelps fall this month but no active heat */}
{whelpingThisMonth.length > 0 && activeCycles.length === 0 && (
Projected Whelping This Month
{whelpingThisMonth.map(c => {
const wd = getWhelpDates(c)
return (
{c.dog_name} — expected {fmt(wd.expected)}
(range {fmt(wd.earliest)}–{fmt(wd.latest)})
)
})}
)}
{/* Modals */}
{showStartModal && (
setShowStartModal(false)}
onSaved={() => { setShowStartModal(false); load() }}
/>
)}
{selectedCycle && (
setSelectedCycle(null)}
onDeleted={() => { setSelectedCycle(null); load() }}
onRecordLitter={handleRecordLitter}
/>
)}
)
}