diff --git a/ROADMAP.md b/ROADMAP.md
index bc3f46b..d6c240c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 58–63) 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:** 8–10 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:** 6–8 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:** 4–5 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:** 3–4 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
diff --git a/client/src/components/ClearanceSummaryCard.jsx b/client/src/components/ClearanceSummaryCard.jsx
new file mode 100644
index 0000000..708cfcf
--- /dev/null
+++ b/client/src/components/ClearanceSummaryCard.jsx
@@ -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 (
+
+
+
+
+ {GROUP_LABELS[group]}
+
+
+ {cfg.label}
+
+
+
+ )
+}
+
+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 (
+
+ {/* Header row */}
+
+
+ OFA Clearances
+
+
+ {grca_eligible && (
+ GRCA Eligible
+ )}
+ {!age_eligible && (
+ Under 24mo
+ )}
+ {chic_number && (
+ CHIC #{chic_number}
+ )}
+
+
+
+ {/* Clearance chips */}
+
+ {Object.entries(summary).map(([group, { status, record }]) => (
+
+ ))}
+
+
+ {/* Expiry warning */}
+ {hasExpiring && (
+
+
+ One or more clearances expire within 90 days. Schedule re-testing.
+
+ )}
+
+ {/* CTA */}
+ {(hasMissing || onAddRecord) && (
+
+ )}
+
+ )
+}
diff --git a/client/src/components/HealthRecordForm.jsx b/client/src/components/HealthRecordForm.jsx
new file mode 100644
index 0000000..6916974
--- /dev/null
+++ b/client/src/components/HealthRecordForm.jsx
@@ -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 (
+
+
+
+
{record && record.id ? 'Edit' : 'Add'} Health Record
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/pages/DogDetail.jsx b/client/src/pages/DogDetail.jsx
index 60057d5..838c0b4 100644
--- a/client/src/pages/DogDetail.jsx
+++ b/client/src/pages/DogDetail.jsx
@@ -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 Loading...
- if (!dog) return Dog not found
+ 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 Loading...
+ if (!dog) return Dog not found
+
+ const isChampion = !!dog.is_champion
const hasBloodline = !isChampion && hasChampionBlood(dog)
return (
@@ -93,13 +112,13 @@ function DogDetail() {
{dog.name}
- {isChampion && }
+ {isChampion && }
{hasBloodline && }
{dog.breed}
·
- {dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}
+ {dog.sex === 'male' ? 'Male' : 'Female'}
{dog.birth_date && (
<>
·
@@ -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() {
Sex
- {dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}
+ {dog.sex === 'male' ? 'Male' : 'Female'}
Champion
@@ -219,7 +237,7 @@ function DogDetail() {
?
: hasBloodline
?
- : —
+ : —
}
@@ -296,6 +314,49 @@ function DogDetail() {
)}
+ {/* OFA Clearance Summary */}
+
+
+ {/* Health Records List */}
+ {healthRecords.length > 0 && (
+
+
+
+ Health Records ({healthRecords.length})
+
+
+
+
+ {healthRecords.map(rec => (
+
+
+
+ {rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
+
+ {rec.ofa_result && (
+
+ {rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
+
+ )}
+
+
+ {rec.test_date ? new Date(rec.test_date).toLocaleDateString() : ''}
+
+
+
+ ))}
+
+
+ )}
+
{/* Offspring */}
{dog.offspring && dog.offspring.length > 0 && (
@@ -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)'
}}
>
{child.name}
{child.is_champion && }
- {child.sex === 'male' ? '♂' : '♀'}
+ {child.sex === 'male' ? '' : ''}
))}
@@ -337,6 +398,7 @@ function DogDetail() {
)}
+ {/* Edit Dog Modal */}
{showEditModal && (
{ fetchDog(); setShowEditModal(false) }}
/>
)}
+
+ {/* Health Record Form Modal */}
+ {showHealthForm && (
+
+ )}
)
}
diff --git a/server/db/init.js b/server/db/init.js
index bc7d937..44685f4 100644
--- a/server/db/init.js
+++ b/server/db/init.js
@@ -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');
diff --git a/server/index.js b/server/index.js
index 363d8e6..b7c32a4 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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;
diff --git a/server/routes/genetics.js b/server/routes/genetics.js
new file mode 100644
index 0000000..4437b62
--- /dev/null
+++ b/server/routes/genetics.js
@@ -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;
diff --git a/server/routes/health.js b/server/routes/health.js
index 3080730..75cde00 100644
--- a/server/routes/health.js
+++ b/server/routes/health.js
@@ -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;
\ No newline at end of file
+module.exports = router;