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() }}
+ />
+ )}
+
+ )
+}