Roadmap 2,3,4
This commit is contained in:
97
client/src/components/GeneticPanelCard.jsx
Normal file
97
client/src/components/GeneticPanelCard.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Dna size={18} /> DNA Genetics Panel
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAdd}>
|
||||||
|
<Plus size={14} /> Update Marker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={item.marker}
|
||||||
|
onClick={() => 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)'}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.2rem', fontWeight: 500 }}>
|
||||||
|
{item.marker}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: style.color, fontWeight: 600, textTransform: 'capitalize' }}>
|
||||||
|
{item.result.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<GeneticTestForm
|
||||||
|
dogId={dogId}
|
||||||
|
record={editingRecord}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
onSave={handleSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
client/src/components/GeneticTestForm.jsx
Normal file
157
client/src/components/GeneticTestForm.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
||||||
|
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', zIndex: 1000, padding: '1rem',
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{
|
||||||
|
width: '100%', maxWidth: '500px', maxHeight: '90vh',
|
||||||
|
overflowY: 'auto', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Genetic Result</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Marker *</label>
|
||||||
|
<select style={inputStyle} value={form.marker} onChange={e => set('marker', e.target.value)} disabled={!!record}>
|
||||||
|
{GR_MARKERS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Result *</label>
|
||||||
|
<select style={inputStyle} value={form.result} onChange={e => set('result', e.target.value)}>
|
||||||
|
{RESULTS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Provider</label>
|
||||||
|
<input style={inputStyle} placeholder="Embark, PawPrint, etc." value={form.test_provider}
|
||||||
|
onChange={e => set('test_provider', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Test Date</label>
|
||||||
|
<input style={inputStyle} type="date" value={form.test_date}
|
||||||
|
onChange={e => set('test_date', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Document URL</label>
|
||||||
|
<input style={inputStyle} type="url" placeholder="Link to PDF or result page" value={form.document_url}
|
||||||
|
onChange={e => set('document_url', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Notes</label>
|
||||||
|
<textarea style={{ ...inputStyle, minHeight: '60px', resize: 'vertical' }}
|
||||||
|
value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
|
||||||
|
}}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Result'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,19 +17,24 @@ function PedigreeView({ dogId, onClose }) {
|
|||||||
}, [dogId])
|
}, [dogId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateDimensions = () => {
|
|
||||||
const container = document.querySelector('.pedigree-container')
|
const container = document.querySelector('.pedigree-container')
|
||||||
if (container) {
|
if (!container) return
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
const width = container.offsetWidth
|
const width = container.offsetWidth
|
||||||
const height = container.offsetHeight
|
const height = container.offsetHeight
|
||||||
setDimensions({ width, height })
|
setDimensions({ width, height })
|
||||||
setTranslate({ x: width / 4, y: height / 2 })
|
setTranslate({ x: width / 4, y: height / 2 })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateDimensions()
|
updateDimensions()
|
||||||
window.addEventListener('resize', updateDimensions)
|
|
||||||
return () => window.removeEventListener('resize', updateDimensions)
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateDimensions()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(container)
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchPedigree = async () => {
|
const fetchPedigree = async () => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import DogForm from '../components/DogForm'
|
|||||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
|
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
|
||||||
import HealthRecordForm from '../components/HealthRecordForm'
|
import HealthRecordForm from '../components/HealthRecordForm'
|
||||||
|
import GeneticPanelCard from '../components/GeneticPanelCard'
|
||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -262,6 +264,18 @@ function DogDetail() {
|
|||||||
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{dog.chic_number && (
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label"><ShieldCheck size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />CHIC Status</span>
|
||||||
|
<span className="info-value">
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem', fontWeight: 600, padding: '0.2rem 0.6rem',
|
||||||
|
background: 'rgba(99,102,241,0.15)', color: '#818cf8',
|
||||||
|
borderRadius: '999px', border: '1px solid rgba(99,102,241,0.3)'
|
||||||
|
}}>CHIC #{dog.chic_number}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dog.microchip && (
|
{dog.microchip && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||||
@@ -317,6 +331,9 @@ function DogDetail() {
|
|||||||
{/* OFA Clearance Summary */}
|
{/* OFA Clearance Summary */}
|
||||||
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
|
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
|
||||||
|
|
||||||
|
{/* DNA Genetics Panel */}
|
||||||
|
<GeneticPanelCard dogId={id} />
|
||||||
|
|
||||||
{/* Health Records List */}
|
{/* Health Records List */}
|
||||||
{healthRecords.length > 0 && (
|
{healthRecords.length > 0 && (
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|||||||
@@ -259,6 +259,19 @@ function DogList() {
|
|||||||
{dog.registration_number}
|
{dog.registration_number}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{dog.chic_number && (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: 'rgba(99,102,241,0.1)',
|
||||||
|
border: '1px solid rgba(99,102,241,0.3)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.75rem', fontWeight: 600,
|
||||||
|
color: '#818cf8', marginLeft: '0.5rem'
|
||||||
|
}}>
|
||||||
|
CHIC #{dog.chic_number}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default function PairingSimulator() {
|
|||||||
const [dogsLoading, setDogsLoading] = useState(true)
|
const [dogsLoading, setDogsLoading] = useState(true)
|
||||||
const [relationWarning, setRelationWarning] = useState(null)
|
const [relationWarning, setRelationWarning] = useState(null)
|
||||||
const [relationChecking, setRelationChecking] = useState(false)
|
const [relationChecking, setRelationChecking] = useState(false)
|
||||||
|
const [geneticRisk, setGeneticRisk] = useState(null)
|
||||||
|
const [geneticChecking, setGeneticChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// include_external=1 ensures external sires/dams appear for pairing
|
// include_external=1 ensures external sires/dams appear for pairing
|
||||||
@@ -27,17 +29,28 @@ export default function PairingSimulator() {
|
|||||||
const checkRelation = useCallback(async (sid, did) => {
|
const checkRelation = useCallback(async (sid, did) => {
|
||||||
if (!sid || !did) {
|
if (!sid || !did) {
|
||||||
setRelationWarning(null)
|
setRelationWarning(null)
|
||||||
|
setGeneticRisk(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setRelationChecking(true)
|
setRelationChecking(true)
|
||||||
|
setGeneticChecking(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
|
const [relRes, genRes] = await Promise.all([
|
||||||
const data = await res.json()
|
fetch(`/api/pedigree/relations/${sid}/${did}`),
|
||||||
setRelationWarning(data.related ? data.relationship : null)
|
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 {
|
} catch {
|
||||||
setRelationWarning(null)
|
setRelationWarning(null)
|
||||||
|
setGeneticRisk(null)
|
||||||
} finally {
|
} finally {
|
||||||
setRelationChecking(false)
|
setRelationChecking(false)
|
||||||
|
setGeneticChecking(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -142,7 +155,7 @@ export default function PairingSimulator() {
|
|||||||
|
|
||||||
{relationChecking && (
|
{relationChecking && (
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
Checking relationship...
|
Checking relationship and genetics...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -158,6 +171,31 @@ export default function PairingSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 1rem', marginBottom: '0.75rem',
|
||||||
|
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.3)',
|
||||||
|
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--warning)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', fontWeight: 600 }}>
|
||||||
|
<ShieldAlert size={16} /> Genetic Risks Detected
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
|
||||||
|
{geneticRisk.risks.map(r => (
|
||||||
|
<li key={r.marker}>
|
||||||
|
<strong>{r.marker}</strong>: {r.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{geneticRisk && geneticRisk.missing_data && !geneticChecking && (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem', fontStyle: 'italic' }}>
|
||||||
|
* Sire or dam has missing genetic tests. Clearances cannot be fully verified.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const geneticMigrations = [
|
||||||
|
['test_provider', 'TEXT'],
|
||||||
|
['marker', "TEXT NOT NULL DEFAULT 'unknown'"],
|
||||||
|
['result', "TEXT NOT NULL DEFAULT 'not_tested'"],
|
||||||
|
['test_date', 'TEXT'],
|
||||||
|
['document_url', 'TEXT'],
|
||||||
|
['notes', 'TEXT']
|
||||||
|
];
|
||||||
|
for (const [col, def] of geneticMigrations) {
|
||||||
|
try { db.exec(`ALTER TABLE genetic_tests ADD COLUMN ${col} ${def}`); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cancer History ────────────────────────────────────────────────────────
|
// ── Cancer History ────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS cancer_history (
|
CREATE TABLE IF NOT EXISTS cancer_history (
|
||||||
@@ -173,6 +185,17 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cancerMigrations = [
|
||||||
|
['cancer_type', 'TEXT'],
|
||||||
|
['age_at_diagnosis', 'TEXT'],
|
||||||
|
['age_at_death', 'TEXT'],
|
||||||
|
['cause_of_death', 'TEXT'],
|
||||||
|
['notes', 'TEXT']
|
||||||
|
];
|
||||||
|
for (const [col, def] of cancerMigrations) {
|
||||||
|
try { db.exec(`ALTER TABLE cancer_history ADD COLUMN ${col} ${def}`); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const GRCA_CORE = {
|
|||||||
heart: ['heart_ofa', 'heart_echo'],
|
heart: ['heart_ofa', 'heart_echo'],
|
||||||
eye: ['eye_caer'],
|
eye: ['eye_caer'],
|
||||||
};
|
};
|
||||||
|
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa', 'dna_panel'];
|
||||||
|
|
||||||
// Helper: compute clearance summary for a dog
|
// Helper: compute clearance summary for a dog
|
||||||
function getClearanceSummary(db, dogId) {
|
function getClearanceSummary(db, dogId) {
|
||||||
@@ -103,17 +104,6 @@ router.get('/dog/:dogId/chic-eligible', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// POST create health record
|
||||||
router.post('/', (req, res) => {
|
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' });
|
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 db = getDatabase();
|
||||||
const dbResult = db.prepare(`
|
const dbResult = db.prepare(`
|
||||||
INSERT INTO health_records
|
INSERT INTO health_records
|
||||||
@@ -157,6 +151,10 @@ router.put('/:id', (req, res) => {
|
|||||||
document_url, result, vet_name, next_due, notes
|
document_url, result, vet_name, next_due, notes
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid test_type' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE health_records
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
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)
|
* getAncestorMap(db, dogId, maxGen)
|
||||||
* Returns Map<id, [{ id, name, generation }, ...]>
|
* Returns Map<id, [{ id, name, generation }, ...]>
|
||||||
@@ -9,6 +28,8 @@ const { getDatabase } = require('../db/init');
|
|||||||
* pairings are correctly detected by calculateCOI.
|
* pairings are correctly detected by calculateCOI.
|
||||||
*/
|
*/
|
||||||
function getAncestorMap(db, dogId, maxGen = 6) {
|
function getAncestorMap(db, dogId, maxGen = 6) {
|
||||||
|
const cacheKey = `${dogId}-${maxGen}`;
|
||||||
|
return getFromCache(ancestorCache, cacheKey, () => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
|
|
||||||
function recurse(id, gen) {
|
function recurse(id, gen) {
|
||||||
@@ -27,6 +48,7 @@ function getAncestorMap(db, dogId, maxGen = 6) {
|
|||||||
|
|
||||||
recurse(parseInt(dogId), 0);
|
recurse(parseInt(dogId), 0);
|
||||||
return map;
|
return map;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +90,8 @@ function isDirectRelation(db, sireId, damId) {
|
|||||||
* self-loops.
|
* self-loops.
|
||||||
*/
|
*/
|
||||||
function calculateCOI(db, sireId, damId) {
|
function calculateCOI(db, sireId, damId) {
|
||||||
|
const cacheKey = `${sireId}-${damId}`;
|
||||||
|
return getFromCache(coiCache, cacheKey, () => {
|
||||||
const sid = parseInt(sireId);
|
const sid = parseInt(sireId);
|
||||||
const did = parseInt(damId);
|
const did = parseInt(damId);
|
||||||
const sireMap = getAncestorMap(db, sid);
|
const sireMap = getAncestorMap(db, sid);
|
||||||
@@ -116,6 +140,7 @@ function calculateCOI(db, sireId, damId) {
|
|||||||
coefficient: coi,
|
coefficient: coi,
|
||||||
commonAncestors: commonAncestorList
|
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
|
// Wildcard routes last
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user