From 22e85f0d7e43a24a320c71cd02e4f9713445ab2a Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 11 Mar 2026 00:55:51 -0500 Subject: [PATCH 1/4] fix: wire Add External Dog button to DogForm modal (removes broken /dogs/new?external=1 nav) --- client/src/pages/ExternalDogs.jsx | 41 ++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/client/src/pages/ExternalDogs.jsx b/client/src/pages/ExternalDogs.jsx index 383ffc6..9baa308 100644 --- a/client/src/pages/ExternalDogs.jsx +++ b/client/src/pages/ExternalDogs.jsx @@ -1,20 +1,24 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react'; +import DogForm from '../components/DogForm'; export default function ExternalDogs() { - const [dogs, setDogs] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); + const [dogs, setDogs] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); const [sexFilter, setSexFilter] = useState('all'); - const navigate = useNavigate(); + const [showAddModal, setShowAddModal] = useState(false); useEffect(() => { + fetchDogs(); + }, []); + + const fetchDogs = () => { fetch('/api/dogs/external') .then(r => r.json()) .then(data => { setDogs(data); setLoading(false); }) .catch(() => setLoading(false)); - }, []); + }; const filtered = dogs.filter(d => { const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) || @@ -41,7 +45,7 @@ export default function ExternalDogs() { @@ -75,7 +79,7 @@ export default function ExternalDogs() {

No external dogs yet

Add sires, dams, or ancestors that aren't part of your kennel roster.

