-
Direct Relation — High Inbreeding Risk
-
{result.directRelation}
+
+
+ Simulation Result
+
+
+
+ {result.coi < 0.0625
+ ?
+ :
+ }
+
+
+ {(result.coi * 100).toFixed(2)}%
+
+
+ COI — {coiLabel(result.coi)}
+
+
+
+
+ {result.common_ancestors && result.common_ancestors.length > 0 && (
+
+
+ Common Ancestors ({result.common_ancestors.length})
+
+
+ {result.common_ancestors.map((a, i) => (
+ {a}
+ ))}
)}
- {/* COI Summary */}
-
-
-
-
Pairing
-
- {result.sire.name}
- ×
- {result.dam.name}
-
-
-
-
COI
-
- {result.coi.toFixed(2)}%
-
-
+ {result.recommendation && (
+
+ {result.recommendation}
-
-
-
-
-
-
- COI Guide: <5% Low risk · 5–10% Moderate risk · >10% High risk
-
-
-
- {/* Common Ancestors */}
-
-
-
-
Common Ancestors
-
- {result.commonAncestors.length} found
-
-
-
- {result.commonAncestors.length === 0 ? (
-
- No common ancestors found within 6 generations. This pairing has excellent genetic diversity.
-
- ) : (
-
-
-
-
- | Ancestor |
- Sire Gen |
- Dam Gen |
-
-
-
- {result.commonAncestors.map((anc, i) => (
-
- | {anc.name} |
-
- Gen {anc.sireGen}
- |
-
- Gen {anc.damGen}
- |
-
- ))}
-
-
-
- )}
-
+ )}
)}
diff --git a/server/db/init.js b/server/db/init.js
index 4fd9d2c..b8d4fe2 100644
--- a/server/db/init.js
+++ b/server/db/init.js
@@ -13,33 +13,35 @@ function initDatabase() {
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
- // ── Dogs ────────────────────────────────────────────────────────────
+ // ── 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,
- 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'))
+ 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,
+ is_external 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 columns if missing (safe on existing DBs)
const dogMigrations = [
['is_champion', 'INTEGER DEFAULT 0'],
+ ['is_external', 'INTEGER DEFAULT 0'],
['chic_number', 'TEXT'],
['age_at_death', 'TEXT'],
['cause_of_death', 'TEXT'],
@@ -48,7 +50,7 @@ function initDatabase() {
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
- // ── Parents ─────────────────────────────────────────────────────────
+ // ── Parents ──────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -60,7 +62,7 @@ function initDatabase() {
)
`);
- // ── Breeding Records ─────────────────────────────────────────────────
+ // ── Breeding Records ─────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS breeding_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -77,34 +79,28 @@ function initDatabase() {
)
`);
- // ── Litters ──────────────────────────────────────────────────────────
+ // ── Litters ──────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS litters (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- breeding_id INTEGER,
- sire_id INTEGER NOT NULL,
- dam_id INTEGER NOT NULL,
- whelp_date TEXT,
- total_count INTEGER DEFAULT 0,
- male_count INTEGER DEFAULT 0,
- female_count INTEGER DEFAULT 0,
- stillborn_count INTEGER DEFAULT 0,
- notes TEXT,
- created_at TEXT DEFAULT (datetime('now')),
- updated_at TEXT DEFAULT (datetime('now')),
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ breeding_id INTEGER,
+ sire_id INTEGER NOT NULL,
+ dam_id INTEGER NOT NULL,
+ whelp_date TEXT,
+ total_count INTEGER DEFAULT 0,
+ male_count INTEGER DEFAULT 0,
+ female_count INTEGER DEFAULT 0,
+ stillborn_count INTEGER DEFAULT 0,
+ notes TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
FOREIGN KEY (sire_id) REFERENCES dogs(id),
FOREIGN KEY (dam_id) REFERENCES dogs(id)
)
`);
- // ── 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
+ // ── Health Records (OFA-extended) ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -130,7 +126,7 @@ function initDatabase() {
// migrate: add OFA-specific columns if missing (covers existing DBs)
const healthMigrations = [
- ['test_type', 'TEXT'],
+ ['test_type', 'TEXT'],
['ofa_result', 'TEXT'],
['ofa_number', 'TEXT'],
['performed_by', 'TEXT'],
@@ -144,10 +140,7 @@ function initDatabase() {
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
+ // ── Genetic Tests (DNA Panel) ──────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS genetic_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -164,23 +157,23 @@ function initDatabase() {
)
`);
- // ── Cancer History ───────────────────────────────────────────────────
+ // ── 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')),
+ 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)
)
`);
- // ── Settings ─────────────────────────────────────────────────────────
+ // ── Settings ──────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/server/routes/dogs.js b/server/routes/dogs.js
index 575924f..9df96f6 100644
--- a/server/routes/dogs.js
+++ b/server/routes/dogs.js
@@ -31,15 +31,67 @@ const upload = multer({
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
-// ── Shared SELECT columns ────────────────────────────────────────────
+// ── Shared SELECT columns ────────────────────────────────────────────────
const DOG_COLS = `
id, name, registration_number, breed, sex, birth_date,
color, microchip, photo_urls, notes, litter_id, is_active,
- is_champion, created_at, updated_at
+ is_champion, is_external, created_at, updated_at
`;
-// ── GET all dogs ─────────────────────────────────────────────────────
+// ── Helper: attach parents to a list of dogs ─────────────────────────────
+function attachParents(db, dogs) {
+ const parentStmt = db.prepare(`
+ SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
+ FROM parents p
+ JOIN dogs d ON p.parent_id = d.id
+ WHERE p.dog_id = ?
+ `);
+ dogs.forEach(dog => {
+ dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
+ const parents = parentStmt.all(dog.id);
+ dog.sire = parents.find(p => p.parent_type === 'sire') || null;
+ dog.dam = parents.find(p => p.parent_type === 'dam') || null;
+ });
+ return dogs;
+}
+
+// ── GET dogs
+// Default: kennel dogs only (is_external = 0)
+// ?include_external=1 : all active dogs (kennel + external)
+// ?external_only=1 : external dogs only
+// ─────────────────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
+ try {
+ const db = getDatabase();
+ const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
+ const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
+
+ let whereClause;
+ if (externalOnly) {
+ whereClause = 'WHERE is_active = 1 AND is_external = 1';
+ } else if (includeExternal) {
+ whereClause = 'WHERE is_active = 1';
+ } else {
+ whereClause = 'WHERE is_active = 1 AND is_external = 0';
+ }
+
+ const dogs = db.prepare(`
+ SELECT ${DOG_COLS}
+ FROM dogs
+ ${whereClause}
+ ORDER BY name
+ `).all();
+
+ res.json(attachParents(db, dogs));
+ } catch (error) {
+ console.error('Error fetching dogs:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ──────────
+// Kept for backwards-compat; equivalent to GET /?include_external=1
+router.get('/all', (req, res) => {
try {
const db = getDatabase();
const dogs = db.prepare(`
@@ -48,29 +100,32 @@ router.get('/', (req, res) => {
WHERE is_active = 1
ORDER BY name
`).all();
-
- const parentStmt = db.prepare(`
- SELECT p.parent_type, d.id, d.name, d.is_champion
- FROM parents p
- JOIN dogs d ON p.parent_id = d.id
- WHERE p.dog_id = ?
- `);
-
- dogs.forEach(dog => {
- dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
- const parents = parentStmt.all(dog.id);
- dog.sire = parents.find(p => p.parent_type === 'sire') || null;
- dog.dam = parents.find(p => p.parent_type === 'dam') || null;
- });
-
- res.json(dogs);
+ res.json(attachParents(db, dogs));
} catch (error) {
- console.error('Error fetching dogs:', error);
+ console.error('Error fetching all dogs:', error);
res.status(500).json({ error: error.message });
}
});
-// ── GET single dog (with parents + offspring) ────────────────────────
+// ── GET external dogs only (is_external = 1) ──────────────────────────────
+// Kept for backwards-compat; equivalent to GET /?external_only=1
+router.get('/external', (req, res) => {
+ try {
+ const db = getDatabase();
+ const dogs = db.prepare(`
+ SELECT ${DOG_COLS}
+ FROM dogs
+ WHERE is_active = 1 AND is_external = 1
+ ORDER BY name
+ `).all();
+ res.json(attachParents(db, dogs));
+ } catch (error) {
+ console.error('Error fetching external dogs:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// ── GET single dog (with parents + offspring) ──────────────────────────
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
@@ -81,7 +136,7 @@ router.get('/:id', (req, res) => {
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const parents = db.prepare(`
- SELECT p.parent_type, d.id, d.name, d.is_champion
+ SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
FROM parents p
JOIN dogs d ON p.parent_id = d.id
WHERE p.dog_id = ?
@@ -91,7 +146,7 @@ router.get('/:id', (req, res) => {
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
dog.offspring = db.prepare(`
- SELECT d.id, d.name, d.sex, d.is_champion
+ SELECT d.id, d.name, d.sex, d.is_champion, d.is_external
FROM dogs d
JOIN parents p ON d.id = p.dog_id
WHERE p.parent_id = ? AND d.is_active = 1
@@ -104,13 +159,11 @@ router.get('/:id', (req, res) => {
}
});
-// ── POST create dog ──────────────────────────────────────────────────
+// ── POST create dog ─────────────────────────────────────────────────────
router.post('/', (req, res) => {
try {
const { name, registration_number, breed, sex, birth_date, color,
- microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
-
- console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion });
+ microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
if (!name || !breed || !sex) {
return res.status(400).json({ error: 'Name, breed, and sex are required' });
@@ -119,8 +172,8 @@ router.post('/', (req, res) => {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
- microchip, notes, litter_id, photo_urls, is_champion)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ microchip, notes, litter_id, photo_urls, is_champion, is_external)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
emptyToNull(registration_number),
@@ -131,11 +184,11 @@ router.post('/', (req, res) => {
emptyToNull(notes),
emptyToNull(litter_id),
'[]',
- is_champion ? 1 : 0
+ is_champion ? 1 : 0,
+ is_external ? 1 : 0
);
const dogId = result.lastInsertRowid;
- console.log(`✔ Dog inserted with ID: ${dogId}`);
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
@@ -147,7 +200,7 @@ router.post('/', (req, res) => {
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
dog.photo_urls = [];
- console.log(`✔ Dog created: ${dog.name} (ID: ${dogId})`);
+ console.log(`✔ Dog created: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`);
res.status(201).json(dog);
} catch (error) {
console.error('Error creating dog:', error);
@@ -155,20 +208,18 @@ router.post('/', (req, res) => {
}
});
-// ── PUT update dog ───────────────────────────────────────────────────
+// ── PUT update dog ───────────────────────────────────────────────────────
router.put('/:id', (req, res) => {
try {
const { name, registration_number, breed, sex, birth_date, color,
- microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
-
- console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion });
+ microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
const db = getDatabase();
db.prepare(`
UPDATE dogs
SET name = ?, registration_number = ?, breed = ?, sex = ?,
birth_date = ?, color = ?, microchip = ?, notes = ?,
- litter_id = ?, is_champion = ?, updated_at = datetime('now')
+ litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
name,
@@ -180,6 +231,7 @@ router.put('/:id', (req, res) => {
emptyToNull(notes),
emptyToNull(litter_id),
is_champion ? 1 : 0,
+ is_external ? 1 : 0,
req.params.id
);
@@ -202,10 +254,7 @@ router.put('/:id', (req, res) => {
}
});
-// ── DELETE dog (hard delete with cascade) ────────────────────────────
-// Removes: parent relationships (both directions), health records,
-// heat cycles, and the dog record itself.
-// Photo files on disk are NOT removed here — run a gc job if needed.
+// ── DELETE dog (hard delete with cascade) ───────────────────────────────
router.delete('/:id', (req, res) => {
try {
const db = getDatabase();
@@ -213,13 +262,11 @@ router.delete('/:id', (req, res) => {
if (!existing) return res.status(404).json({ error: 'Dog not found' });
const id = req.params.id;
-
- // Cascade cleanup
- db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); // remove as parent
- db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); // remove own parents
- db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
- db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
- db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
+ db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
+ db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
+ db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
+ db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
+ db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
res.json({ success: true, message: `${existing.name} has been deleted` });
@@ -229,7 +276,7 @@ router.delete('/:id', (req, res) => {
}
});
-// ── POST upload photo ────────────────────────────────────────────────
+// ── POST upload photo ────────────────────────────────────────────────────
router.post('/:id/photos', upload.single('photo'), (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
@@ -249,7 +296,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
}
});
-// ── DELETE photo ─────────────────────────────────────────────────────
+// ── DELETE photo ──────────────────────────────────────────────────────
router.delete('/:id/photos/:photoIndex', (req, res) => {
try {
const db = getDatabase();