Merge pull request 'fix: wire external dogs end-to-end (modal, form flag, pairing simulator)' (#49) from fix/external-dogs-wiring into feature/external-dogs

Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
2026-03-11 01:01:18 -05:00
4 changed files with 277 additions and 258 deletions

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { X, Award } from 'lucide-react' import { X, Award, ExternalLink } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
function DogForm({ dog, onClose, onSave }) { function DogForm({ dog, onClose, onSave, isExternal = false }) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
registration_number: '', registration_number: '',
@@ -16,6 +16,7 @@ function DogForm({ dog, onClose, onSave }) {
dam_id: null, dam_id: null,
litter_id: null, litter_id: null,
is_champion: false, is_champion: false,
is_external: isExternal ? 1 : 0,
}) })
const [dogs, setDogs] = useState([]) const [dogs, setDogs] = useState([])
const [litters, setLitters] = useState([]) const [litters, setLitters] = useState([])
@@ -24,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) {
const [useManualParents, setUseManualParents] = useState(true) const [useManualParents, setUseManualParents] = useState(true)
const [littersAvailable, setLittersAvailable] = useState(false) 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(() => { useEffect(() => {
if (!effectiveExternal) {
fetchDogs() fetchDogs()
fetchLitters() fetchLitters()
}
if (dog) { if (dog) {
setFormData({ setFormData({
name: dog.name || '', name: dog.name || '',
@@ -41,6 +47,7 @@ function DogForm({ dog, onClose, onSave }) {
dam_id: dog.dam?.id || null, dam_id: dog.dam?.id || null,
litter_id: dog.litter_id || null, litter_id: dog.litter_id || null,
is_champion: !!dog.is_champion, is_champion: !!dog.is_champion,
is_external: dog.is_external ?? (isExternal ? 1 : 0),
}) })
setUseManualParents(!dog.litter_id) setUseManualParents(!dog.litter_id)
} }
@@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) {
const submitData = { const submitData = {
...formData, ...formData,
is_champion: formData.is_champion ? 1 : 0, is_champion: formData.is_champion ? 1 : 0,
sire_id: formData.sire_id || null, is_external: effectiveExternal ? 1 : 0,
dam_id: formData.dam_id || null, sire_id: effectiveExternal ? null : (formData.sire_id || null),
litter_id: useManualParents ? null : (formData.litter_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, registration_number: formData.registration_number || null,
birth_date: formData.birth_date || null, birth_date: formData.birth_date || null,
color: formData.color || null, color: formData.color || null,
@@ -133,10 +141,31 @@ function DogForm({ dog, onClose, onSave }) {
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2> <h2>
{effectiveExternal && <ExternalLink size={18} style={{ marginRight: '0.4rem', verticalAlign: 'middle', color: 'var(--text-muted)' }} />}
{dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'}
</h2>
<button className="btn-icon" onClick={onClose}><X size={24} /></button> <button className="btn-icon" onClick={onClose}><X size={24} /></button>
</div> </div>
{effectiveExternal && (
<div style={{
margin: '0 0 1rem',
padding: '0.6rem 1rem',
background: 'rgba(99,102,241,0.08)',
border: '1px solid rgba(99,102,241,0.25)',
borderRadius: 'var(--radius)',
fontSize: '0.875rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<ExternalLink size={14} />
External dog not part of your kennel roster. Litter and parent fields are not applicable.
</div>
)}
<form onSubmit={handleSubmit} className="modal-body"> <form onSubmit={handleSubmit} className="modal-body">
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
@@ -221,7 +250,8 @@ function DogForm({ dog, onClose, onSave }) {
</div> </div>
</div> </div>
{/* Parent Section */} {/* Parent Section — hidden for external dogs */}
{!effectiveExternal && (
<div style={{ <div style={{
marginTop: '1.5rem', padding: '1rem', marginTop: '1.5rem', padding: '1rem',
background: 'rgba(194, 134, 42, 0.04)', background: 'rgba(194, 134, 42, 0.04)',
@@ -284,6 +314,7 @@ function DogForm({ dog, onClose, onSave }) {
</div> </div>
)} )}
</div> </div>
)}
<div className="form-group" style={{ marginTop: '1rem' }}> <div className="form-group" style={{ marginTop: '1rem' }}>
<label className="label">Notes</label> <label className="label">Notes</label>
@@ -294,7 +325,7 @@ function DogForm({ dog, onClose, onSave }) {
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button> <button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={loading}> <button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'} {loading ? 'Saving...' : dog ? 'Update Dog' : effectiveExternal ? 'Add External Dog' : 'Add Dog'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,20 +1,24 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react'; import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react';
import DogForm from '../components/DogForm';
export default function ExternalDogs() { export default function ExternalDogs() {
const [dogs, setDogs] = useState([]); const [dogs, setDogs] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sexFilter, setSexFilter] = useState('all'); const [sexFilter, setSexFilter] = useState('all');
const navigate = useNavigate(); const [showAddModal, setShowAddModal] = useState(false);
useEffect(() => { useEffect(() => {
fetchDogs();
}, []);
const fetchDogs = () => {
fetch('/api/dogs/external') fetch('/api/dogs/external')
.then(r => r.json()) .then(r => r.json())
.then(data => { setDogs(data); setLoading(false); }) .then(data => { setDogs(data); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); };
const filtered = dogs.filter(d => { const filtered = dogs.filter(d => {
const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) || const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) ||
@@ -41,7 +45,7 @@ export default function ExternalDogs() {
</div> </div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => navigate('/dogs/new?external=1')} onClick={() => setShowAddModal(true)}
> >
<Plus size={16} /> Add External Dog <Plus size={16} /> Add External Dog
</button> </button>
@@ -75,7 +79,7 @@ export default function ExternalDogs() {
<ExternalLink size={48} className="empty-icon" /> <ExternalLink size={48} className="empty-icon" />
<h3>No external dogs yet</h3> <h3>No external dogs yet</h3>
<p>Add sires, dams, or ancestors that aren't part of your kennel roster.</p> <p>Add sires, dams, or ancestors that aren't part of your kennel roster.</p>
<button className="btn btn-primary" onClick={() => navigate('/dogs/new?external=1')}> <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={16} /> Add First External Dog <Plus size={16} /> Add First External Dog
</button> </button>
</div> </div>
@@ -85,7 +89,7 @@ export default function ExternalDogs() {
<section className="external-section"> <section className="external-section">
<h2 className="section-heading">&#9794; Sires ({sires.length})</h2> <h2 className="section-heading">&#9794; Sires ({sires.length})</h2>
<div className="dog-grid"> <div className="dog-grid">
{sires.map(dog => <DogCard key={dog.id} dog={dog} navigate={navigate} />)} {sires.map(dog => <DogCard key={dog.id} dog={dog} />)}
</div> </div>
</section> </section>
)} )}
@@ -93,25 +97,34 @@ export default function ExternalDogs() {
<section className="external-section"> <section className="external-section">
<h2 className="section-heading">&#9792; Dams ({dams.length})</h2> <h2 className="section-heading">&#9792; Dams ({dams.length})</h2>
<div className="dog-grid"> <div className="dog-grid">
{dams.map(dog => <DogCard key={dog.id} dog={dog} navigate={navigate} />)} {dams.map(dog => <DogCard key={dog.id} dog={dog} />)}
</div> </div>
</section> </section>
)} )}
</div> </div>
)} )}
{/* Add External Dog Modal */}
{showAddModal && (
<DogForm
isExternal={true}
onClose={() => setShowAddModal(false)}
onSave={() => { fetchDogs(); setShowAddModal(false); }}
/>
)}
</div> </div>
); );
} }
function DogCard({ dog, navigate }) { function DogCard({ dog }) {
const photo = dog.photo_urls?.[0]; const photo = dog.photo_urls?.[0];
return ( return (
<div <div
className="dog-card dog-card--external" className="dog-card dog-card--external"
onClick={() => navigate(`/dogs/${dog.id}`)} onClick={() => window.location.href = `/dogs/${dog.id}`}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={e => e.key === 'Enter' && navigate(`/dogs/${dog.id}`)} onKeyDown={e => e.key === 'Enter' && (window.location.href = `/dogs/${dog.id}`)}
> >
<div className="dog-card-photo"> <div className="dog-card-photo">
{photo {photo
@@ -129,7 +142,7 @@ function DogCard({ dog, navigate }) {
<div className="dog-card-meta">{dog.breed}</div> <div className="dog-card-meta">{dog.breed}</div>
<div className="dog-card-meta dog-card-meta--muted"> <div className="dog-card-meta dog-card-meta--muted">
{dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'} {dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'}
{dog.birth_date && <> &middot; {dog.birth_date}</>} {dog.birth_date && <>&middot; {dog.birth_date}</>}
</div> </div>
{(dog.sire || dog.dam) && ( {(dog.sire || dog.dam) && (
<div className="dog-card-parents"> <div className="dog-card-parents">

View File

@@ -13,7 +13,8 @@ export default function PairingSimulator() {
const [relationChecking, setRelationChecking] = useState(false) const [relationChecking, setRelationChecking] = useState(false)
useEffect(() => { 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(r => r.json())
.then(data => { .then(data => {
setDogs(Array.isArray(data) ? data : (data.dogs || [])) setDogs(Array.isArray(data) ? data : (data.dogs || []))
@@ -54,9 +55,6 @@ export default function PairingSimulator() {
checkRelation(sireId, val) checkRelation(sireId, val)
} }
const males = dogs.filter(d => d.sex === 'male')
const females = dogs.filter(d => d.sex === 'female')
async function handleSimulate(e) { async function handleSimulate(e) {
e.preventDefault() e.preventDefault()
if (!sireId || !damId) return if (!sireId || !damId) return
@@ -64,16 +62,14 @@ export default function PairingSimulator() {
setError(null) setError(null)
setResult(null) setResult(null)
try { try {
const res = await fetch('/api/pedigree/trial-pairing', { const res = await fetch('/api/pedigree/coi', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 data = await res.json()
const err = await res.json() if (!res.ok) throw new Error(data.error || 'Simulation failed')
throw new Error(err.error || 'Failed to calculate') setResult(data)
}
setResult(await res.json())
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -81,204 +77,164 @@ export default function PairingSimulator() {
} }
} }
function RiskBadge({ coi, recommendation }) { const males = dogs.filter(d => d.sex === 'male')
const isLow = coi < 5 const females = dogs.filter(d => d.sex === 'female')
const isMed = coi >= 5 && coi < 10
const isHigh = coi >= 10 const coiColor = (coi) => {
return ( if (coi < 0.0625) return 'var(--success)'
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}> if (coi < 0.125) return 'var(--warning)'
{isLow && <CheckCircle size={20} />} return 'var(--danger)'
{isMed && <AlertTriangle size={20} />} }
{isHigh && <XCircle size={20} />}
<span>{recommendation}</span> const coiLabel = (coi) => {
</div> if (coi < 0.0625) return 'Low'
) if (coi < 0.125) return 'Moderate'
if (coi < 0.25) return 'High'
return 'Very High'
} }
return ( return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}> <div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(139,92,246,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)' }}> <FlaskConical size={28} style={{ color: 'var(--primary)' }} />
<FlaskConical size={20} /> <h1 style={{ margin: 0 }}>Pairing Simulator</h1>
</div> </div>
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Trial Pairing Simulator</h1> <p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
</div> Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding.
<p style={{ color: 'var(--text-muted)', margin: 0 }}> Includes both kennel and external dogs.
Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors.
</p> </p>
</div>
{/* Selector Card */} <div className="card" style={{ marginBottom: '1.5rem' }}>
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
<form onSubmit={handleSimulate}> <form onSubmit={handleSimulate}>
<div className="form-grid" style={{ marginBottom: '1.25rem' }}> <div className="form-grid" style={{ marginBottom: '1rem' }}>
<div className="form-group" style={{ margin: 0 }}> <div className="form-group">
<label className="label">Sire (Male) </label> <label className="label">Sire (Male) *</label>
<select {dogsLoading ? (
value={sireId} <div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
onChange={handleSireChange} ) : (
required <select className="input" value={sireId} onChange={handleSireChange} required>
disabled={dogsLoading} <option value="">Select sire...</option>
>
<option value=""> Select Sire </option>
{males.map(d => ( {males.map(d => (
<option key={d.id} value={d.id}> <option key={d.id} value={d.id}>
{d.name}{d.breed ? ` · ${d.breed}` : ''} {d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option> </option>
))} ))}
</select> </select>
{!dogsLoading && males.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
)} )}
</div> </div>
<div className="form-group" style={{ margin: 0 }}> <div className="form-group">
<label className="label">Dam (Female) </label> <label className="label">Dam (Female) *</label>
<select {dogsLoading ? (
value={damId} <div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
onChange={handleDamChange} ) : (
required <select className="input" value={damId} onChange={handleDamChange} required>
disabled={dogsLoading} <option value="">Select dam...</option>
>
<option value=""> Select Dam </option>
{females.map(d => ( {females.map(d => (
<option key={d.id} value={d.id}> <option key={d.id} value={d.id}>
{d.name}{d.breed ? ` · ${d.breed}` : ''} {d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option> </option>
))} ))}
</select> </select>
{!dogsLoading && females.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
)} )}
</div> </div>
</div> </div>
{/* Direct-relation warning banner */}
{relationChecking && ( {relationChecking && (
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship</p> <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
)} Checking relationship...
{!relationChecking && relationWarning && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
}}>
<ShieldAlert size={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
<div>
<p style={{ margin: 0, fontWeight: 600, color: '#eab308', fontSize: '0.875rem' }}>Direct Relation Detected</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
{relationWarning}. COI will reflect the high inbreeding coefficient for this pairing.
</p>
</div> </div>
)}
{relationWarning && !relationChecking && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--danger)',
}}>
<ShieldAlert size={16} />
<strong>Related:</strong>&nbsp;{relationWarning}
</div> </div>
)} )}
<button <button
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary"
disabled={!sireId || !damId || loading || relationChecking} disabled={loading || dogsLoading || !sireId || !damId}
style={{ minWidth: '160px' }} style={{ width: '100%' }}
> >
{loading ? 'Calculating' : <><FlaskConical size={16} /> Simulate Pairing</>} {loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
</button> </button>
</form> </form>
</div> </div>
{/* Error */} {error && (
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</div>} <div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--danger)' }}>
<XCircle size={18} />
<strong>Error:</strong> {error}
</div>
</div>
)}
{/* Results */}
{result && ( {result && (
<div style={{ maxWidth: '720px' }}>
{/* Direct-relation alert in results */}
{result.directRelation && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
}}>
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
<div>
<p style={{ margin: 0, fontWeight: 600, color: 'var(--danger)', fontSize: '0.875rem' }}>Direct Relation High Inbreeding Risk</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{result.directRelation}</p>
</div>
</div>
)}
{/* COI Summary */}
<div className="card" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
</p>
</div>
<div style={{ textAlign: 'right' }}>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
<p style={{
fontSize: '2rem', fontWeight: 700, lineHeight: 1,
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
}}>
{result.coi.toFixed(2)}%
</p>
</div>
</div>
<div style={{ marginTop: '1.25rem' }}>
<RiskBadge coi={result.coi} recommendation={result.recommendation} />
</div>
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<strong>COI Guide:</strong> &lt;5% Low risk · 510% Moderate risk · &gt;10% High risk
</div>
</div>
{/* Common Ancestors */}
<div className="card"> <div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}> <h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<GitMerge size={18} style={{ color: 'var(--accent)' }} /> Simulation Result
<h3 style={{ margin: 0, fontSize: '1rem' }}>Common Ancestors</h3> </h2>
<span className="badge badge-primary" style={{ marginLeft: 'auto' }}>
{result.commonAncestors.length} found <div style={{
</span> display: 'flex', alignItems: 'center', gap: '1rem',
padding: '1.25rem', marginBottom: '1rem',
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
border: `2px solid ${coiColor(result.coi)}`,
}}>
{result.coi < 0.0625
? <CheckCircle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
: <AlertTriangle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
}
<div>
<div style={{ fontSize: '2rem', fontWeight: 700, color: coiColor(result.coi), lineHeight: 1 }}>
{(result.coi * 100).toFixed(2)}%
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
COI &mdash; <strong style={{ color: coiColor(result.coi) }}>{coiLabel(result.coi)}</strong>
</div>
</div>
</div> </div>
{result.commonAncestors.length === 0 ? ( {result.common_ancestors && result.common_ancestors.length > 0 && (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 0 }}> <div>
No common ancestors found within 6 generations. This pairing has excellent genetic diversity. <h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
</p> Common Ancestors ({result.common_ancestors.length})
) : ( </h3>
<div style={{ overflowX: 'auto' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}> {result.common_ancestors.map((a, i) => (
<thead> <span key={i} style={{
<tr style={{ borderBottom: '1px solid var(--border)' }}> padding: '0.2rem 0.6rem',
<th style={{ textAlign: 'left', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Ancestor</th> background: 'var(--bg-tertiary)',
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire Gen</th> borderRadius: 'var(--radius-sm)',
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam Gen</th> fontSize: '0.8rem',
</tr> border: '1px solid var(--border)',
</thead> }}>{a}</span>
<tbody>
{result.commonAncestors.map((anc, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '0.625rem 0.75rem', fontWeight: 500 }}>{anc.name}</td>
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
<span className="badge badge-primary">Gen {anc.sireGen}</span>
</td>
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
<span className="badge" style={{ background: 'rgba(244,114,182,0.15)', color: '#f472b6' }}>Gen {anc.damGen}</span>
</td>
</tr>
))} ))}
</tbody> </div>
</table>
</div> </div>
)} )}
{result.recommendation && (
<div style={{
marginTop: '1rem', padding: '0.75rem 1rem',
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
borderRadius: 'var(--radius)',
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
fontSize: '0.875rem',
color: 'var(--text-secondary)',
}}>
{result.recommendation}
</div> </div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -55,16 +55,33 @@ function attachParents(db, dogs) {
return 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) => { router.get('/', (req, res) => {
try { try {
const db = getDatabase(); 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(` const dogs = db.prepare(`
SELECT ${DOG_COLS} SELECT ${DOG_COLS}
FROM dogs FROM dogs
WHERE is_active = 1 AND is_external = 0 ${whereClause}
ORDER BY name ORDER BY name
`).all(); `).all();
res.json(attachParents(db, dogs)); res.json(attachParents(db, dogs));
} catch (error) { } catch (error) {
console.error('Error fetching dogs:', error); console.error('Error fetching dogs:', error);
@@ -73,6 +90,7 @@ router.get('/', (req, res) => {
}); });
// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ────────── // ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ──────────
// Kept for backwards-compat; equivalent to GET /?include_external=1
router.get('/all', (req, res) => { router.get('/all', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
@@ -90,6 +108,7 @@ router.get('/all', (req, res) => {
}); });
// ── GET external dogs only (is_external = 1) ────────────────────────────── // ── GET external dogs only (is_external = 1) ──────────────────────────────
// Kept for backwards-compat; equivalent to GET /?external_only=1
router.get('/external', (req, res) => { router.get('/external', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();