Merge pull request 'feat: Heat Cycle Calendar — month grid, start-cycle modal, breeding date suggestions, whelping estimate' (#23) from feat/heat-cycle-calendar into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
@@ -1,67 +1,529 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Heart } from 'lucide-react'
|
import {
|
||||||
import axios from 'axios'
|
Heart, ChevronLeft, ChevronRight, Plus, X,
|
||||||
|
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
function BreedingCalendar() {
|
// ─── Date helpers ────────────────────────────────────────────────────────────
|
||||||
const [heatCycles, setHeatCycles] = useState([])
|
const toISO = d => d.toISOString().split('T')[0]
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
// ─── Cycle window classifier ─────────────────────────────────────────────────
|
||||||
fetchHeatCycles()
|
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 {
|
try {
|
||||||
const res = await axios.get('/api/breeding/heat-cycles/active')
|
const res = await fetch('/api/breeding/heat-cycles', {
|
||||||
setHeatCycles(res.data)
|
method: 'POST',
|
||||||
setLoading(false)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} catch (error) {
|
body: JSON.stringify({ dog_id: parseInt(dogId), start_date: startDate, notes: notes || null })
|
||||||
console.error('Error fetching heat cycles:', error)
|
})
|
||||||
setLoading(false)
|
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 (
|
return (
|
||||||
<div className="container">
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<h1 style={{ marginBottom: '2rem' }}>Breeding Calendar</h1>
|
<div className="modal-content" style={{ maxWidth: '480px' }}>
|
||||||
|
<div className="modal-header">
|
||||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
<h2>Active Heat Cycles</h2>
|
<Heart size={18} style={{ color: '#f472b6' }} />
|
||||||
{heatCycles.length === 0 ? (
|
<h2>Start Heat Cycle</h2>
|
||||||
<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>
|
</div>
|
||||||
) : (
|
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||||
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
|
</div>
|
||||||
{heatCycles.map(cycle => (
|
<form onSubmit={handleSubmit}>
|
||||||
<div key={cycle.id} className="card" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="modal-body">
|
||||||
<h3>{cycle.dog_name}</h3>
|
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<div className="form-group">
|
||||||
Started: {new Date(cycle.start_date).toLocaleDateString()}
|
<label className="label">Female Dog *</label>
|
||||||
</p>
|
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
|
||||||
{cycle.registration_number && (
|
<option value="">— Select Female —</option>
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
{females.map(d => (
|
||||||
Reg: {cycle.registration_number}
|
<option key={d.id} value={d.id}>
|
||||||
</p>
|
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||||
)}
|
</option>
|
||||||
</div>
|
))}
|
||||||
))}
|
</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="modal-footer">
|
||||||
</div>
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || !dogId}>
|
||||||
<div className="card">
|
{saving ? 'Saving…' : <><Heart size={15} /> Start Cycle</>}
|
||||||
<h2>Whelping Calculator</h2>
|
</button>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>Calculate expected whelping dates based on breeding dates</p>
|
</div>
|
||||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem' }}>Feature coming soon...</p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,35 +11,130 @@ router.get('/heat-cycles/dog/:dogId', (req, res) => {
|
|||||||
WHERE dog_id = ?
|
WHERE dog_id = ?
|
||||||
ORDER BY start_date DESC
|
ORDER BY start_date DESC
|
||||||
`).all(req.params.dogId);
|
`).all(req.params.dogId);
|
||||||
|
|
||||||
res.json(cycles);
|
res.json(cycles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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) => {
|
router.get('/heat-cycles/active', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const cycles = db.prepare(`
|
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
|
FROM heat_cycles hc
|
||||||
JOIN dogs d ON hc.dog_id = d.id
|
JOIN dogs d ON hc.dog_id = d.id
|
||||||
WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days')
|
WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days')
|
||||||
ORDER BY hc.start_date DESC
|
ORDER BY hc.start_date DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
res.json(cycles);
|
res.json(cycles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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
|
// POST create heat cycle
|
||||||
router.post('/heat-cycles', (req, res) => {
|
router.post('/heat-cycles', (req, res) => {
|
||||||
try {
|
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) {
|
if (!dog_id || !start_date) {
|
||||||
return res.status(400).json({ error: 'Dog ID and start date are required' });
|
return res.status(400).json({ error: 'Dog ID and start date are required' });
|
||||||
@@ -54,12 +149,11 @@ router.post('/heat-cycles', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO heat_cycles (dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes)
|
INSERT INTO heat_cycles (dog_id, start_date, end_date, breeding_date, breeding_successful, notes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful || 0, notes);
|
`).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);
|
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json(cycle);
|
res.status(201).json(cycle);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -69,16 +163,13 @@ router.post('/heat-cycles', (req, res) => {
|
|||||||
// PUT update heat cycle
|
// PUT update heat cycle
|
||||||
router.put('/heat-cycles/:id', (req, res) => {
|
router.put('/heat-cycles/:id', (req, res) => {
|
||||||
try {
|
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();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE heat_cycles
|
UPDATE heat_cycles
|
||||||
SET start_date = ?, end_date = ?, progesterone_peak_date = ?,
|
SET start_date = ?, end_date = ?, breeding_date = ?, breeding_successful = ?, notes = ?
|
||||||
breeding_date = ?, breeding_successful = ?, notes = ?
|
|
||||||
WHERE id = ?
|
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);
|
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(req.params.id);
|
||||||
res.json(cycle);
|
res.json(cycle);
|
||||||
} catch (error) {
|
} 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) => {
|
router.get('/whelping-calculator', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { breeding_date } = req.query;
|
const { breeding_date } = req.query;
|
||||||
|
|
||||||
if (!breeding_date) {
|
if (!breeding_date) {
|
||||||
return res.status(400).json({ error: 'Breeding date is required' });
|
return res.status(400).json({ error: 'Breeding date is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const breedDate = new Date(breeding_date);
|
const breedDate = new Date(breeding_date);
|
||||||
|
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r.toISOString().split('T')[0]; };
|
||||||
// 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);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
breeding_date: breeding_date,
|
breeding_date,
|
||||||
expected_whelping_date: expectedDate.toISOString().split('T')[0],
|
expected_whelping_date: addDays(breedDate, 63),
|
||||||
earliest_date: earliestDate.toISOString().split('T')[0],
|
earliest_date: addDays(breedDate, 58),
|
||||||
latest_date: latestDate.toISOString().split('T')[0],
|
latest_date: addDays(breedDate, 68),
|
||||||
gestation_days: 63
|
gestation_days: 63
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user