feat: Heat Cycle Calendar — month grid, start-cycle modal, breeding date suggestions, whelping estimate #23
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user