Merge pull request 'feat/phase-4b-health-genetics' (#36) from feat/phase-4b-health-genetics into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #36
This commit was merged in pull request #36.
This commit is contained in:
240
ROADMAP.md
240
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
|
||||
|
||||
126
client/src/components/ClearanceSummaryCard.jsx
Normal file
126
client/src/components/ClearanceSummaryCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
client/src/components/HealthRecordForm.jsx
Normal file
194
client/src/components/HealthRecordForm.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -77,6 +91,11 @@ function DogDetail() {
|
||||
const hasChampionBlood = (d) =>
|
||||
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
|
||||
|
||||
const openAddHealth = () => { setEditingRecord(null); setShowHealthForm(true) }
|
||||
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
|
||||
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
|
||||
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
|
||||
|
||||
if (loading) return <div className="container loading">Loading...</div>
|
||||
if (!dog) return <div className="container">Dog not found</div>
|
||||
|
||||
@@ -99,7 +118,7 @@ function DogDetail() {
|
||||
<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' }}>—</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,11 +378,11 @@ function DogDetail() {
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||
e.currentTarget.style.background = 'var(--bg-tertiary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--border)'
|
||||
e.currentTarget.style.background = 'var(--bg-primary)'
|
||||
}}
|
||||
@@ -329,7 +390,7 @@ function DogDetail() {
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ function initDatabase() {
|
||||
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')),
|
||||
@@ -34,10 +37,16 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 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,7 +60,7 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Breeding Records ────────────────────────────────────────────────
|
||||
// ── Breeding Records ─────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS breeding_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -68,7 +77,7 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Litters ─────────────────────────────────────────────────────────
|
||||
// ── Litters ──────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS litters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -89,19 +98,79 @@ 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,
|
||||
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');
|
||||
|
||||
@@ -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
158
server/routes/genetics.js
Normal 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;
|
||||
@@ -2,6 +2,45 @@ 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 {
|
||||
@@ -11,23 +50,65 @@ router.get('/dog/:dogId', (req, res) => {
|
||||
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,14 +151,26 @@ 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 = ?
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user