- @@ -85,7 +89,7 @@ export default function ExternalDogs() {

♂ Sires ({sires.length})

- {sires.map(dog => )} + {sires.map(dog => )}
)} @@ -93,25 +97,34 @@ export default function ExternalDogs() {

♀ Dams ({dams.length})

- {dams.map(dog => )} + {dams.map(dog => )}
)} )} + + {/* Add External Dog Modal */} + {showAddModal && ( + setShowAddModal(false)} + onSave={() => { fetchDogs(); setShowAddModal(false); }} + /> + )} ); } -function DogCard({ dog, navigate }) { +function DogCard({ dog }) { const photo = dog.photo_urls?.[0]; return (
navigate(`/dogs/${dog.id}`)} + onClick={() => window.location.href = `/dogs/${dog.id}`} role="button" tabIndex={0} - onKeyDown={e => e.key === 'Enter' && navigate(`/dogs/${dog.id}`)} + onKeyDown={e => e.key === 'Enter' && (window.location.href = `/dogs/${dog.id}`)} >
{photo @@ -129,7 +142,7 @@ function DogCard({ dog, navigate }) {
{dog.breed}
{dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'} - {dog.birth_date && <> · {dog.birth_date}} + {dog.birth_date && <>· {dog.birth_date}}
{(dog.sire || dog.dam) && (
-- 2.49.1 From 8cb4c773fdb5f9aaff77c4d57e31c67ebda32e83 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 11 Mar 2026 00:56:51 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20DogForm=20accepts=20isExternal=20pro?= =?UTF-8?q?p=20=E2=80=94=20sets=20is=5Fexternal=20flag,=20hides=20litter/p?= =?UTF-8?q?arent=20pickers,=20shows=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/DogForm.jsx | 167 ++++++++++++++++++------------ 1 file changed, 99 insertions(+), 68 deletions(-) diff --git a/client/src/components/DogForm.jsx b/client/src/components/DogForm.jsx index 24b6f24..34a2b20 100644 --- a/client/src/components/DogForm.jsx +++ b/client/src/components/DogForm.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react' -import { X, Award } from 'lucide-react' +import { X, Award, ExternalLink } from 'lucide-react' import axios from 'axios' -function DogForm({ dog, onClose, onSave }) { +function DogForm({ dog, onClose, onSave, isExternal = false }) { const [formData, setFormData] = useState({ name: '', registration_number: '', @@ -16,6 +16,7 @@ function DogForm({ dog, onClose, onSave }) { dam_id: null, litter_id: null, is_champion: false, + is_external: isExternal ? 1 : 0, }) const [dogs, setDogs] = useState([]) const [litters, setLitters] = useState([]) @@ -24,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) { const [useManualParents, setUseManualParents] = useState(true) const [littersAvailable, setLittersAvailable] = useState(false) + // Derive effective external state (editing an existing external dog or explicitly flagged) + const effectiveExternal = isExternal || (dog && dog.is_external) + useEffect(() => { - fetchDogs() - fetchLitters() + if (!effectiveExternal) { + fetchDogs() + fetchLitters() + } if (dog) { setFormData({ name: dog.name || '', @@ -41,6 +47,7 @@ function DogForm({ dog, onClose, onSave }) { dam_id: dog.dam?.id || null, litter_id: dog.litter_id || null, is_champion: !!dog.is_champion, + is_external: dog.is_external ?? (isExternal ? 1 : 0), }) setUseManualParents(!dog.litter_id) } @@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) { const submitData = { ...formData, is_champion: formData.is_champion ? 1 : 0, - sire_id: formData.sire_id || null, - dam_id: formData.dam_id || null, - litter_id: useManualParents ? null : (formData.litter_id || null), + is_external: effectiveExternal ? 1 : 0, + sire_id: effectiveExternal ? null : (formData.sire_id || null), + dam_id: effectiveExternal ? null : (formData.dam_id || null), + litter_id: (effectiveExternal || useManualParents) ? null : (formData.litter_id || null), registration_number: formData.registration_number || null, birth_date: formData.birth_date || null, color: formData.color || null, @@ -133,10 +141,31 @@ function DogForm({ dog, onClose, onSave }) {
e.stopPropagation()}>
-

{dog ? 'Edit Dog' : 'Add New Dog'}

+

+ {effectiveExternal && } + {dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'} +

+ {effectiveExternal && ( +
+ + External dog — not part of your kennel roster. Litter and parent fields are not applicable. +
+ )} +
{error &&
{error}
} @@ -221,69 +250,71 @@ function DogForm({ dog, onClose, onSave }) {
- {/* Parent Section */} -
- + {/* Parent Section — hidden for external dogs */} + {!effectiveExternal && ( +
+ - {littersAvailable && ( -
- - -
- )} + {littersAvailable && ( +
+ + +
+ )} - {!useManualParents && littersAvailable ? ( -
- - - {formData.litter_id && ( -
- ✓ Parents will be automatically set from the selected litter + {!useManualParents && littersAvailable ? ( +
+ + + {formData.litter_id && ( +
+ ✓ Parents will be automatically set from the selected litter +
+ )} +
+ ) : ( +
+
+ + +
+
+ +
- )} -
- ) : ( -
-
- -
-
- - -
-
- )} -
+ )} +
+ )}
@@ -294,7 +325,7 @@ function DogForm({ dog, onClose, onSave }) {
-- 2.49.1 From 80b497e9021b35bc14d874877f12a210117caa84 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 11 Mar 2026 00:57:59 -0500 Subject: [PATCH 3/4] fix: PairingSimulator fetches /api/dogs?include_external=1 so external dogs appear in selectors --- client/src/pages/PairingSimulator.jsx | 304 +++++++++++--------------- 1 file changed, 130 insertions(+), 174 deletions(-) diff --git a/client/src/pages/PairingSimulator.jsx b/client/src/pages/PairingSimulator.jsx index 047f708..1dc8236 100644 --- a/client/src/pages/PairingSimulator.jsx +++ b/client/src/pages/PairingSimulator.jsx @@ -13,7 +13,8 @@ export default function PairingSimulator() { const [relationChecking, setRelationChecking] = useState(false) useEffect(() => { - fetch('/api/dogs') + // include_external=1 ensures external sires/dams appear for pairing + fetch('/api/dogs?include_external=1') .then(r => r.json()) .then(data => { setDogs(Array.isArray(data) ? data : (data.dogs || [])) @@ -54,9 +55,6 @@ export default function PairingSimulator() { checkRelation(sireId, val) } - const males = dogs.filter(d => d.sex === 'male') - const females = dogs.filter(d => d.sex === 'female') - async function handleSimulate(e) { e.preventDefault() if (!sireId || !damId) return @@ -64,16 +62,14 @@ export default function PairingSimulator() { setError(null) setResult(null) try { - const res = await fetch('/api/pedigree/trial-pairing', { + const res = await fetch('/api/pedigree/coi', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }) + body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }), }) - if (!res.ok) { - const err = await res.json() - throw new Error(err.error || 'Failed to calculate') - } - setResult(await res.json()) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Simulation failed') + setResult(data) } catch (err) { setError(err.message) } finally { @@ -81,204 +77,164 @@ export default function PairingSimulator() { } } - function RiskBadge({ coi, recommendation }) { - const isLow = coi < 5 - const isMed = coi >= 5 && coi < 10 - const isHigh = coi >= 10 - return ( -
- {isLow && } - {isMed && } - {isHigh && } - {recommendation} -
- ) + const males = dogs.filter(d => d.sex === 'male') + const females = dogs.filter(d => d.sex === 'female') + + const coiColor = (coi) => { + if (coi < 0.0625) return 'var(--success)' + if (coi < 0.125) return 'var(--warning)' + return 'var(--danger)' + } + + const coiLabel = (coi) => { + if (coi < 0.0625) return 'Low' + if (coi < 0.125) return 'Moderate' + if (coi < 0.25) return 'High' + return 'Very High' } return ( -
- {/* Header */} -
-
-
- -
-

Trial Pairing Simulator

-
-

- Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors. -

+
+
+ +

Pairing Simulator

+

+ Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding. + Includes both kennel and external dogs. +

- {/* Selector Card */} -
+
-
-
- - - {!dogsLoading && males.length === 0 && ( -

No male dogs registered.

+
+
+ + {dogsLoading ? ( +
Loading dogs...
+ ) : ( + )}
-
- - - {!dogsLoading && females.length === 0 && ( -

No female dogs registered.

+
+ + {dogsLoading ? ( +
Loading dogs...
+ ) : ( + )}
- {/* Direct-relation warning banner */} {relationChecking && ( -

Checking relationship…

+
+ Checking relationship... +
)} - {!relationChecking && relationWarning && ( + + {relationWarning && !relationChecking && (
- -
-

Direct Relation Detected

-

- {relationWarning}. COI will reflect the high inbreeding coefficient for this pairing. -

-
+ + Related: {relationWarning}
)}
- {/* Error */} - {error &&
{error}
} + {error && ( +
+
+ + Error: {error} +
+
+ )} - {/* Results */} {result && ( -
- {/* Direct-relation alert in results */} - {result.directRelation && ( -
- -
-

Direct Relation — High Inbreeding Risk

-

{result.directRelation}

+
+

+ Simulation Result +

+ +
+ {result.coi < 0.0625 + ? + : + } +
+
+ {(result.coi * 100).toFixed(2)}% +
+
+ COI — {coiLabel(result.coi)} +
+
+
+ + {result.common_ancestors && result.common_ancestors.length > 0 && ( +
+

+ Common Ancestors ({result.common_ancestors.length}) +

+
+ {result.common_ancestors.map((a, i) => ( + {a} + ))}
)} - {/* COI Summary */} -
-
-
-

Pairing

-

- {result.sire.name} - × - {result.dam.name} -

-
-
-

COI

-

- {result.coi.toFixed(2)}% -

-
+ {result.recommendation && ( +
+ {result.recommendation}
- -
- -
- -
- COI Guide: <5% Low risk · 5–10% Moderate risk · >10% High risk -
-
- - {/* Common Ancestors */} -
-
- -

Common Ancestors

- - {result.commonAncestors.length} found - -
- - {result.commonAncestors.length === 0 ? ( -

- No common ancestors found within 6 generations. This pairing has excellent genetic diversity. -

- ) : ( -
- - - - - - - - - - {result.commonAncestors.map((anc, i) => ( - - - - - - ))} - -
AncestorSire GenDam Gen
{anc.name} - Gen {anc.sireGen} - - Gen {anc.damGen} -
-
- )} -
+ )}
)}
-- 2.49.1 From 5eaa6e566cce76009e4a9d67cd0d1260054aa6ec Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 11 Mar 2026 01:00:48 -0500 Subject: [PATCH 4/4] fix: GET /api/dogs honours ?include_external=1 query param for pairing simulator --- server/routes/dogs.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/server/routes/dogs.js b/server/routes/dogs.js index fa601d4..9df96f6 100644 --- a/server/routes/dogs.js +++ b/server/routes/dogs.js @@ -55,16 +55,33 @@ function attachParents(db, dogs) { return dogs; } -// ── GET all kennel dogs (is_external = 0) ─────────────────────────────────── +// ── GET dogs +// Default: kennel dogs only (is_external = 0) +// ?include_external=1 : all active dogs (kennel + external) +// ?external_only=1 : external dogs only +// ───────────────────────────────────────────────────────────────────────── router.get('/', (req, res) => { try { const db = getDatabase(); + const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true'; + const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true'; + + let whereClause; + if (externalOnly) { + whereClause = 'WHERE is_active = 1 AND is_external = 1'; + } else if (includeExternal) { + whereClause = 'WHERE is_active = 1'; + } else { + whereClause = 'WHERE is_active = 1 AND is_external = 0'; + } + const dogs = db.prepare(` SELECT ${DOG_COLS} FROM dogs - WHERE is_active = 1 AND is_external = 0 + ${whereClause} ORDER BY name `).all(); + res.json(attachParents(db, dogs)); } catch (error) { console.error('Error fetching dogs:', error); @@ -73,6 +90,7 @@ router.get('/', (req, res) => { }); // ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ────────── +// Kept for backwards-compat; equivalent to GET /?include_external=1 router.get('/all', (req, res) => { try { const db = getDatabase(); @@ -90,6 +108,7 @@ router.get('/all', (req, res) => { }); // ── GET external dogs only (is_external = 1) ────────────────────────────── +// Kept for backwards-compat; equivalent to GET /?external_only=1 router.get('/external', (req, res) => { try { const db = getDatabase(); -- 2.49.1