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:
@@ -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 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
|
||||
@@ -34,6 +49,14 @@ const WINDOW_STYLES = {
|
||||
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('')
|
||||
@@ -150,6 +173,9 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
||||
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 (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal-content" style={{ maxWidth: '560px' }}>
|
||||
@@ -220,9 +246,30 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
||||
{savingBreed ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</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)}
|
||||
<span style={{ color: 'var(--text-muted)' }}>(expected {fmt(projectedWhelp.expected)})</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Whelping estimate */}
|
||||
{/* Whelping estimate (from API suggestions) */}
|
||||
{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' }}>
|
||||
<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 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)
|
||||
@@ -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) {
|
||||
setSelectedDay(dateStr)
|
||||
if (dayCycles.length === 1) {
|
||||
@@ -389,6 +459,15 @@ export default function BreedingCalendar() {
|
||||
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 (
|
||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||
{/* Header */}
|
||||
@@ -399,7 +478,7 @@ export default function BreedingCalendar() {
|
||||
</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>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Track heat cycles, optimal breeding windows, and projected whelping dates</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowStartModal(true)}>
|
||||
@@ -415,6 +494,11 @@ export default function BreedingCalendar() {
|
||||
{s.label}
|
||||
</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>
|
||||
|
||||
{/* Month navigator */}
|
||||
@@ -444,6 +528,11 @@ export default function BreedingCalendar() {
|
||||
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) {
|
||||
@@ -452,6 +541,10 @@ export default function BreedingCalendar() {
|
||||
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 (
|
||||
@@ -494,10 +587,46 @@ export default function BreedingCalendar() {
|
||||
</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) && (
|
||||
<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>
|
||||
@@ -528,6 +657,7 @@ export default function BreedingCalendar() {
|
||||
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 (
|
||||
<div
|
||||
key={c.id}
|
||||
@@ -551,6 +681,52 @@ export default function BreedingCalendar() {
|
||||
<CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
@@ -558,6 +734,34 @@ export default function BreedingCalendar() {
|
||||
)}
|
||||
</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 */}
|
||||
{showStartModal && (
|
||||
<StartCycleModal
|
||||
|
||||
Reference in New Issue
Block a user