diff --git a/client/src/components/GeneticPanelCard.jsx b/client/src/components/GeneticPanelCard.jsx
new file mode 100644
index 0000000..4d93d61
--- /dev/null
+++ b/client/src/components/GeneticPanelCard.jsx
@@ -0,0 +1,97 @@
+import { useState, useEffect } from 'react'
+import { Dna, Plus } from 'lucide-react'
+import axios from 'axios'
+import GeneticTestForm from './GeneticTestForm'
+
+const RESULT_STYLES = {
+ clear: { bg: 'rgba(52,199,89,0.15)', color: 'var(--success)' },
+ carrier: { bg: 'rgba(255,159,10,0.15)', color: 'var(--warning)' },
+ affected: { bg: 'rgba(255,59,48,0.15)', color: 'var(--danger)' },
+ not_tested: { bg: 'var(--bg-tertiary)', color: 'var(--text-muted)' }
+}
+
+export default function GeneticPanelCard({ dogId }) {
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [showForm, setShowForm] = useState(false)
+ const [editingRecord, setEditingRecord] = useState(null)
+
+ const fetchGenetics = () => {
+ setLoading(true)
+ axios.get(`/api/genetics/dog/${dogId}`)
+ .then(res => setData(res.data))
+ .catch(() => setError(true))
+ .finally(() => setLoading(false))
+ }
+
+ useEffect(() => { fetchGenetics() }, [dogId])
+
+ const openAdd = () => { setEditingRecord(null); setShowForm(true) }
+ const openEdit = (rec) => { setEditingRecord(rec); setShowForm(true) }
+ const handleSaved = () => { setShowForm(false); fetchGenetics() }
+
+ if (error || (!loading && !data)) return null
+
+ const panel = data?.panel || []
+
+ return (
+
+
+
+ DNA Genetics Panel
+
+
+ Update Marker
+
+
+
+ {loading ? (
+
Loading...
+ ) : (
+
+ {panel.map(item => {
+ const style = RESULT_STYLES[item.result] || RESULT_STYLES.not_tested
+ // Pass the whole test record if it exists so we can edit it
+ const record = item.id ? item : { marker: item.marker, result: 'not_tested' }
+
+ return (
+
openEdit(record)}
+ style={{
+ padding: '0.5rem 0.75rem',
+ background: style.bg,
+ border: `1px solid ${style.color}44`,
+ borderRadius: 'var(--radius-sm)',
+ cursor: 'pointer',
+ transition: 'transform 0.1s ease',
+ }}
+ onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-2px)'}
+ onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}
+ >
+
+ {item.marker}
+
+
+ {item.result.replace('_', ' ')}
+
+
+ )
+ })}
+
+ )}
+
+ {showForm && (
+
setShowForm(false)}
+ onSave={handleSaved}
+ />
+ )}
+
+ )
+}
diff --git a/client/src/components/GeneticTestForm.jsx b/client/src/components/GeneticTestForm.jsx
new file mode 100644
index 0000000..b99a4fc
--- /dev/null
+++ b/client/src/components/GeneticTestForm.jsx
@@ -0,0 +1,157 @@
+import { useState } from 'react'
+import { X } from 'lucide-react'
+import axios from 'axios'
+
+const GR_MARKERS = [
+ { value: 'PRA1', label: 'PRA1' },
+ { value: 'PRA2', label: 'PRA2' },
+ { value: 'prcd-PRA', label: 'prcd-PRA' },
+ { value: 'GR-PRA1', label: 'GR-PRA1' },
+ { value: 'GR-PRA2', label: 'GR-PRA2' },
+ { value: 'ICH1', label: 'ICH1 (Ichthyosis 1)' },
+ { value: 'ICH2', label: 'ICH2 (Ichthyosis 2)' },
+ { value: 'NCL', label: 'Neuronal Ceroid Lipofuscinosis' },
+ { value: 'DM', label: 'Degenerative Myelopathy' },
+ { value: 'MD', label: 'Muscular Dystrophy' }
+]
+
+const RESULTS = [
+ { value: 'clear', label: 'Clear / Normal' },
+ { value: 'carrier', label: 'Carrier (1 copy)' },
+ { value: 'affected', label: 'Affected / At Risk (2 copies)' },
+ { value: 'not_tested', label: 'Not Tested' }
+]
+
+const EMPTY = {
+ test_provider: 'Embark',
+ marker: 'PRA1',
+ result: 'clear',
+ test_date: '',
+ document_url: '',
+ notes: ''
+}
+
+export default function GeneticTestForm({ dogId, record, onClose, onSave }) {
+ const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setSaving(true)
+ setError(null)
+
+ // If not tested, don't save
+ if (form.result === 'not_tested' && !record) {
+ setError('Cannot save a "Not Tested" result. Please just delete the record if it exists.')
+ setSaving(false)
+ return
+ }
+
+ try {
+ if (record && record.id) {
+ if (form.result === 'not_tested') {
+ // If changed to not_tested, just delete it
+ await axios.delete(`/api/genetics/${record.id}`)
+ } else {
+ await axios.put(`/api/genetics/${record.id}`, form)
+ }
+ } else {
+ await axios.post('/api/genetics', { ...form, dog_id: dogId })
+ }
+ onSave()
+ } catch (err) {
+ setError(err.response?.data?.error || 'Failed to save genetic record')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const labelStyle = {
+ fontSize: '0.8rem', color: 'var(--text-muted)',
+ marginBottom: '0.25rem', display: 'block',
+ }
+ const inputStyle = {
+ width: '100%', background: 'var(--bg-primary)',
+ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
+ padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
+ boxSizing: 'border-box',
+ }
+ const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
+
+ return (
+
+
+
+
{record && record.id ? 'Edit' : 'Add'} Genetic Result
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/PedigreeView.jsx b/client/src/components/PedigreeView.jsx
index bba0b0b..49e7634 100644
--- a/client/src/components/PedigreeView.jsx
+++ b/client/src/components/PedigreeView.jsx
@@ -17,19 +17,24 @@ function PedigreeView({ dogId, onClose }) {
}, [dogId])
useEffect(() => {
+ const container = document.querySelector('.pedigree-container')
+ if (!container) return
+
const updateDimensions = () => {
- const container = document.querySelector('.pedigree-container')
- if (container) {
- const width = container.offsetWidth
- const height = container.offsetHeight
- setDimensions({ width, height })
- setTranslate({ x: width / 4, y: height / 2 })
- }
+ const width = container.offsetWidth
+ const height = container.offsetHeight
+ setDimensions({ width, height })
+ setTranslate({ x: width / 4, y: height / 2 })
}
updateDimensions()
- window.addEventListener('resize', updateDimensions)
- return () => window.removeEventListener('resize', updateDimensions)
+
+ const resizeObserver = new ResizeObserver(() => {
+ updateDimensions()
+ })
+ resizeObserver.observe(container)
+
+ return () => resizeObserver.disconnect()
}, [])
const fetchPedigree = async () => {
diff --git a/client/src/pages/DogDetail.jsx b/client/src/pages/DogDetail.jsx
index 838c0b4..558456c 100644
--- a/client/src/pages/DogDetail.jsx
+++ b/client/src/pages/DogDetail.jsx
@@ -6,6 +6,8 @@ import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
import HealthRecordForm from '../components/HealthRecordForm'
+import GeneticPanelCard from '../components/GeneticPanelCard'
+import { ShieldCheck } from 'lucide-react'
function DogDetail() {
const { id } = useParams()
@@ -262,6 +264,18 @@ function DogDetail() {
{dog.registration_number}
)}
+ {dog.chic_number && (
+
+ CHIC Status
+
+ CHIC #{dog.chic_number}
+
+
+ )}
{dog.microchip && (
Microchip
@@ -317,6 +331,9 @@ function DogDetail() {
{/* OFA Clearance Summary */}
+ {/* DNA Genetics Panel */}
+
+
{/* Health Records List */}
{healthRecords.length > 0 && (
diff --git a/client/src/pages/DogList.jsx b/client/src/pages/DogList.jsx
index ba5f79c..d1f67a4 100644
--- a/client/src/pages/DogList.jsx
+++ b/client/src/pages/DogList.jsx
@@ -259,6 +259,19 @@ function DogList() {
{dog.registration_number}
)}
+ {dog.chic_number && (
+
+ CHIC #{dog.chic_number}
+
+ )}
{/* Actions */}
diff --git a/client/src/pages/PairingSimulator.jsx b/client/src/pages/PairingSimulator.jsx
index 9eb4f2d..c214863 100644
--- a/client/src/pages/PairingSimulator.jsx
+++ b/client/src/pages/PairingSimulator.jsx
@@ -11,6 +11,8 @@ export default function PairingSimulator() {
const [dogsLoading, setDogsLoading] = useState(true)
const [relationWarning, setRelationWarning] = useState(null)
const [relationChecking, setRelationChecking] = useState(false)
+ const [geneticRisk, setGeneticRisk] = useState(null)
+ const [geneticChecking, setGeneticChecking] = useState(false)
useEffect(() => {
// include_external=1 ensures external sires/dams appear for pairing
@@ -27,17 +29,28 @@ export default function PairingSimulator() {
const checkRelation = useCallback(async (sid, did) => {
if (!sid || !did) {
setRelationWarning(null)
+ setGeneticRisk(null)
return
}
setRelationChecking(true)
+ setGeneticChecking(true)
try {
- const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
- const data = await res.json()
- setRelationWarning(data.related ? data.relationship : null)
+ const [relRes, genRes] = await Promise.all([
+ fetch(`/api/pedigree/relations/${sid}/${did}`),
+ fetch(`/api/genetics/pairing-risk?sireId=${sid}&damId=${did}`)
+ ])
+
+ const relData = await relRes.json()
+ setRelationWarning(relData.related ? relData.relationship : null)
+
+ const genData = await genRes.json()
+ setGeneticRisk(genData)
} catch {
setRelationWarning(null)
+ setGeneticRisk(null)
} finally {
setRelationChecking(false)
+ setGeneticChecking(false)
}
}, [])
@@ -142,7 +155,7 @@ export default function PairingSimulator() {
{relationChecking && (
- Checking relationship...
+ Checking relationship and genetics...
)}
@@ -158,6 +171,31 @@ export default function PairingSimulator() {
)}
+ {geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
+
+
+ Genetic Risks Detected
+
+
+ {geneticRisk.risks.map(r => (
+
+ {r.marker} : {r.message}
+
+ ))}
+
+
+ )}
+
+ {geneticRisk && geneticRisk.missing_data && !geneticChecking && (
+
+ * Sire or dam has missing genetic tests. Clearances cannot be fully verified.
+
+ )}
+
{
}
});
-// GET single health record
-router.get('/:id', (req, res) => {
- try {
- const db = getDatabase();
- const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
- if (!record) return res.status(404).json({ error: 'Health record not found' });
- res.json(record);
- } catch (error) {
- res.status(500).json({ error: error.message });
- }
-});
// POST create health record
router.post('/', (req, res) => {
@@ -128,6 +118,10 @@ router.post('/', (req, res) => {
return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' });
}
+ if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
+ return res.status(400).json({ error: 'Invalid test_type' });
+ }
+
const db = getDatabase();
const dbResult = db.prepare(`
INSERT INTO health_records
@@ -157,6 +151,10 @@ router.put('/:id', (req, res) => {
document_url, result, vet_name, next_due, notes
} = req.body;
+ if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
+ return res.status(400).json({ error: 'Invalid test_type' });
+ }
+
const db = getDatabase();
db.prepare(`
UPDATE health_records
@@ -190,4 +188,70 @@ router.delete('/:id', (req, res) => {
}
});
+// GET cancer history for a dog
+router.get('/dog/:dogId/cancer-history', (req, res) => {
+ try {
+ const db = getDatabase();
+ const records = db.prepare(`
+ SELECT * FROM cancer_history
+ WHERE dog_id = ?
+ ORDER BY age_at_diagnosis ASC, created_at DESC
+ `).all(req.params.dogId);
+ res.json(records);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// POST create cancer history record
+router.post('/cancer-history', (req, res) => {
+ try {
+ const {
+ dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes
+ } = req.body;
+
+ if (!dog_id || !cancer_type) {
+ return res.status(400).json({ error: 'dog_id and cancer_type are required' });
+ }
+
+ const db = getDatabase();
+
+ // Update dog's age_at_death and cause_of_death if provided
+ if (age_at_death || cause_of_death) {
+ db.prepare(`
+ UPDATE dogs SET
+ age_at_death = COALESCE(?, age_at_death),
+ cause_of_death = COALESCE(?, cause_of_death)
+ WHERE id = ?
+ `).run(age_at_death || null, cause_of_death || null, dog_id);
+ }
+
+ const dbResult = db.prepare(`
+ INSERT INTO cancer_history
+ (dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ dog_id, cancer_type, age_at_diagnosis || null,
+ age_at_death || null, cause_of_death || null, notes || null
+ );
+
+ const record = db.prepare('SELECT * FROM cancer_history WHERE id = ?').get(dbResult.lastInsertRowid);
+ res.status(201).json(record);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// GET single health record (wildcard should go last to prevent overlap)
+router.get('/:id', (req, res) => {
+ try {
+ const db = getDatabase();
+ const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
+ if (!record) return res.status(404).json({ error: 'Health record not found' });
+ res.json(record);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
module.exports = router;
diff --git a/server/routes/pedigree.js b/server/routes/pedigree.js
index 3cb3861..b74e17b 100644
--- a/server/routes/pedigree.js
+++ b/server/routes/pedigree.js
@@ -2,6 +2,25 @@ const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
+const MAX_CACHE_SIZE = 1000;
+const ancestorCache = new Map();
+const coiCache = new Map();
+
+function getFromCache(cache, key, computeFn) {
+ if (cache.has(key)) {
+ const val = cache.get(key);
+ cache.delete(key);
+ cache.set(key, val);
+ return val;
+ }
+ const val = computeFn();
+ if (cache.size >= MAX_CACHE_SIZE) {
+ cache.delete(cache.keys().next().value);
+ }
+ cache.set(key, val);
+ return val;
+}
+
/**
* getAncestorMap(db, dogId, maxGen)
* Returns Map
@@ -9,24 +28,27 @@ const { getDatabase } = require('../db/init');
* pairings are correctly detected by calculateCOI.
*/
function getAncestorMap(db, dogId, maxGen = 6) {
- const map = new Map();
+ const cacheKey = `${dogId}-${maxGen}`;
+ return getFromCache(ancestorCache, cacheKey, () => {
+ const map = new Map();
- function recurse(id, gen) {
- if (gen > maxGen) return;
- const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
- if (!dog) return;
- if (!map.has(id)) map.set(id, []);
- map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
- if (map.get(id).length === 1) {
- const parents = db.prepare(`
- SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
- `).all(id);
- parents.forEach(p => recurse(p.parent_id, gen + 1));
+ function recurse(id, gen) {
+ if (gen > maxGen) return;
+ const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
+ if (!dog) return;
+ if (!map.has(id)) map.set(id, []);
+ map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
+ if (map.get(id).length === 1) {
+ const parents = db.prepare(`
+ SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
+ `).all(id);
+ parents.forEach(p => recurse(p.parent_id, gen + 1));
+ }
}
- }
- recurse(parseInt(dogId), 0);
- return map;
+ recurse(parseInt(dogId), 0);
+ return map;
+ });
}
/**
@@ -68,54 +90,57 @@ function isDirectRelation(db, sireId, damId) {
* self-loops.
*/
function calculateCOI(db, sireId, damId) {
- const sid = parseInt(sireId);
- const did = parseInt(damId);
- const sireMap = getAncestorMap(db, sid);
- const damMap = getAncestorMap(db, did);
+ const cacheKey = `${sireId}-${damId}`;
+ return getFromCache(coiCache, cacheKey, () => {
+ const sid = parseInt(sireId);
+ const did = parseInt(damId);
+ const sireMap = getAncestorMap(db, sid);
+ const damMap = getAncestorMap(db, did);
- // Common ancestors: in BOTH maps, but:
- // - not the dam itself appearing in sireMap (would be a loop)
- // - not the sire itself appearing in damMap already handled below
- // We collect all IDs present in both, excluding only the direct
- // subjects (did from sireMap side, sid excluded already since we
- // iterate sireMap keys — but sid IS in sireMap at gen 0, and if
- // damMap also has sid, that is the parent×offspring case we WANT).
- const commonIds = [...sireMap.keys()].filter(
- id => damMap.has(id) && id !== did
- );
+ // Common ancestors: in BOTH maps, but:
+ // - not the dam itself appearing in sireMap (would be a loop)
+ // - not the sire itself appearing in damMap already handled below
+ // We collect all IDs present in both, excluding only the direct
+ // subjects (did from sireMap side, sid excluded already since we
+ // iterate sireMap keys — but sid IS in sireMap at gen 0, and if
+ // damMap also has sid, that is the parent×offspring case we WANT).
+ const commonIds = [...sireMap.keys()].filter(
+ id => damMap.has(id) && id !== did
+ );
- let coi = 0;
- const processedPaths = new Set();
- const commonAncestorList = [];
+ let coi = 0;
+ const processedPaths = new Set();
+ const commonAncestorList = [];
- commonIds.forEach(ancId => {
- const sireOccs = sireMap.get(ancId);
- const damOccs = damMap.get(ancId);
+ commonIds.forEach(ancId => {
+ const sireOccs = sireMap.get(ancId);
+ const damOccs = damMap.get(ancId);
- sireOccs.forEach(so => {
- damOccs.forEach(do_ => {
- const key = `${ancId}-${so.generation}-${do_.generation}`;
- if (!processedPaths.has(key)) {
- processedPaths.add(key);
- coi += Math.pow(0.5, so.generation + do_.generation + 1);
- }
+ sireOccs.forEach(so => {
+ damOccs.forEach(do_ => {
+ const key = `${ancId}-${so.generation}-${do_.generation}`;
+ if (!processedPaths.has(key)) {
+ processedPaths.add(key);
+ coi += Math.pow(0.5, so.generation + do_.generation + 1);
+ }
+ });
+ });
+
+ const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
+ const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
+ commonAncestorList.push({
+ id: ancId,
+ name: sireOccs[0].name,
+ sireGen: closestSire.generation,
+ damGen: closestDam.generation
});
});
- const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
- const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
- commonAncestorList.push({
- id: ancId,
- name: sireOccs[0].name,
- sireGen: closestSire.generation,
- damGen: closestDam.generation
- });
+ return {
+ coefficient: coi,
+ commonAncestors: commonAncestorList
+ };
});
-
- return {
- coefficient: coi,
- commonAncestors: commonAncestorList
- };
}
// =====================================================================
@@ -195,6 +220,63 @@ router.get('/relations/:sireId/:damId', (req, res) => {
}
});
+// GET /api/pedigree/:id/cancer-lineage
+router.get('/:id/cancer-lineage', (req, res) => {
+ try {
+ const db = getDatabase();
+ // Get ancestor map up to 5 generations
+ const ancestorMap = getAncestorMap(db, req.params.id, 5);
+
+ // Collect all unique ancestor IDs
+ const ancestorIds = Array.from(ancestorMap.keys());
+
+ if (ancestorIds.length === 0) {
+ return res.json({ lineage_cases: [], stats: { total_ancestors: 0, ancestors_with_cancer: 0 } });
+ }
+
+ // Query cancer history for all ancestors
+ const placeholders = ancestorIds.map(() => '?').join(',');
+ const cancerRecords = db.prepare(`
+ SELECT c.*, d.name, d.sex
+ FROM cancer_history c
+ JOIN dogs d ON c.dog_id = d.id
+ WHERE c.dog_id IN (${placeholders})
+ `).all(...ancestorIds);
+
+ // Structure the response
+ const cases = cancerRecords.map(record => {
+ // Find the closest generation this ancestor appears in
+ const occurrences = ancestorMap.get(record.dog_id);
+ const closestGen = occurrences.reduce((min, occ) => occ.generation < min ? occ.generation : min, 999);
+
+ return {
+ ...record,
+ generation_distance: closestGen
+ };
+ });
+
+ // Sort by generation distance (closer relatives first)
+ cases.sort((a, b) => a.generation_distance - b.generation_distance);
+
+ // Count unique dogs with cancer (excluding generation 0 if we only want stats on ancestors)
+ const ancestorCases = cases.filter(c => c.generation_distance > 0);
+ const uniqueAncestorsWithCancer = new Set(ancestorCases.map(c => c.dog_id)).size;
+
+ // Number of ancestors is total unique IDs minus 1 for the dog itself
+ const numAncestors = ancestorIds.length > 0 && ancestorMap.get(parseInt(req.params.id)) ? ancestorIds.length - 1 : ancestorIds.length;
+
+ res.json({
+ lineage_cases: cases,
+ stats: {
+ total_ancestors: numAncestors,
+ ancestors_with_cancer: uniqueAncestorsWithCancer
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// =====================================================================
// Wildcard routes last
// =====================================================================