feat/phase-4b-health-genetics #36

Merged
jason merged 8 commits from feat/phase-4b-health-genetics into master 2026-03-09 23:38:19 -05:00
8 changed files with 989 additions and 152 deletions

View File

@@ -114,7 +114,7 @@
- [x] `GET /api/breeding/heat-cycles` endpoint
- [x] `GET /api/breeding/heat-cycles/:id/suggestions` endpoint
- [x] **Projected Whelping Calendar Identifier***(March 9, 2026 v0.5.1)*
- [x] **Projected Whelping Calendar Identifier***(March 9, 2026 v0.5.1)*
- [x] Gestation constants: earliest=58, expected=63, latest=65 days
- [x] `getWwhelpDates(cycle)` client-side helper (no extra API call)
- [x] Indigo whelp window cells (days 5863) on calendar grid
@@ -129,7 +129,7 @@
---
## ✅ Phase 4a: Champion & Settings (COMPLETE v0.6.0)
## ✅ Phase 4a: Champion & Settings (COMPLETE v0.6.0)
### Champion Bloodline Tracking
- [x] `is_champion INTEGER DEFAULT 0` column on `dogs` table
@@ -145,48 +145,147 @@
- [x] `settings` table: `kennel_name`, `kennel_tagline`, `kennel_address`, `kennel_phone`, `kennel_email`, `kennel_website`, `kennel_akc_id`, `kennel_breed`, `owner_name`
- [x] Safe `ALTER TABLE settings ADD COLUMN` migration loop for all kennel fields
- [x] Auto-seed default row (`kennel_name = 'BREEDR'`) if table is empty
- [x] `GET /api/settings` returns single-row as flat JSON object
- [x] `PUT /api/settings` partial update via `ALLOWED_KEYS` whitelist
- [x] `GET /api/settings` returns single-row as flat JSON object
- [x] `PUT /api/settings` partial update via `ALLOWED_KEYS` whitelist
- [x] `SettingsProvider` / `useSettings` React context hook
- [x] Kennel name displayed in navbar from settings
- [x] `SettingsPage` component for editing kennel info
### Build & Runtime Fixes (v0.6.0)
- [x] `useSettings.js``useSettings.jsx` Vite build failed because JSX in `.js` file
- [x] `server/index.js` `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal)
- [x] `server/index.js` removed duplicate `app.get('/api/health')` inline route
- [x] `server/index.js` `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation
- [x] `server/routes/settings.js` rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
- [x] `useSettings.js``useSettings.jsx` Vite build failed because JSX in `.js` file
- [x] `server/index.js` `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal)
- [x] `server/index.js` removed duplicate `app.get('/api/health')` inline route
- [x] `server/index.js` `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation
- [x] `server/routes/settings.js` rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
---
## 📋 Phase 4b: Health & Genetics (NEXT UP)
## 📋 Phase 4b: Health & Genetics (NEXT UP v0.7.0)
### Health Records *(Priority 1)* 🚨
- [ ] Health records list view per dog
- [ ] Add/edit health test results
- [ ] Vaccination tracking with expiry alerts
- [ ] Medical history timeline view
- [ ] Document uploads (PDFs, images)
- [ ] Health clearance status badges on dog cards
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
> This phase builds a structured, breed-aware health tracking system aligned with those requirements.
### Tier 1 — OFA Health Clearances *(Priority 1)* 🩺
The four GRCA-required clearances that must be on record in the public OFA database before breeding.
**Database (schema additions to `health_records` table):**
- [ ] Add `test_type` ENUM-style field: `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`, `dna_panel`
- [ ] Add `result` field: `pass`, `fail`, `carrier`, `clear`, `excellent`, `good`, `fair`, `borderline`
- [ ] Add `ofa_number` VARCHAR — official OFA certification number
- [ ] Add `chic_number` VARCHAR — CHIC certification number (dog-level field on `dogs` table)
- [ ] Add `performed_by` VARCHAR — vet or specialist name
- [ ] Add `expires_at` DATE — for annually-renewed tests (eyes, heart)
- [ ] Add `document_url` VARCHAR — path to uploaded PDF/image
- [ ] Safe ALTER TABLE migration guards for all new columns
**API:**
- [ ] `GET /api/health/:dogId` — list all health records for a dog
- [ ] `POST /api/health` — create health record
- [ ] `PUT /api/health/:id` — update health record
- [ ] `DELETE /api/health/:id` — delete health record
- [ ] `GET /api/health/:dogId/clearance-summary` — returns pass/fail/missing for all 4 OFA tiers
- [ ] `GET /api/health/:dogId/chic-eligible` — returns boolean + missing tests
**UI Components:**
- [ ] `HealthRecordForm` modal — test type dropdown, result, OFA#, date, performed-by, expiry, document upload
- [ ] `HealthTimeline` component — chronological list of all health events per dog on DogDetail page
- [ ] `ClearanceSummaryCard` — shows OFA Hip / Elbow / Heart / Eyes status in a 2x2 grid with color badges (green=pass, yellow=expiring, red=missing/fail)
- [ ] `ChicStatusBadge` — amber badge on dog cards and DogDetail if CHIC number is on file
- [ ] Expiry alert: yellow badge on dog card if any annual test expires within 90 days; red if expired
- [ ] Document upload support (PDF/image) tied to individual health records
**Clearance Tiers Tracked:**
| Test | OFA Minimum Age | Renewal | Notes |
|---|---|---|---|
| Hip Dysplasia | 24 months | Once (final) | OFA eval or PennHIP |
| Elbow Dysplasia | 24 months | Once (final) | OFA eval |
| Cardiac (Heart) | 12 months | Annual recommended | Echo preferred over auscultation |
| Eyes (CAER) | 12 months | **Annual** | Board-certified ACVO ophthalmologist |
| Thyroid (OFA) | 12 months | Annual recommended | Bonus/Tier 2 |
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
**Estimated Time:** 810 hours
**Why this is recommended:**
- Natural complement to existing dog profiles
- Vaccination expiry alerts are high day-to-day utility
- Clearance badges on dog cards improve trust at a glance
- Builds toward breeding decision support
---
**Estimated Time:** 6-8 hours
### Tier 2 — DNA Genetic Panel *(Priority 2)* 🧬
### Genetic Trait Tracking *(Priority 2)*
- [ ] Track inherited traits
- [ ] Color genetics calculator
- [ ] Health clearance status
- [ ] Link traits to ancestors
Embark or equivalent panel results per dog. Allows carrier × clear pairing without producing affected offspring.
**Estimated Time:** 5-7 hours
**Database:**
- [ ] `genetic_tests` table: `id`, `dog_id`, `test_provider` (embark/optigen/etc), `test_name`, `result` (clear/carrier/affected), `test_date`, `document_url`, `created_at`
- [ ] Safe `CREATE TABLE IF NOT EXISTS` guard
**Golden Retriever Panel — Key Markers:**
- [ ] PRA1 (Progressive Retinal Atrophy type 1)
- [ ] PRA2 (Progressive Retinal Atrophy type 2)
- [ ] prcd-PRA (Progressive Rod-Cone Degeneration)
- [ ] ICH1 / ICH2 (Ichthyosis — very common in Goldens)
- [ ] NCL (Neuronal Ceroid Lipofuscinosis — fatal neurological)
- [ ] DM (Degenerative Myelopathy)
- [ ] MD (Muscular Dystrophy)
- [ ] GR-PRA1, GR-PRA2 (Golden-specific PRA variants)
**API:**
- [ ] `GET /api/genetics/:dogId` — list all genetic test results
- [ ] `POST /api/genetics` — add genetic result
- [ ] `PUT /api/genetics/:id` — update
- [ ] `DELETE /api/genetics/:id` — delete
- [ ] `GET /api/genetics/pairing-risk?sireId=&damId=` — returns at-risk combinations for a trial pairing
**UI Components:**
- [ ] `GeneticTestForm` modal — provider, marker, result (clear/carrier/affected), date, upload
- [ ] `GeneticPanelCard` on DogDetail — color-coded grid of all markers (green=clear, yellow=carrier, red=affected, gray=not tested)
- [ ] Pairing risk overlay on Trial Pairing Simulator — flag if sire+dam are both carriers for same marker
- [ ] "Not Tested" indicator on dog cards when no DNA panel on file
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
**Estimated Time:** 68 hours
---
### Tier 3 — Cancer Lineage & Longevity Tracking *(Priority 3)* 📊
Golden Retrievers have ~60% cancer mortality rate. Lineage-based cancer history is a major differentiator for responsible breeders.
**Database:**
- [ ] `cancer_history` table: `id`, `dog_id`, `cancer_type`, `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`, `created_at`
- [ ] Add `age_at_death` and `cause_of_death` optional fields to `dogs` table
**API:**
- [ ] `GET /api/health/:dogId/cancer-history`
- [ ] `POST /api/health/cancer-history`
- [ ] `GET /api/pedigree/:dogId/cancer-lineage` — walks ancestors and returns cancer incidence summary
**UI:**
- [ ] Longevity section on DogDetail — age at death, cause of death
- [ ] Cancer lineage indicator on Trial Pairing Simulator — "X of 8 ancestors had cancer history"
- [ ] Optional cancer history entry on DogForm
**Complexity:** Low-Medium | **Impact:** Medium | **User Value:** High (differentiator)
**Estimated Time:** 45 hours
---
### Tier 4 — Breeding Eligibility Checker *(Priority 4)* ✅
Automatic litter eligibility gate based on health clearance status of sire and dam.
**Logic:**
- [ ] Dog is "GRCA eligible" if: Hip OFA ✅ + Elbow OFA ✅ + Heart ✅ + Eyes (non-expired) ✅ + age ≥ 24 months
- [ ] Dog is "CHIC eligible" if all four tests are in OFA public database (CHIC number on file)
- [ ] Warning flags in Trial Pairing Simulator if sire or dam is missing required clearances
- [ ] Block litter creation (with override) if either parent fails eligibility check
**UI:**
- [ ] Eligibility badge on dog cards: `GRCA Eligible` (green) / `Incomplete` (yellow) / `Not Eligible` (red)
- [ ] Eligibility breakdown tooltip on hover — shows which tests are missing
- [ ] Pre-litter warning modal when creating a litter with non-eligible parents
- [ ] CHIC number field + verification note on DogDetail
**Complexity:** Low | **Impact:** High | **User Value:** Excellent
**Estimated Time:** 34 hours
---
@@ -266,9 +365,10 @@
- [ ] Export to Excel/CSV
- [ ] Integration with kennel clubs
- [ ] Backup to cloud storage
- [ ] OFA database lookup by registration number
### Advanced Genetics
- [ ] DNA test result tracking
- [ ] DNA test result tracking (full Embark import)
- [ ] Genetic diversity analysis
- [ ] Breed-specific calculators
- [ ] Health risk predictions
@@ -281,48 +381,58 @@
---
## 📅 Current Sprint: v0.7.0
## 🏃 Current Sprint: v0.7.0 (Phase 4b)
### ✅ Completed This Sprint (v0.6.0)
- [x] `is_champion` flag DB column, API, DogForm toggle, offspring badge, parent dropdown `✪`
- [x] Kennel Settings `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name
- [x] `is_champion` flag DB column, API, DogForm toggle, offspring badge, parent dropdown `✪`
- [x] Kennel Settings `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name
- [x] `useSettings.jsx` rename (Vite build fix)
- [x] `server/index.js` fix `initDatabase()` no-arg call, duplicate health route removed
- [x] `server/routes/settings.js` rewrite — fixed double-encoded base64 + wrong key/value schema
- [x] `server/index.js` fix `initDatabase()` no-arg, duplicate health route removed
- [x] `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed
### ✅ Previously Completed (v0.5.1)
- [x] Projected Whelping Calendar Identifier indigo whelp window cells, due label, active card range, jump-to-month button
- [x] Projected Whelping Calendar Identifier indigo whelp window cells, due label, active card range, jump-to-month button
- [x] Live whelp preview in Cycle Detail modal (client-side, no save required)
- [x] Full-width whelping banner for months with projected whelps
- [x] "Projected Whelp" legend entry + updated page subtitle
### 🔜 Next Up (Priority Order)
### 🔜 Next Up — Phase 4b Build Order
#### Option 1: Health Records System (Recommended) 🚨
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
#### Step 1: DB Schema Extensions
- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url)
- [ ] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants)
- [ ] Create `cancer_history` table
- [ ] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table
- [ ] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards
**Tasks:**
- Create `HealthRecordForm` component
- Health records list/timeline per dog on DogDetail page
- Vaccination tracking with expiry date + alert badge
- Health clearance status badges (OFA, CERF, etc.)
- Optional document/PDF upload
#### Step 2: API Layer
- [ ] `GET|POST|PUT|DELETE /api/health/:dogId` (OFA records)
- [ ] `GET /api/health/:dogId/clearance-summary`
- [ ] `GET /api/health/:dogId/chic-eligible`
- [ ] `GET|POST|PUT|DELETE /api/genetics/:dogId`
- [ ] `GET /api/genetics/pairing-risk` (sire + dam carrier check)
- [ ] Cancer history endpoints
**Estimated Time:** 6-8 hours
#### Step 3: Core UI — Health Records
- [ ] `HealthRecordForm` modal (test type, result, OFA#, expiry, doc upload)
- [ ] `HealthTimeline` on DogDetail page
- [ ] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes)
- [ ] `ChicStatusBadge` on dog cards
- [ ] Expiry alert badges (90-day warning, expired)
---
#### Step 4: Core UI — Genetics Panel
- [ ] `GeneticTestForm` modal
- [ ] `GeneticPanelCard` on DogDetail (color-coded markers)
- [ ] Pairing risk overlay on Trial Pairing Simulator
#### Option 2: Genetic Trait Tracking
**Complexity:** Medium | **Impact:** Medium | **User Value:** Good
#### Step 5: Eligibility Checker
- [ ] Eligibility logic (`grca_eligible`, `chic_eligible` computed fields)
- [ ] Eligibility badge on dog cards
- [ ] Pre-litter eligibility warning modal
**Tasks:**
- Trait entry form (coat color, pattern, carried traits)
- Display traits on dog detail page
- Predicted trait calculator for trial pairings
**Estimated Time:** 5-7 hours
---
#### Step 6: Cancer / Longevity (Stretch)
- [ ] Cancer history form + lineage summary on Trial Pairing page
- [ ] Age at death / cause of death on DogDetail
### Testing Needed
- [x] Add/edit dog forms with litter selection
@@ -335,13 +445,15 @@
- [x] Brand logo display and sizing
- [x] Gradient title rendering
- [x] Static asset serving in prod and dev
- [ ] Champion toggle DogForm save/load round-trip
- [ ] Champion badge offspring card display
- [ ] Kennel settings save + navbar name update
- [ ] Champion toggle DogForm save/load round-trip
- [ ] Champion badge offspring card display
- [ ] Kennel settings save + navbar name update
- [ ] Trial pairing simulator (end-to-end)
- [ ] Heat cycle calendar (start cycle, detail modal, whelping)
- [ ] Projected whelping calendar identifier (whelp cells, due label, banner)
- [ ] Health records
- [ ] Health records — OFA clearance CRUD
- [ ] Genetic panel — DNA marker entry and display
- [ ] Eligibility checker — badge and litter gate
### Known Issues
- None currently
@@ -358,6 +470,12 @@
## Version History
- **v0.7.0** (In Progress) - Phase 4b: Health & Genetics
- OFA clearance tracking (Hip, Elbow, Heart, Eyes + CHIC number)
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
- Cancer lineage & longevity tracking
- Breeding eligibility checker (GRCA + CHIC gates)
- **v0.6.0** (March 9, 2026) - Champion Bloodline, Settings, Build Fixes
- `is_champion` flag on dogs table with ALTER TABLE migration guard
- Champion toggle in DogForm; `✪` suffix in parent dropdowns; offspring badge

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { ShieldCheck, ShieldAlert, ShieldX, Clock, AlertTriangle, Plus } from 'lucide-react'
import axios from 'axios'
const STATUS_CONFIG = {
pass: { icon: ShieldCheck, color: 'var(--success)', label: 'Clear', bg: 'rgba(52,199,89,0.1)' },
expiring_soon: { icon: Clock, color: 'var(--warning)', label: 'Expiring Soon', bg: 'rgba(255,159,10,0.1)' },
expired: { icon: ShieldX, color: 'var(--danger)', label: 'Expired', bg: 'rgba(255,59,48,0.1)' },
missing: { icon: ShieldAlert, color: 'var(--text-muted)', label: 'Missing', bg: 'var(--bg-primary)' },
}
const GROUP_LABELS = { hip: 'Hips', elbow: 'Elbows', heart: 'Heart', eye: 'Eyes' }
function ClearanceChip({ group, status, record }) {
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.missing
const Icon = cfg.icon
const tip = record
? `OFA #${record.ofa_number || '-'} - ${record.ofa_result || record.result || ''}`
: 'No record on file'
return (
<div
title={tip}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
padding: '0.45rem 0.75rem',
background: cfg.bg,
border: `1px solid ${cfg.color}44`,
borderRadius: 'var(--radius-sm)',
flex: '1 1 calc(50% - 0.5rem)',
minWidth: '140px',
}}
>
<Icon size={15} color={cfg.color} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{GROUP_LABELS[group]}
</div>
<div style={{ fontSize: '0.82rem', fontWeight: 500, color: cfg.color }}>
{cfg.label}
</div>
</div>
</div>
)
}
export default function ClearanceSummaryCard({ dogId, onAddRecord }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
axios.get(`/api/health/dog/${dogId}/clearance-summary`)
.then(r => setData(r.data))
.catch(() => setError(true))
}, [dogId])
if (error || !data) return null
const { summary, grca_eligible, age_eligible, chic_number } = data
const hasMissing = Object.values(summary).some(s => s.status === 'missing')
const hasExpiring = Object.values(summary).some(s => s.status === 'expiring_soon')
return (
<div className="card" style={{ marginBottom: '1.5rem' }}>
{/* Header row */}
<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 }}>
OFA Clearances
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{grca_eligible && (
<span style={{
fontSize: '0.7rem', fontWeight: 600, padding: '0.2rem 0.6rem',
background: 'rgba(52,199,89,0.15)', color: 'var(--success)',
borderRadius: '999px', border: '1px solid rgba(52,199,89,0.3)'
}}>GRCA Eligible</span>
)}
{!age_eligible && (
<span style={{
fontSize: '0.7rem', fontWeight: 600, padding: '0.2rem 0.6rem',
background: 'rgba(255,159,10,0.15)', color: 'var(--warning)',
borderRadius: '999px', border: '1px solid rgba(255,159,10,0.3)'
}}>Under 24mo</span>
)}
{chic_number && (
<span style={{
fontSize: '0.7rem', 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 #{chic_number}</span>
)}
</div>
</div>
{/* Clearance chips */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
{Object.entries(summary).map(([group, { status, record }]) => (
<ClearanceChip key={group} group={group} status={status} record={record} />
))}
</div>
{/* Expiry warning */}
{hasExpiring && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 0.75rem', borderRadius: 'var(--radius-sm)',
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.25)',
fontSize: '0.8rem', color: 'var(--warning)', marginBottom: '0.5rem'
}}>
<AlertTriangle size={14} />
One or more clearances expire within 90 days. Schedule re-testing.
</div>
)}
{/* CTA */}
{(hasMissing || onAddRecord) && (
<button
className="btn btn-ghost"
onClick={onAddRecord}
style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.3rem' }}
>
<Plus size={14} /> Add Health Record
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import axios from 'axios'
const RECORD_TYPES = ['ofa_clearance', 'vaccination', 'exam', 'surgery', 'medication', 'other']
const OFA_TEST_TYPES = [
{ value: 'hip_ofa', label: 'Hip - OFA' },
{ value: 'hip_pennhip', label: 'Hip - PennHIP' },
{ value: 'elbow_ofa', label: 'Elbow - OFA' },
{ value: 'heart_ofa', label: 'Heart - OFA' },
{ value: 'heart_echo', label: 'Heart - Echo' },
{ value: 'eye_caer', label: 'Eyes - CAER' },
]
const OFA_RESULTS = ['Excellent', 'Good', 'Fair', 'Mild', 'Moderate', 'Severe', 'Normal', 'Abnormal', 'Pass', 'Fail']
const EMPTY = {
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
test_date: '', ofa_result: 'Good', ofa_number: '', performed_by: '',
expires_at: '', result: '', vet_name: '', next_due: '', notes: '', document_url: '',
}
export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const isOFA = form.record_type === 'ofa_clearance'
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
if (record && record.id) {
await axios.put(`/api/health/${record.id}`, form)
} else {
await axios.post('/api/health', { ...form, dog_id: dogId })
}
onSave()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save 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' }
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
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: '560px', 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'} Health Record</h2>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Record type */}
<div style={fw}>
<label style={labelStyle}>Record Type</label>
<select style={inputStyle} value={form.record_type} onChange={e => set('record_type', e.target.value)}>
{RECORD_TYPES.map(t => (
<option key={t} value={t}>
{t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</option>
))}
</select>
</div>
{isOFA ? (
<>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>OFA Test Type</label>
<select style={inputStyle} value={form.test_type} onChange={e => set('test_type', e.target.value)}>
{OFA_TEST_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div style={fw}>
<label style={labelStyle}>OFA Result</label>
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
{OFA_RESULTS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>OFA Number</label>
<input style={inputStyle} placeholder="GR-12345E24M-VPI" value={form.ofa_number}
onChange={e => set('ofa_number', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Performed By</label>
<input style={inputStyle} placeholder="Radiologist / cardiologist" value={form.performed_by}
onChange={e => set('performed_by', e.target.value)} />
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Test Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Expires At</label>
<input style={inputStyle} type="date" value={form.expires_at}
onChange={e => set('expires_at', e.target.value)} />
</div>
</div>
</>
) : (
<>
<div style={fw}>
<label style={labelStyle}>Test / Procedure Name</label>
<input style={inputStyle} placeholder="e.g. Rabies, Bordetella..." value={form.test_name}
onChange={e => set('test_name', e.target.value)} />
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Next Due</label>
<input style={inputStyle} type="date" value={form.next_due}
onChange={e => set('next_due', e.target.value)} />
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Result</label>
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
onChange={e => set('result', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Vet Name</label>
<input style={inputStyle} placeholder="Dr. Smith" value={form.vet_name}
onChange={e => set('vet_name', e.target.value)} />
</div>
</div>
</>
)}
<div style={fw}>
<label style={labelStyle}>Document URL (optional)</label>
<input style={inputStyle} type="url" placeholder="https://ofa.org/..." 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: '70px', 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' }}>
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : record && record.id ? 'Save Changes' : 'Add Record'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -4,6 +4,8 @@ import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
import HealthRecordForm from '../components/HealthRecordForm'
function DogDetail() {
const { id } = useParams()
@@ -15,7 +17,13 @@ function DogDetail() {
const [selectedPhoto, setSelectedPhoto] = useState(0)
const fileInputRef = useRef(null)
// Health records state
const [healthRecords, setHealthRecords] = useState([])
const [showHealthForm, setShowHealthForm] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
useEffect(() => { fetchDog() }, [id])
useEffect(() => { fetchHealth() }, [id])
const fetchDog = async () => {
try {
@@ -28,6 +36,12 @@ function DogDetail() {
}
}
const fetchHealth = () => {
axios.get(`/api/health/dog/${id}`)
.then(r => setHealthRecords(r.data))
.catch(() => {})
}
const handlePhotoUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
@@ -66,7 +80,7 @@ function DogDetail() {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) { years--; months += 12 }
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
@@ -77,10 +91,15 @@ function DogDetail() {
const hasChampionBlood = (d) =>
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
if (loading) return <div className="container loading">Loading...</div>
if (!dog) return <div className="container">Dog not found</div>
const openAddHealth = () => { setEditingRecord(null); setShowHealthForm(true) }
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
const isChampion = !!dog.is_champion
if (loading) return <div className="container loading">Loading...</div>
if (!dog) return <div className="container">Dog not found</div>
const isChampion = !!dog.is_champion
const hasBloodline = !isChampion && hasChampionBlood(dog)
return (
@@ -93,13 +112,13 @@ function DogDetail() {
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<h1 style={{ margin: 0 }}>{dog.name}</h1>
{isChampion && <ChampionBadge size="lg" />}
{isChampion && <ChampionBadge size="lg" />}
{hasBloodline && <ChampionBloodlineBadge size="lg" />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
<span>{dog.breed}</span>
<span>·</span>
<span>{dog.sex === 'male' ? 'Male' : 'Female'}</span>
<span>{dog.sex === 'male' ? 'Male' : 'Female'}</span>
{dog.birth_date && (
<>
<span>·</span>
@@ -180,8 +199,7 @@ function DogDetail() {
onClick={() => setSelectedPhoto(index)}
style={{
width: '60px', height: '60px', objectFit: 'cover',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
opacity: selectedPhoto === index ? 1 : 0.6,
transition: 'all 0.2s'
@@ -210,7 +228,7 @@ function DogDetail() {
</div>
<div className="info-row">
<span className="info-label">Sex</span>
<span className="info-value">{dog.sex === 'male' ? 'Male' : 'Female'}</span>
<span className="info-value">{dog.sex === 'male' ? 'Male' : 'Female'}</span>
</div>
<div className="info-row">
<span className="info-label">Champion</span>
@@ -219,7 +237,7 @@ function DogDetail() {
? <ChampionBadge size="lg" />
: hasBloodline
? <ChampionBloodlineBadge size="lg" />
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}></span>
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>&mdash;</span>
}
</span>
</div>
@@ -296,6 +314,49 @@ function DogDetail() {
</div>
)}
{/* OFA Clearance Summary */}
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
{/* Health Records List */}
{healthRecords.length > 0 && (
<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 }}>
Health Records ({healthRecords.length})
</h2>
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAddHealth}>
+ Add
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{healthRecords.map(rec => (
<div key={rec.id} style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.6rem 0.75rem', background: 'var(--bg-primary)',
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
}}>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
</span>
{rec.ofa_result && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
</span>
)}
</div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{rec.test_date ? new Date(rec.test_date).toLocaleDateString() : ''}
</span>
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
<Edit size={14} />
</button>
</div>
))}
</div>
</div>
)}
{/* Offspring */}
{dog.offspring && dog.offspring.length > 0 && (
<div className="card">
@@ -317,19 +378,19 @@ function DogDetail() {
alignItems: 'center',
gap: '0.5rem'
}}
onMouseEnter={(e) => {
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.background = 'var(--bg-tertiary)'
e.currentTarget.style.background = 'var(--bg-tertiary)'
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.background = 'var(--bg-primary)'
e.currentTarget.style.background = 'var(--bg-primary)'
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{child.is_champion && <ChampionBadge />}
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '' : ''}</span>
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '' : ''}</span>
</div>
</Link>
))}
@@ -337,6 +398,7 @@ function DogDetail() {
</div>
)}
{/* Edit Dog Modal */}
{showEditModal && (
<DogForm
dog={dog}
@@ -344,6 +406,16 @@ function DogDetail() {
onSave={() => { fetchDog(); setShowEditModal(false) }}
/>
)}
{/* Health Record Form Modal */}
{showHealthForm && (
<HealthRecordForm
dogId={id}
record={editingRecord}
onClose={closeHealthForm}
onSave={handleHealthSaved}
/>
)}
</div>
)
}

View File

@@ -16,28 +16,37 @@ function initDatabase() {
// ── Dogs ────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
registration_number TEXT,
breed TEXT NOT NULL,
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
birth_date TEXT,
color TEXT,
microchip TEXT,
litter_id INTEGER,
is_active INTEGER DEFAULT 1,
is_champion INTEGER DEFAULT 0,
photo_urls TEXT DEFAULT '[]',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
registration_number TEXT,
breed TEXT NOT NULL,
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
birth_date TEXT,
color TEXT,
microchip TEXT,
litter_id INTEGER,
is_active INTEGER DEFAULT 1,
is_champion INTEGER DEFAULT 0,
chic_number TEXT,
age_at_death TEXT,
cause_of_death TEXT,
photo_urls TEXT DEFAULT '[]',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
// migrate: add is_champion if missing (safe on existing DBs)
try {
db.exec(`ALTER TABLE dogs ADD COLUMN is_champion INTEGER DEFAULT 0`);
} catch (_) { /* column already exists */ }
// migrate: add columns if missing (safe on existing DBs)
const dogMigrations = [
['is_champion', 'INTEGER DEFAULT 0'],
['chic_number', 'TEXT'],
['age_at_death', 'TEXT'],
['cause_of_death', 'TEXT'],
];
for (const [col, def] of dogMigrations) {
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
// ── Parents ─────────────────────────────────────────────────────────
db.exec(`
@@ -51,24 +60,24 @@ function initDatabase() {
)
`);
// ── Breeding Records ────────────────────────────────────────────────
// ── Breeding Records ────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS breeding_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sire_id INTEGER NOT NULL,
dam_id INTEGER NOT NULL,
breeding_date TEXT,
due_date TEXT,
conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
id INTEGER PRIMARY KEY AUTOINCREMENT,
sire_id INTEGER NOT NULL,
dam_id INTEGER NOT NULL,
breeding_date TEXT,
due_date TEXT,
conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (sire_id) REFERENCES dogs(id),
FOREIGN KEY (dam_id) REFERENCES dogs(id)
)
`);
// ── Litters ─────────────────────────────────────────────────────────
// ── Litters ─────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS litters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -89,21 +98,81 @@ function initDatabase() {
)
`);
// ── Health Records ──────────────────────────────────────────────────
// ── Health Records (OFA-extended) ────────────────────────────────────
// test_type values: hip_ofa | hip_pennhip | elbow_ofa | heart_ofa |
// heart_echo | eye_caer | thyroid_ofa | dna_panel | vaccination |
// other
// ofa_result values: excellent | good | fair | borderline | mild |
// moderate | severe | normal | abnormal | pass | fail | carrier |
// clear | affected | n/a
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL,
date TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
vet_name TEXT,
notes TEXT,
result TEXT,
next_due TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL,
test_type TEXT,
test_name TEXT,
test_date TEXT NOT NULL,
ofa_result TEXT,
ofa_number TEXT,
performed_by TEXT,
expires_at TEXT,
document_url TEXT,
result TEXT,
vet_name TEXT,
next_due TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
// migrate: add OFA-specific columns if missing
const healthMigrations = [
['test_type', 'TEXT'],
['ofa_result', 'TEXT'],
['ofa_number', 'TEXT'],
['performed_by', 'TEXT'],
['expires_at', 'TEXT'],
['document_url', 'TEXT'],
];
for (const [col, def] of healthMigrations) {
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
// ── Genetic Tests (DNA Panel) ─────────────────────────────────────────
// result values: clear | carrier | affected | not_tested
// marker examples: PRA1, PRA2, prcd-PRA, GR-PRA1, GR-PRA2, ICH1,
// ICH2, NCL, DM, MD
db.exec(`
CREATE TABLE IF NOT EXISTS genetic_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
test_provider TEXT,
marker TEXT NOT NULL,
result TEXT NOT NULL CHECK(result IN ('clear', 'carrier', 'affected', 'not_tested')),
test_date TEXT,
document_url TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
// ── Cancer History ───────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS cancer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
cancer_type TEXT,
age_at_diagnosis TEXT,
age_at_death TEXT,
cause_of_death TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
@@ -126,7 +195,6 @@ function initDatabase() {
)
`);
// migrate: add new kennel columns if missing (safe on existing DBs)
const kennelCols = [
['kennel_name', "TEXT DEFAULT 'BREEDR'"],
['kennel_tagline', 'TEXT'],
@@ -139,12 +207,9 @@ function initDatabase() {
['owner_name', 'TEXT'],
];
for (const [col, def] of kennelCols) {
try {
db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`);
} catch (_) { /* already exists */ }
try { db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
// Seed a default settings row if none exists
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
if (!existing) {
db.prepare(`INSERT INTO settings (kennel_name) VALUES (?)`).run('BREEDR');

View File

@@ -22,7 +22,7 @@ console.log('Initializing database...');
initDatabase();
console.log('✓ Database ready!\n');
// ── Middleware ─────────────────────────────────────────────────────────
// ── Middleware ─────────────────────────────────────────────────────────
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());
@@ -38,6 +38,7 @@ app.use('/static', (_req, res) => res.status(404).json({ error: 'Static asset n
app.use('/api/dogs', require('./routes/dogs'));
app.use('/api/litters', require('./routes/litters'));
app.use('/api/health', require('./routes/health'));
app.use('/api/genetics', require('./routes/genetics'));
app.use('/api/pedigree', require('./routes/pedigree'));
app.use('/api/breeding', require('./routes/breeding'));
app.use('/api/settings', require('./routes/settings'));
@@ -61,15 +62,15 @@ app.use((err, _req, res, _next) => {
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n\U0001f415 BREEDR Server Running`);
console.log(`=========================================`);
console.log(`\n🐕 BREEDR Server Running`);
console.log(`=============================================`);
console.log(`Environment : ${process.env.NODE_ENV || 'development'}`);
console.log(`Port : ${PORT}`);
console.log(`Data dir : ${DATA_DIR}`);
console.log(`Uploads : ${UPLOAD_PATH}`);
console.log(`Static : ${STATIC_PATH}`);
console.log(`Access : http://localhost:${PORT}`);
console.log(`=========================================\n`);
console.log(`=============================================\n`);
});
module.exports = app;

158
server/routes/genetics.js Normal file
View File

@@ -0,0 +1,158 @@
const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// Golden Retriever panel markers tracked by Breedr
const GR_MARKERS = [
'PRA1', 'PRA2', 'prcd-PRA', 'GR-PRA1', 'GR-PRA2',
'ICH1', 'ICH2', 'NCL', 'DM', 'MD'
];
// GET all genetic tests for a dog
router.get('/dog/:dogId', (req, res) => {
try {
const db = getDatabase();
const tests = db.prepare(`
SELECT * FROM genetic_tests
WHERE dog_id = ?
ORDER BY marker ASC
`).all(req.params.dogId);
// Return a full panel including not_tested placeholders
const byMarker = {};
for (const t of tests) byMarker[t.marker] = t;
const panel = GR_MARKERS.map(marker => ({
marker,
...(byMarker[marker] || { result: 'not_tested', dog_id: Number(req.params.dogId) })
}));
res.json({ tests, panel });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET pairing risk — compare sire + dam carrier status
// Usage: GET /api/genetics/pairing-risk?sireId=1&damId=2
router.get('/pairing-risk', (req, res) => {
try {
const { sireId, damId } = req.query;
if (!sireId || !damId) {
return res.status(400).json({ error: 'sireId and damId are required' });
}
const db = getDatabase();
const getResults = (dogId) => {
const rows = db.prepare('SELECT marker, result FROM genetic_tests WHERE dog_id = ?').all(dogId);
const map = {};
for (const r of rows) map[r.marker] = r.result;
return map;
};
const sireResults = getResults(sireId);
const damResults = getResults(damId);
const risks = [];
for (const marker of GR_MARKERS) {
const s = sireResults[marker] || 'not_tested';
const d = damResults[marker] || 'not_tested';
// Both affected or carrier x carrier = risk
if (
(s === 'affected' || d === 'affected') ||
(s === 'carrier' && d === 'carrier')
) {
risks.push({
marker,
sire_result: s,
dam_result: d,
risk_level: (s === 'affected' || d === 'affected') ? 'high' : 'moderate',
note: s === 'affected' || d === 'affected'
? 'One or both parents are affected — do not breed'
: 'Both parents are carriers — 25% chance of affected offspring',
});
}
}
res.json({
sire_id: Number(sireId),
dam_id: Number(damId),
risks,
safe_to_pair: risks.length === 0,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET single genetic test
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id);
if (!test) return res.status(404).json({ error: 'Genetic test not found' });
res.json(test);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST create genetic test
router.post('/', (req, res) => {
try {
const { dog_id, test_provider, marker, result, test_date, document_url, notes } = req.body;
if (!dog_id || !marker || !result) {
return res.status(400).json({ error: 'dog_id, marker, and result are required' });
}
if (!['clear', 'carrier', 'affected', 'not_tested'].includes(result)) {
return res.status(400).json({ error: 'result must be: clear | carrier | affected | not_tested' });
}
const db = getDatabase();
const dbResult = db.prepare(`
INSERT INTO genetic_tests (dog_id, test_provider, marker, result, test_date, document_url, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(dog_id, test_provider || null, marker, result, test_date || null, document_url || null, notes || null);
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(dbResult.lastInsertRowid);
res.status(201).json(test);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT update genetic test
router.put('/:id', (req, res) => {
try {
const { test_provider, marker, result, test_date, document_url, notes } = req.body;
const db = getDatabase();
db.prepare(`
UPDATE genetic_tests
SET test_provider = ?, marker = ?, result = ?, test_date = ?,
document_url = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?
`).run(test_provider || null, marker, result, test_date || null, document_url || null, notes || null, req.params.id);
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id);
res.json(test);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE genetic test
router.delete('/:id', (req, res) => {
try {
const db = getDatabase();
db.prepare('DELETE FROM genetic_tests WHERE id = ?').run(req.params.id);
res.json({ message: 'Genetic test deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -2,32 +2,113 @@ const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// OFA tests that count toward GRCA eligibility
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
const GRCA_CORE = {
hip: ['hip_ofa', 'hip_pennhip'],
elbow: ['elbow_ofa'],
heart: ['heart_ofa', 'heart_echo'],
eye: ['eye_caer'],
};
// Helper: compute clearance summary for a dog
function getClearanceSummary(db, dogId) {
const records = db.prepare(`
SELECT test_type, ofa_result, ofa_number, expires_at, test_date
FROM health_records
WHERE dog_id = ? AND test_type IS NOT NULL
ORDER BY test_date DESC
`).all(dogId);
const today = new Date();
const in90 = new Date(); in90.setDate(today.getDate() + 90);
const summary = {};
for (const [group, types] of Object.entries(GRCA_CORE)) {
const match = records.find(r => types.includes(r.test_type));
if (!match) {
summary[group] = { status: 'missing', record: null };
} else {
let status = 'pass';
if (match.expires_at) {
const exp = new Date(match.expires_at);
if (exp < today) status = 'expired';
else if (exp <= in90) status = 'expiring_soon';
}
summary[group] = { status, record: match };
}
}
return summary;
}
// GET all health records for a dog
router.get('/dog/:dogId', (req, res) => {
try {
const db = getDatabase();
const records = db.prepare(`
SELECT * FROM health_records
WHERE dog_id = ?
SELECT * FROM health_records
WHERE dog_id = ?
ORDER BY test_date DESC
`).all(req.params.dogId);
res.json(records);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET clearance summary (Hip / Elbow / Heart / Eyes) for a dog
router.get('/dog/:dogId/clearance-summary', (req, res) => {
try {
const db = getDatabase();
const dog = db.prepare('SELECT id, birth_date, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
const summary = getClearanceSummary(db, dog.id);
// Age check: must be >= 24 months for hip/elbow
let ageEligible = false;
if (dog.birth_date) {
const months = (new Date() - new Date(dog.birth_date)) / (1000 * 60 * 60 * 24 * 30.44);
ageEligible = months >= 24;
}
const allPass = Object.values(summary).every(s => ['pass', 'expiring_soon'].includes(s.status));
const grca_eligible = allPass && ageEligible;
res.json({ summary, grca_eligible, age_eligible: ageEligible, chic_number: dog.chic_number });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET CHIC eligibility check
router.get('/dog/:dogId/chic-eligible', (req, res) => {
try {
const db = getDatabase();
const dog = db.prepare('SELECT id, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
const summary = getClearanceSummary(db, dog.id);
const missing = Object.entries(summary)
.filter(([, v]) => v.status === 'missing')
.map(([k]) => k);
res.json({
chic_eligible: missing.length === 0,
chic_number: dog.chic_number || null,
missing_tests: missing,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 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' });
}
if (!record) return res.status(404).json({ error: 'Health record not found' });
res.json(record);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -37,20 +118,30 @@ router.get('/:id', (req, res) => {
// POST create health record
router.post('/', (req, res) => {
try {
const { dog_id, record_type, test_name, test_date, result, document_url, notes } = req.body;
const {
dog_id, record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes
} = req.body;
if (!dog_id || !record_type || !test_date) {
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' });
}
const db = getDatabase();
const dbResult = db.prepare(`
INSERT INTO health_records (dog_id, record_type, test_name, test_date, result, document_url, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(dog_id, record_type, test_name, test_date, result, document_url, notes);
INSERT INTO health_records
(dog_id, record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
dog_id, record_type, test_type || null, test_name || null, test_date,
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
document_url || null, result || null, vet_name || null, next_due || null, notes || null
);
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid);
res.status(201).json(record);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -60,15 +151,27 @@ router.post('/', (req, res) => {
// PUT update health record
router.put('/:id', (req, res) => {
try {
const { record_type, test_name, test_date, result, document_url, notes } = req.body;
const {
record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes
} = req.body;
const db = getDatabase();
db.prepare(`
UPDATE health_records
SET record_type = ?, test_name = ?, test_date = ?, result = ?, document_url = ?, notes = ?
UPDATE health_records
SET record_type = ?, test_type = ?, test_name = ?, test_date = ?,
ofa_result = ?, ofa_number = ?, performed_by = ?, expires_at = ?,
document_url = ?, result = ?, vet_name = ?, next_due = ?, notes = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(record_type, test_name, test_date, result, document_url, notes, req.params.id);
`).run(
record_type, test_type || null, test_name || null, test_date,
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
document_url || null, result || null, vet_name || null, next_due || null, notes || null,
req.params.id
);
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
res.json(record);
} catch (error) {
@@ -87,4 +190,4 @@ router.delete('/:id', (req, res) => {
}
});
module.exports = router;
module.exports = router;