From 4ad3ffae4e8a3cc5d873b8490d380d440d60b04a Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 21:33:13 -0500 Subject: [PATCH] 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 --- client/src/pages/BreedingCalendar.jsx | 210 +++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 3 deletions(-) diff --git a/client/src/pages/BreedingCalendar.jsx b/client/src/pages/BreedingCalendar.jsx index e413033..1846a35 100644 --- a/client/src/pages/BreedingCalendar.jsx +++ b/client/src/pages/BreedingCalendar.jsx @@ -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 (
e.target === e.currentTarget && onClose()}>
@@ -220,9 +246,30 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) { {savingBreed ? 'Saving…' : 'Save'}
+ {/* Live projected whelp preview — shown as soon as a breeding date is entered */} + {projectedWhelp && ( +
+ + Projected Whelp: + + {fmt(projectedWhelp.earliest)} – {fmt(projectedWhelp.latest)} +  (expected {fmt(projectedWhelp.expected)}) + +
+ )}
- {/* Whelping estimate */} + {/* Whelping estimate (from API suggestions) */} {whelp && (

@@ -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 (
{/* Header */} @@ -399,7 +478,7 @@ export default function BreedingCalendar() {

Heat Cycle Calendar

-

Track heat cycles and optimal breeding windows

+

Track heat cycles, optimal breeding windows, and projected whelping dates

+ ) + } + return null + })()} + + )} ) })} @@ -558,6 +734,34 @@ export default function BreedingCalendar() { )} + {/* Whelping cycles banner — shown if any projected whelps fall this month but no active heat */} + {whelpingThisMonth.length > 0 && activeCycles.length === 0 && ( +
+ +
+
Projected Whelping This Month
+ {whelpingThisMonth.map(c => { + const wd = getWhelpDates(c) + return ( +
+ {c.dog_name} — expected {fmt(wd.expected)} + (range {fmt(wd.earliest)}–{fmt(wd.latest)}) +
+ ) + })} +
+
+ )} + {/* Modals */} {showStartModal && (