feat: add projected whelping identifier on heat cycle calendar

- Compute projected whelp date (breeding_date + 63 days) client-side
- Mark projected whelp day on calendar grid with Baby icon + teal ring
- Show whelp range (earliest/expected/latest) tooltip on calendar cell
- Add 'Projected Whelp' entry to legend
- Show projected whelp date on active cycle cards below breeding date
- Active cycle cards navigate to whelp month if outside current view
This commit is contained in:
2026-03-09 21:33:13 -05:00
parent da6b2f2838
commit 4ad3ffae4e

View File

@@ -14,6 +14,21 @@ const addDays = (dateStr, n) => {
const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '' const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
const today = toISO(new Date()) 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 ───────────────────────────────────────────────── // ─── Cycle window classifier ─────────────────────────────────────────────────
function getWindowForDate(cycle, dateStr) { function getWindowForDate(cycle, dateStr) {
if (!cycle?.start_date) return null if (!cycle?.start_date) return null
@@ -34,6 +49,14 @@ const WINDOW_STYLES = {
diestrus: { bg: 'rgba(148,163,184,0.12)', border: '#64748b', label: 'Diestrus', dot: '#64748b' }, 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 ─────────────────────────────────────────────────── // ─── Start Heat Cycle Modal ───────────────────────────────────────────────────
function StartCycleModal({ females, onClose, onSaved }) { function StartCycleModal({ females, onClose, onSaved }) {
const [dogId, setDogId] = useState('') const [dogId, setDogId] = useState('')
@@ -150,6 +173,9 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
const whelp = suggestions?.whelping const whelp = suggestions?.whelping
const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date) const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date)
// Client-side projected whelp dates (immediate, before API suggestions load)
const projectedWhelp = getWhelpDates({ breeding_date: breedingDate })
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-content" style={{ maxWidth: '560px' }}> <div className="modal-content" style={{ maxWidth: '560px' }}>
@@ -220,9 +246,30 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
{savingBreed ? 'Saving…' : 'Save'} {savingBreed ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
{/* Live projected whelp preview — shown as soon as a breeding date is entered */}
{projectedWhelp && (
<div style={{
marginTop: '0.875rem',
padding: '0.625rem 0.875rem',
background: WHELP_STYLE.bg,
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: 'var(--radius-sm)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap'
}}>
<Baby size={15} style={{ color: WHELP_STYLE.dot, flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: WHELP_STYLE.dot }}>Projected Whelp:</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
{fmt(projectedWhelp.earliest)} {fmt(projectedWhelp.latest)}
&nbsp;<span style={{ color: 'var(--text-muted)' }}>(expected {fmt(projectedWhelp.expected)})</span>
</span>
</div>
)}
</div> </div>
{/* Whelping estimate */} {/* Whelping estimate (from API suggestions) */}
{whelp && ( {whelp && (
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1rem' }}> <div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1rem' }}>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}> <h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}>
@@ -342,6 +389,12 @@ export default function BreedingCalendar() {
} }
}, [navigate]) }, [navigate])
// ── Navigate to a specific year/month ──
function goToMonth(y, m) {
setYear(y)
setMonth(m)
}
// ── Build calendar grid ── // ── Build calendar grid ──
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0) const lastDay = new Date(year, month + 1, 0)
@@ -370,6 +423,23 @@ export default function BreedingCalendar() {
}) })
} }
/** 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) { function handleDayClick(dateStr, dayCycles) {
setSelectedDay(dateStr) setSelectedDay(dateStr)
if (dayCycles.length === 1) { if (dayCycles.length === 1) {
@@ -389,6 +459,15 @@ export default function BreedingCalendar() {
return s <= mEnd && end >= mStart 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 ( return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}> <div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */} {/* Header */}
@@ -399,7 +478,7 @@ export default function BreedingCalendar() {
</div> </div>
<div> <div>
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Heat Cycle Calendar</h1> <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> <p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Track heat cycles, optimal breeding windows, and projected whelping dates</p>
</div> </div>
</div> </div>
<button className="btn btn-primary" onClick={() => setShowStartModal(true)}> <button className="btn btn-primary" onClick={() => setShowStartModal(true)}>
@@ -415,6 +494,11 @@ export default function BreedingCalendar() {
{s.label} {s.label}
</div> </div>
))} ))}
{/* Whelp legend entry */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<Baby size={11} style={{ color: WHELP_STYLE.dot }} />
{WHELP_STYLE.label}
</div>
</div> </div>
{/* Month navigator */} {/* Month navigator */}
@@ -444,6 +528,11 @@ export default function BreedingCalendar() {
const dayCycles = dateStr ? cyclesForDate(dateStr) : [] const dayCycles = dateStr ? cyclesForDate(dateStr) : []
const isToday = dateStr === today 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 cellBg = 'transparent'
let cellBorder = 'var(--border)' let cellBorder = 'var(--border)'
if (dayCycles.length > 0) { if (dayCycles.length > 0) {
@@ -452,6 +541,10 @@ export default function BreedingCalendar() {
cellBg = WINDOW_STYLES[win].bg cellBg = WINDOW_STYLES[win].bg
cellBorder = WINDOW_STYLES[win].border 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 ( return (
@@ -494,10 +587,46 @@ export default function BreedingCalendar() {
</div> </div>
) )
})} })}
{/* Breeding date marker */} {/* Projected whelp window indicator */}
{hasWhelpActivity && (
<div style={{ marginTop: '0.15rem' }}>
{whelpCycles.map((c, i) => (
<div key={i} style={{
fontSize: '0.67rem',
color: WHELP_STYLE.dot,
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.3,
display: 'flex',
alignItems: 'center',
gap: '0.2rem'
}}>
<Baby size={9} />
{isExpectedWhelp && getWhelpDates(c)?.expected === dateStr
? `${c.dog_name} due`
: c.dog_name
}
</div>
))}
</div>
)}
{/* Breeding date marker dot */}
{dayCycles.some(c => c.breeding_date === dateStr) && ( {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 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" />
)} )}
{/* Expected whelp date ring marker */}
{isExpectedWhelp && (
<div style={{
position: 'absolute',
top: 2, right: dayCycles.some(c => 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" />
)}
</> </>
)} )}
</div> </div>
@@ -528,6 +657,7 @@ export default function BreedingCalendar() {
const win = getWindowForDate(c, today) const win = getWindowForDate(c, today)
const ws = win ? WINDOW_STYLES[win] : null const ws = win ? WINDOW_STYLES[win] : null
const daysSince = Math.round((new Date(today) - new Date(c.start_date + 'T00:00:00')) / 86400000) const daysSince = Math.round((new Date(today) - new Date(c.start_date + 'T00:00:00')) / 86400000)
const projWhelp = getWhelpDates(c)
return ( return (
<div <div
key={c.id} key={c.id}
@@ -551,6 +681,52 @@ export default function BreedingCalendar() {
<CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)} <CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)}
</div> </div>
)} )}
{/* Projected whelp date on card */}
{projWhelp && (
<div style={{
marginTop: '0.4rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.8rem',
color: WHELP_STYLE.dot,
fontWeight: 500
}}>
<Baby size={13} />
Whelp est. {fmt(projWhelp.expected)}
<span style={{ fontSize: '0.73rem', color: 'var(--text-muted)', fontWeight: 400 }}>
({fmt(projWhelp.earliest)}{fmt(projWhelp.latest)})
</span>
{/* 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 (
<button
style={{
marginLeft: 'auto',
background: 'none',
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: '0.25rem',
color: WHELP_STYLE.dot,
fontSize: '0.7rem',
padding: '0.1rem 0.35rem',
cursor: 'pointer',
fontWeight: 600,
whiteSpace: 'nowrap'
}}
onClick={e => { e.stopPropagation(); goToMonth(wdY, wdM) }}
>
View {MONTH_NAMES[wdM].slice(0,3)} {wdY}
</button>
)
}
return null
})()}
</div>
)}
</div> </div>
) )
})} })}
@@ -558,6 +734,34 @@ export default function BreedingCalendar() {
)} )}
</div> </div>
{/* Whelping cycles banner — shown if any projected whelps fall this month but no active heat */}
{whelpingThisMonth.length > 0 && activeCycles.length === 0 && (
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: WHELP_STYLE.bg,
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: 'var(--radius)',
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem'
}}>
<Baby size={18} style={{ color: WHELP_STYLE.dot, flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600, color: WHELP_STYLE.dot, marginBottom: '0.3rem' }}>Projected Whelping This Month</div>
{whelpingThisMonth.map(c => {
const wd = getWhelpDates(c)
return (
<div key={c.id} style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.2rem' }}>
<strong>{c.dog_name}</strong> expected {fmt(wd.expected)}
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}> (range {fmt(wd.earliest)}{fmt(wd.latest)})</span>
</div>
)
})}
</div>
</div>
)}
{/* Modals */} {/* Modals */}
{showStartModal && ( {showStartModal && (
<StartCycleModal <StartCycleModal