From 683fef7e9c687e18addaa24d7098a0d6492591c3 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:00:40 -0500 Subject: [PATCH 01/15] feat: add is_champion column to dogs table and settings table --- server/db/init.js | 176 +--------------------------------------------- 1 file changed, 1 insertion(+), 175 deletions(-) diff --git a/server/db/init.js b/server/db/init.js index 7b74afc..0228cd4 100644 --- a/server/db/init.js +++ b/server/db/init.js @@ -1,175 +1 @@ -const Database = require('better-sqlite3'); -const path = require('path'); -const fs = require('fs'); - -function initDatabase(dbPath) { - // Ensure data directory exists - const dir = path.dirname(dbPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - const db = new Database(dbPath); - - // Enable foreign keys - db.pragma('foreign_keys = ON'); - - console.log('Initializing database schema...'); - - // Dogs table - NO sire/dam columns, only litter_id - db.exec(` - CREATE TABLE IF NOT EXISTS dogs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - registration_number TEXT UNIQUE, - breed TEXT NOT NULL, - sex TEXT NOT NULL CHECK(sex IN ('male', 'female')), - birth_date DATE, - color TEXT, - microchip TEXT, - photo_urls TEXT, -- JSON array of photo URLs - notes TEXT, - litter_id INTEGER, - is_active INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL - ) - `); - - // Create unique index for microchip that allows NULL values - db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip - ON dogs(microchip) - WHERE microchip IS NOT NULL - `); - - // Parents table - Stores sire/dam relationships - db.exec(` - CREATE TABLE IF NOT EXISTS parents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - parent_id INTEGER NOT NULL, - parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')), - FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE, - FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE, - UNIQUE(dog_id, parent_type) - ) - `); - - // Litters table - Breeding records - db.exec(` - CREATE TABLE IF NOT EXISTS litters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sire_id INTEGER NOT NULL, - dam_id INTEGER NOT NULL, - breeding_date DATE NOT NULL, - whelping_date DATE, - puppy_count INTEGER DEFAULT 0, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (sire_id) REFERENCES dogs(id) ON DELETE CASCADE, - FOREIGN KEY (dam_id) REFERENCES dogs(id) ON DELETE CASCADE - ) - `); - - // Health records table - db.exec(` - CREATE TABLE IF NOT EXISTS health_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - record_type TEXT NOT NULL CHECK(record_type IN ('test', 'vaccination', 'exam', 'treatment', 'certification')), - test_name TEXT, - test_date DATE NOT NULL, - result TEXT, - document_url TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE - ) - `); - - // Heat cycles table - db.exec(` - CREATE TABLE IF NOT EXISTS heat_cycles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - start_date DATE NOT NULL, - end_date DATE, - progesterone_peak_date DATE, - breeding_date DATE, - breeding_successful INTEGER DEFAULT 0, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE - ) - `); - - // Traits table - Genetic trait tracking - db.exec(` - CREATE TABLE IF NOT EXISTS traits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - trait_category TEXT NOT NULL, - trait_name TEXT NOT NULL, - trait_value TEXT NOT NULL, - inherited_from INTEGER, - notes TEXT, - FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE, - FOREIGN KEY (inherited_from) REFERENCES dogs(id) ON DELETE SET NULL - ) - `); - - // Create indexes for performance - db.exec(` - CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name); - CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number); - CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id); - CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id); - CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id); - CREATE INDEX IF NOT EXISTS idx_litters_sire ON litters(sire_id); - CREATE INDEX IF NOT EXISTS idx_litters_dam ON litters(dam_id); - CREATE INDEX IF NOT EXISTS idx_health_dog ON health_records(dog_id); - CREATE INDEX IF NOT EXISTS idx_heat_dog ON heat_cycles(dog_id); - CREATE INDEX IF NOT EXISTS idx_traits_dog ON traits(dog_id); - `); - - // Create trigger for updated_at - db.exec(` - CREATE TRIGGER IF NOT EXISTS update_dogs_timestamp - AFTER UPDATE ON dogs - FOR EACH ROW - BEGIN - UPDATE dogs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - `); - - console.log('✓ Database schema initialized successfully!'); - console.log('✓ Dogs table: NO sire/dam columns, uses parents table'); - console.log('✓ Parents table: Stores sire/dam relationships'); - console.log('✓ Litters table: Links puppies via litter_id'); - - db.close(); - return true; -} - -function getDatabase() { - const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db'); - const db = new Database(dbPath); - db.pragma('foreign_keys = ON'); - return db; -} - -module.exports = { initDatabase, getDatabase }; - -// Run initialization if called directly -if (require.main === module) { - const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db'); - console.log('\n=========================================='); - console.log('BREEDR Database Initialization'); - console.log('=========================================='); - console.log(`Database: ${dbPath}`); - console.log('==========================================\n'); - initDatabase(dbPath); - console.log('\n✓ Database ready!\n'); -} +Y29uc3QgRGF0YWJhc2UgPSByZXF1aXJlKCdiZXR0ZXItc3FsaXRlMycpOwpjb25zdCBwYXRoID0gcmVxdWlyZSgncGF0aCcpOwpjb25zdCBmcyA9IHJlcXVpcmUoJ2ZzJyk7CgpmdW5jdGlvbiBpbml0RGF0YWJhc2UoZGJQYXRoKSB7CiAgY29uc3QgZGlyID0gcGF0aC5kaXJuYW1lKGRiUGF0aCk7CiAgaWYgKCFmcy5leGlzdHNTeW5jKGRpcikpIHsKICAgIGZzLm1rZGlyU3luYyhkaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwogIH0KCiAgY29uc3QgZGIgPSBuZXcgRGF0YWJhc2UoZGJQYXRoKTsKICBkYi5wcmFnbWEoJ2ZvcmVpZ25fa2V5cyA9IE9OJyk7CiAgY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZSBzY2hlbWEuLi4nKTsKCiAgLy8gRG9ncyB0YWJsZSAtIGluY2x1ZGVzIGlzX2NoYW1waW9uIGZsYWcKICBkYi5leGVjKGAKICAgIENSRUFURSBUQUJMRSBJRiBOT1QgRVhJU1RTIGRvZ3MgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIG5hbWUgVEVYVCBOT1QgTlVMTCwKICAgICAgcmVnaXN0cmF0aW9uX251bWJlciBURVhUIFVOSVFVRSwKICAgICAgYnJlZWQgVEVYVCBOT1QgTlVMTCwKICAgICAgc2V4IFRFWFQgTk9UIE5VTEwgQ0hFQ0soc2V4IElOICgnbWFsZScsICdmZW1hbGUnKSksCiAgICAgIGJpcnRoX2RhdGUgREFURSwKICAgICAgY29sb3IgVEVYVCwKICAgICAgbWljcm9jaGlwIFRFWFQsCiAgICAgIHBob3RvX3VybHMgVEVYVCwKICAgICAgbm90ZXMgVEVYVCwKICAgICAgbGl0dGVyX2lkIElOVEVHRVIsCiAgICAgIGlzX2FjdGl2ZSBJTlRFR0VSIERFRkFVTFQgMSwKICAgICAgaXNfY2hhbXBpb24gSU5URUdFUiBERUZBVUxUIDAsCiAgICAgIGNyZWF0ZWRfYXQgREFURVRJTUUgREVGQVVMVCBDVVJSRU5UX1RJTUVTVEFNUCwKICAgICAgdXBkYXRlZF9hdCBEQVRFVElNRSBERUZBVUxUIENVUlJFTlRfVElNRVNUQU1QLAogICAgICBGT1JFSUdOIEtFWSAobGl0dGVyX2lkKSBSRUZFUkVOQ0VTIGxpdHRlcnMoaWQpIE9OIERFTEVURSBTRVQgTlVMTAogICAgKQogIGApOwoKICAvLyBTZXR0aW5ncyB0YWJsZSAtIGtlbm5lbCBuYW1lICsgaW5mbwogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgc2V0dGluZ3MgKAogICAgICBrZXkgVEVYVCBQUklNQVJZIEtFWSwKICAgICAgdmFsdWUgVEVYVAogICAgKQogIGApOwoKICAvLyBJbnNlcnQgZGVmYXVsdHMgaWYgbm90IHByZXNlbnQKICBkYi5wcmVwYXJlKGBJTlNFUlQgT1IgSUdOT1JFIElOVE8gc2V0dGluZ3MgKGtleSwgdmFsdWUpIFZBTFVFUyAoPywgPylgKS5ydW4oJ2tlbm5lbF9uYW1lJywgJ015IEtlbm5lbCcpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX3Bob25lJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX2VtYWlsJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX2xvY2F0aW9uJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX25vdGVzJywgJycpOwoKICAvLyBNaWdyYXRpb246IGFkZCBpc19jaGFtcGlvbiBpZiBtaXNzaW5nIG9uIGV4aXN0aW5nIERCcwogIHRyeSB7CiAgICBkYi5leGVjKCdBTFRFUiBUQUJMRSBkb2dzIEFERCBDT0xVTU4gaXNfY2hhbXBpb24gSU5URUdFUiBERUZBVUxUIDAeKTsKICB9IGNhdGNoIChlKSB7CiAgICAvLyBDb2x1bW4gYWxyZWFkeSBleGlzdHMgLSBpZ25vcmUKICB9CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFVOSVFVRSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9kb2dzX21pY3JvY2hpcAogICAgT04gZG9ncyhtaWNyb2NoaXApCiAgICBXSEVSRSBtaWNyb2NoaXAgSVMgTk9UIE5VTEwKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyBwYXJlbnRzICgKICAgICAgaWQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPSU5DUkVNRU5ULAogICAgICBkb2dfaWQgSU5URUdFUiBOT1QgTlVMTCwKICAgICAgcGFyZW50X2lkIElOVEVHRVIgTk9UIE5VTEwsCiAgICAgIHBhcmVudF90eXBlIFRFWFQgTk9UIE5VTEwgQ0hFQ0socGFyZW50X3R5cGUgSU4gKCdzaXJlJywgJ2RhbScpKSwKICAgICAgRk9SRUlHTiBLRVkgKGRvZ19pZCkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICAgICAgRk9SRUlHTiBLRVkgKHBhcmVudF9pZCkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICAgICAgVU5JUVVFKGRvZ19pZCwgcGFyZW50X3R5cGUpCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgbGl0dGVycyAoCiAgICAgIGlkIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT0lOQ1JFTUVOVCwKICAgICAgc2lyZV9pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICBkYW1faWQgSU5URUdFUiBOT1QgTlVMTCwKICAgICAgYnJlZWRpbmdfZGF0ZSBEQVRFIE5PVCBOVUxMLAogICAgICB3aGVscGluZ19kYXRlIERBVEUsCiAgICAgIHB1cHB5X2NvdW50IElOVEVHRVIgREVGQVVMVCAwLAogICAgICBub3RlcyBURVhULAogICAgICBjcmVhdGVkX2F0IERBVEVUSU1FIERFRkFVTFQgQ1VSUkVOVF9USU1FU1RBTVAsCiAgICAgIEZPUkVJR04gS0VZIChzaXJlX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFLAogICAgICBGT1JFSUdOIEtFWSAoZGFtX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgaGVhbHRoX3JlY29yZHMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICByZWNvcmRfdHlwZSBURVhUIE5PVCBOVUxMIENIRUNLKHJlY29yZF90eXBlIElOICgndGVzdCcsICd2YWNjaW5hdGlvbicsICdleGFtJywgJ3RyZWF0bWVudCcsICdjZXJ0aWZpY2F0aW9uJykpLAogICAgICB0ZXN0X25hbWUgVEVYVCwKICAgICAgdGVzdF9kYXRlIERBVEUgTk9UIE5VTEwsCiAgICAgIHJlc3VsdCBURVhULAogICAgICBkb2N1bWVudF91cmwgVEVYVCwKICAgICAgbm90ZXMgVEVYVCwKICAgICAgY3JlYXRlZF9hdCBEQVRFVElNRSBERUZBVUxUIENVUlJFTlRfVElNRVNUQU1QLAogICAgICBGT1JFSUdOIEtFWSAoZG9nX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgaGVhdF9jeWNsZXMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICBzdGFydF9kYXRlIERBVEUgTk9UIE5VTEwsCiAgICAgIGVuZF9kYXRlIERBVEUsCiAgICAgIHByb2dlc3Rlcm9uZV9wZWFrX2RhdGUgREFURSwKICAgICAgYnJlZWRpbmdfZGF0ZSBEQVRFLAogICAgICBicmVlZGluZ19zdWNjZXNzZnVsIElOVEVHRVIgREVGQVVMVCAwLAogICAgICBub3RlcyBURVhULAogICAgICBjcmVhdGVkX2F0IERBVEVUSU1FIERFRkFVTFQgQ1VSUkVOVF9USU1FU1RBTVAsCiAgICAgIEZPUkVJR04gS0VZIChkb2dfaWQpIFJFRkVSRU5DRVMgZG9ncyhpZCkgT04gREVMRVRFIENBU0NBREUKICAgICkKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyB0cmFpdHMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICB0cmFpdF9jYXRlZ29yeSBURVhUIE5PVCBOVUxMLAogICAgICB0cmFpdF9uYW1lIFRFWFQgTk9UIE5VTEwsCiAgICAgIHRyYWl0X3ZhbHVlIFRFWFQgTk9UIE5VTEwsCiAgICAgIGluaGVyaXRlZF9mcm9tIElOVEVHRVIsCiAgICAgIG5vdGVzIFRFWFQsCiAgICAgIEZPUkVJR04gS0VZIChkb2dfaWQpIFJFRkVSRU5DRVMgZG9ncyhpZCkgT04gREVMRVRFIENBU0NBREUsCiAgICAgIEZPUkVJR04gS0VZIChpbmhlcml0ZWRfZnJvbSkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgU0VUIE5VTEwKICAgICkKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfZG9nc19uYW1lIE9OIGRvZ3MobmFtZSk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfZG9nc19yZWdpc3RyYXRpb24gT04gZG9ncyhyZWdpc3RyYXRpb25fbnVtYmVyKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9kb2dzX2xpdHRlciBPTiBkb2dzKGxpdHRlcl9pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfcGFyZW50c19kb2cgT04gcGFyZW50cyhkb2dfaWQpOwogICAgQ1JFQVRFIElOREVYIElGIE5PVCBFWElTVFMgaWR4X3BhcmVudHNfcGFyZW50IE9OIHBhcmVudHMocGFyZW50X2lkKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9saXR0ZXJzX3NpcmUgT04gbGl0dGVycyhzaXJlX2lkKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9saXR0ZXJzX2RhbSBPTiBsaXR0ZXJzKGRhbV9pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfaGVhbHRoX2RvZyBPTiBoZWFsdGhfcmVjb3Jkcyhkb2dfaWQpOwogICAgQ1JFQVRFIElOREVYIElGIE5PVCBFWElTVFMgaWR4X2hlYXRfZG9nIE9OIGhlYXRfY3ljbGVzKGRvZ19pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfdHJhaXRzX2RvZyBPTiB0cmFpdHMoZG9nX2lkKTsKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVFJJR0dFUiBJRiBOT1QgRVhJU1RTIHVwZGF0ZV9kb2dzX3RpbWVzdGFtcAogICAgQUZURVIgVVBEQVRFIE9OIGRvZ3MKICAgIEZPUiBFQUNIIFJPVwogICAgQkVHSU4KICAgICAgVVBEQVRFIGRvZ3MgU0VUIHVwZGF0ZWRfYXQgPSBDVVJSRU5UX1RJTUVTVEFNUCBXSEVSRSBpZCA9IE5FVy5pZDsKICAgIEVORDsKICBgKTsKCiAgY29uc29sZS5sb2coJ+KckyBEYXRhYmFzZSBzY2hlbWEgaW5pdGlhbGl6ZWQgc3VjY2Vzc2Z1bGx5IScpOwogIGNvbnNvbGUubG9nKCfinJMgRG9ncyB0YWJsZTogaXNfY2hhbXBpb24gY29sdW1uIGluY2x1ZGVkJyk7CiAgY29uc29sZS5sb2coJ+KckSBTZXR0aW5ncyB0YWJsZTogS2VubmVsIG5hbWUgYW5kIGluZm8gc3VwcG9ydGVkJyk7CgogIGRiLmNsb3NlKCk7CiAgcmV0dXJuIHRydWU7Cn0KCmZ1bmN0aW9uIGdldERhdGFiYXNlKCkgewogIGNvbnN0IGRiUGF0aCA9IHByb2Nlc3MuZW52LkRCX1BBVEggfHwgcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uLy4uL2RhdGEvYnJlZWRyLmRiJyk7CiAgY29uc3QgZGIgPSBuZXcgRGF0YWJhc2UoZGJQYXRoKTsKICBkYi5wcmFnbWEoJ2ZvcmVpZ25fa2V5cyA9IE9OJyk7CiAgcmV0dXJuIGRiOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgaW5pdERhdGFiYXNlLCBnZXREYXRhYmFzZSB9OwoKaWYgKHJlcXVpcmUubWFpbiA9PT0gbW9kdWxlKSB7CiAgY29uc3QgZGJQYXRoID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vLi4vZGF0YS9icmVlZHIuZGInKTsKICBjb25zb2xlLmxvZygnXG49PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PScpOwogIGNvbnNvbGUubG9nKCdCUkVFRFIgRGF0YWJhc2UgSW5pdGlhbGl6YXRpb24nKTsKICBjb25zb2xlLmxvZygnPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09Jyk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke2RiUGF0aH1gKTsKICBjb25zb2xlLmxvZygnPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG4nKTsKICBpbml0RGF0YWJhc2UoZGJQYXRoKTsKICBjb25zb2xlLmxvZygnXG7inJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKfQo= \ No newline at end of file From 6ce9aebabd142522ae3d7fa3fff3ef3315ed302f Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:01:12 -0500 Subject: [PATCH 02/15] feat: add settings API route for kennel info --- server/routes/settings.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 server/routes/settings.js diff --git a/server/routes/settings.js b/server/routes/settings.js new file mode 100644 index 0000000..202ade4 --- /dev/null +++ b/server/routes/settings.js @@ -0,0 +1 @@ +Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3Qgcm91dGVyID0gZXhwcmVzcy5Sb3V0ZXIoKTsKY29uc3QgeyBnZXREYXRhYmFzZSB9ID0gcmVxdWlyZSgnLi4vZGIvaW5pdCcpOwoKLy8gR0VUIGFsbCBzZXR0aW5ncwpyb3V0ZXIuZ2V0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHJvd3MgPSBkYi5wcmVwYXJlKCdTRUxFQ1Qga2V5LCB2YWx1ZSBGUk9NIHNldHRpbmdzJykuYWxsKCk7CiAgICBjb25zdCBzZXR0aW5ncyA9IHt9OwogICAgcm93cy5mb3JFYWNoKHIgPT4geyBzZXR0aW5nc1tyLmtleV0gPSByLnZhbHVlOyB9KTsKICAgIHJlcy5qc29uKHNldHRpbmdzKTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKLy8gUFVUIHVwZGF0ZSBzZXR0aW5ncwpyb3V0ZXIucHV0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHVwc2VydCA9IGRiLnByZXBhcmUoJ0lOU0VSVCBJTlRPIHNldHRpbmdzIChrZXksIHZhbHVlKSBWQUxVRVMgKD8sID8pIE9OIENPTUZMSUNIVCBLRVBVUERBVEU9ZXhjbHVkZWQudmFsdWUnKTsKICAgIGNvbnN0IHVwZGF0ZU1hbnkgPSBkYi50cmFuc2FjdGlvbigoZW50cmllcykgPT4gewogICAgICBmb3IgKGNvbnN0IFtrZXksIHZhbHVlXSBvZiBPYmplY3QuZW50cmllcyhlbnRyaWVzKSkgewogICAgICAgIHVwc2VydC5ydW4oa2V5LCB2YWx1ZSA9PSBudWxsID8gJycgOiBTdHJpbmcodmFsdWUpKTsKICAgICAgfQogICAgfSk7CiAgICB1cGRhdGVNYW55KHJlcS5ib2R5KTsKICAgIHJlcy5qc29uKHsgbWVzc2FnZTogJ1NldHRpbmdzIHNhdmVkJyB9KTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKbW9kdWxlLmV4cG9ydHMgPSByb3V0ZXI7Cg== \ No newline at end of file From 4f7a2ad0f991b8fe36ed7277c0525f00627f67f6 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:02:21 -0500 Subject: [PATCH 03/15] feat: wire settings route into Express server --- server/index.js | 97 +------------------------------------------------ 1 file changed, 1 insertion(+), 96 deletions(-) diff --git a/server/index.js b/server/index.js index bba3f59..b595d4c 100644 --- a/server/index.js +++ b/server/index.js @@ -1,96 +1 @@ -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const path = require('path'); -const fs = require('fs'); -const { initDatabase } = require('./db/init'); - -const app = express(); -const PORT = process.env.PORT || 3000; -const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db'); -const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads'); -const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static'); - -// Ensure directories exist -const dataDir = path.dirname(DB_PATH); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); -} -if (!fs.existsSync(UPLOAD_PATH)) { - fs.mkdirSync(UPLOAD_PATH, { recursive: true }); -} -if (!fs.existsSync(STATIC_PATH)) { - fs.mkdirSync(STATIC_PATH, { recursive: true }); -} - -// Initialize database schema (creates tables if they don't exist) -console.log('Initializing database...'); -initDatabase(DB_PATH); -console.log('✓ Database ready!\n'); - -// Middleware -app.use(helmet({ - contentSecurityPolicy: false, // Allow inline scripts for React -})); -app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Static asset routes — registered BEFORE React catch-all so they are -// resolved directly and never fall through to index.html -app.use('/uploads', express.static(UPLOAD_PATH)); -app.use('/static', express.static(STATIC_PATH)); - -// Explicit 404 for missing asset files so the catch-all never intercepts them -app.use('/uploads', (req, res) => res.status(404).json({ error: 'Upload not found' })); -app.use('/static', (req, res) => res.status(404).json({ error: 'Static asset not found' })); - -// API Routes -app.use('/api/dogs', require('./routes/dogs')); -app.use('/api/litters', require('./routes/litters')); -app.use('/api/health', require('./routes/health')); -app.use('/api/pedigree', require('./routes/pedigree')); -app.use('/api/breeding', require('./routes/breeding')); - -// Health check endpoint -app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// Serve React frontend in production -// The catch-all is intentionally placed AFTER all asset/API routes above. -// express.static(clientBuildPath) handles real build assets (JS/CSS chunks). -// The scoped '*' only fires for HTML5 client-side routes (e.g. /dogs, /litters). -if (process.env.NODE_ENV === 'production') { - const clientBuildPath = path.join(__dirname, '../client/dist'); - app.use(express.static(clientBuildPath)); - - // Only send index.html for non-asset, non-api paths - app.get(/^(?!\/(api|static|uploads)\/).*$/, (req, res) => { - res.sendFile(path.join(clientBuildPath, 'index.html')); - }); -} - -// Error handling middleware -app.use((err, req, res, next) => { - console.error('Error:', err); - res.status(err.status || 500).json({ - error: err.message || 'Internal server error', - ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) - }); -}); - -// Start server -app.listen(PORT, '0.0.0.0', () => { - console.log(`\n🐕 BREEDR Server Running`); - console.log(`==============================`); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`Port: ${PORT}`); - console.log(`Database: ${DB_PATH}`); - console.log(`Uploads: ${UPLOAD_PATH}`); - console.log(`Static: ${STATIC_PATH}`); - console.log(`Access: http://localhost:${PORT}`); - console.log(`==============================\n`); -}); - -module.exports = app; +Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgY29ycyA9IHJlcXVpcmUoJ2NvcnMnKTsKY29uc3QgaGVsbWV0ID0gcmVxdWlyZSgnaGVsbWV0Jyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGZzID0gcmVxdWlyZSgnZnMnKTsKY29uc3QgeyBpbml0RGF0YWJhc2UgfSA9IHJlcXVpcmUoJy4vZGIvaW5pdCcpOwoKY29uc3QgYXBwID0gZXhwcmVzcygpOwpjb25zdCBQT1JUID0gcHJvY2Vzcy5lbnYuUE9SVCB8fCAzMDAwOwpjb25zdCBEQl9QQVRIID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vZGF0YS9icmVlZHIuZGInKTsKY29uc3QgVVBMT0FEX1BBVEggPSBwcm9jZXNzLmVudi5VUExPQURfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vdXBsb2FkcycpOwpjb25zdCBTVEFUSUNfUEFUSCA9IHByb2Nlc3MuZW52LlNUQVRJQ19QQVRIIHx8IHBhdGguam9pbihfX2Rpcm5hbWUsICcuLi9zdGF0aWMnKTsKCmNvbnN0IGRhdGFEaXIgPSBwYXRoLmRpcm5hbWUoREJfUEFUSCk7CmlmICghZnMuZXhpc3RzU3luYyhkYXRhRGlyKSkgZnMubWtkaXJTeW5jKGRhdGFEaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoVVBMT0FEX1BBVEgpKSBmcy5ta2RpclN5bmMoVVBMT0FEX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoU1RBVElDX1BBVEgpKSBmcy5ta2RpclN5bmMoU1RBVElDX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwoKY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZS4uLicpOwppbml0RGF0YWJhc2UoREJfUEFUSCk7CmNvbnNvbGUubG9nKCfinJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKCmFwcC51c2UoaGVsbWV0KHsgY29udGVudFNlY3VyaXR5UG9saWN5OiBmYWxzZSB9KSk7CmFwcC51c2UoY29ycygpKTsKYXBwLnVzZShleHByZXNzLmpzb24oKSk7CmFwcC51c2UoZXhwcmVzcy51cmxlbmNvZGVkKHsgZXh0ZW5kZWQ6IHRydWUgfSkpOwoKYXBwLnVzZSgnL3VwbG9hZHMnLCBleHByZXNzLnN0YXRpYyhVUExPQURfUEFUSCkpOwphcHAudXNlKCcvc3RhdGljJywgZXhwcmVzcy5zdGF0aWMoU1RBVElDX1BBVEgpKTsKYXBwLnVzZSgnL3VwbG9hZHMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdVcGxvYWQgbm90IGZvdW5kJyB9KSk7CmFwcC51c2UoJy9zdGF0aWMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdTdGF0aWMgYXNzZXQgbm90IGZvdW5kJyB9KSk7CgphcHAudXNlKCcvYXBpL2RvZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9kb2dzJykpOwphcHAudXNlKCcvYXBpL2xpdHRlcnMnLCByZXF1aXJlKCcuL3JvdXRlcy9saXR0ZXJzJykpOwphcHAudXNlKCcvYXBpL2hlYWx0aCcsIHJlcXVpcmUoJy4vcm91dGVzL2hlYWx0aCcpKTsKYXBwLnVzZSgnL2FwaS9wZWRpZ3JlZScsIHJlcXVpcmUoJy4vcm91dGVzL3BlZGlncmVlJykpOwphcHAudXNlKCcvYXBpL2JyZWVkaW5nJywgcmVxdWlyZSgnLi9yb3V0ZXMvYnJlZWRpbmcnKSk7CmFwcC51c2UoJy9hcGkvc2V0dGluZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9zZXR0aW5ncycpKTsKCmFwcC5nZXQoJy9hcGkvaGVhbHRoJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBzdGF0dXM6ICdvaycsIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpIH0pOwp9KTsKCmlmIChwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ3Byb2R1Y3Rpb24nKSB7CiAgY29uc3QgY2xpZW50QnVpbGRQYXRoID0gcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uL2NsaWVudC9kaXN0Jyk7CiAgYXBwLnVzZShleHByZXNzLnN0YXRpYyhjbGllbnRCdWlsZFBhdGgpKTsKICBhcHAuZ2V0KC9eKD8hXC8oYXBpfHN0YXRpY3x1cGxvYWRzKVwvKS4qJC8sIChyZXEsIHJlcykgPT4gewogICAgcmVzLnNlbmRGaWxlKHBhdGguam9pbihjbGllbnRCdWlsZFBhdGgsICdpbmRleC5odG1sJykpOwogIH0pOwp9CgphcHAudXNlKChlcnIsIHJlcSwgcmVzLCBuZXh0KSA9PiB7CiAgY29uc29sZS5lcnJvcignRXJyb3I6JywgZXJyKTsKICByZXMuc3RhdHVzKGVyci5zdGF0dXMgfHwgNTAwKS5qc29uKHsKICAgIGVycm9yOiBlcnIubWVzc2FnZSB8fCAnSW50ZXJuYWwgc2VydmVyIGVycm9yJywKICAgIC4uLihwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ2RldmVsb3BtZW50JyAmJiB7IHN0YWNrOiBlcnIuc3RhY2sgfSkKICB9KTsKfSk7CgphcHAubGlzdGVuKFBPUlQsICcwLjAuMC4wJywgKCkgPT4gewogIGNvbnNvbGUubG9nKGBcbvCfkJUgQlJFRURSIFNlcnZlciBSdW5uaW5nYCk7CiAgY29uc29sZS5sb2coYD09PT09PT09PT09PT09PT09PT09PT09PT09PT09PWApOwogIGNvbnNvbGUubG9nKGBFbnZpcm9ubWVudDogJHtwcm9jZXNzLmVudi5OT0RFX0VOViB8fCAnZGV2ZWxvcG1lbnQnfWApOwogIGNvbnNvbGUubG9nKGBQb3J0OiAke1BPUlR9YCk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke0RCX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFVwbG9hZHM6ICR7VVBMT0FEX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFN0YXRpYzogJHtTVEFUSUNfUEFUSH1gKTsKICBjb25zb2xlLmxvZyhgQWNjZXNzOiBodHRwOi8vbG9jYWxob3N0OiR7UE9SVH1gKTsKICBjb25zb2xlLmxvZyhgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG5gKTsKfSk7Cgptb2R1bGUuZXhwb3J0cyA9IGFwcDsK \ No newline at end of file From 9ee441ffd9c7f644b458a811a8e50e3528a9cf4b Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:14:08 -0500 Subject: [PATCH 04/15] feat: add useSettings hook for kennel settings context --- client/src/hooks/useSettings.js | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 client/src/hooks/useSettings.js diff --git a/client/src/hooks/useSettings.js b/client/src/hooks/useSettings.js new file mode 100644 index 0000000..cafb854 --- /dev/null +++ b/client/src/hooks/useSettings.js @@ -0,0 +1,36 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import axios from 'axios' + +const SettingsContext = createContext({}) + +export function SettingsProvider({ children }) { + const [settings, setSettings] = useState({ + kennel_name: 'BREEDR', + kennel_tagline: '', + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + axios.get('/api/settings') + .then(res => { + setSettings(prev => ({ ...prev, ...res.data })) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + const saveSettings = async (updates) => { + await axios.put('/api/settings', updates) + setSettings(prev => ({ ...prev, ...updates })) + } + + return ( + + {children} + + ) +} + +export function useSettings() { + return useContext(SettingsContext) +} From ec24a15c66173fc52d7ce60a82ed20728d68f9cf Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:14:33 -0500 Subject: [PATCH 05/15] feat: wrap app in SettingsProvider --- client/src/main.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index 0291fe5..91cc3dc 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,10 +1,13 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { SettingsProvider } from './hooks/useSettings' import App from './App.jsx' import './index.css' -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) \ No newline at end of file +createRoot(document.getElementById('root')).render( + + + + + , +) From 67912dc78d44123f68d64c028f3602b364230e0e Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:14:51 -0500 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20App.jsx=20=E2=80=94=20dynamic=20k?= =?UTF-8?q?ennel=20name=20in=20header,=20Settings=20nav=20link,=20useSetti?= =?UTF-8?q?ngs=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.jsx | 111 ++++++++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 51 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index bddf6bc..729ff39 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,5 +1,5 @@ -import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom' -import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react' +import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom' +import { Home, Users, Activity, Heart, FlaskConical, Settings } from 'lucide-react' import Dashboard from './pages/Dashboard' import DogList from './pages/DogList' import DogDetail from './pages/DogDetail' @@ -8,60 +8,69 @@ import LitterList from './pages/LitterList' import LitterDetail from './pages/LitterDetail' import BreedingCalendar from './pages/BreedingCalendar' import PairingSimulator from './pages/PairingSimulator' +import SettingsPage from './pages/SettingsPage' +import { useSettings } from './hooks/useSettings' import './App.css' +function NavLink({ to, icon: Icon, label }) { + const location = useLocation() + const isActive = location.pathname === to + return ( + + + {label} + + ) +} + +function AppInner() { + const { settings } = useSettings() + const kennelName = settings?.kennel_name || 'BREEDR' + + return ( +
+ + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} + function App() { return ( -
- - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
+
) } From 3e777772c377d499014a2663a2db86e51833b29d Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:15:58 -0500 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20retheme=20index.css=20=E2=80=94?= =?UTF-8?q?=20warm=20amber/copper=20palette=20to=20complement=20gold-rust?= =?UTF-8?q?=20gradient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/index.css | 120 ++++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 37 deletions(-) diff --git a/client/src/index.css b/client/src/index.css index 11f6955..c999e2d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -5,36 +5,46 @@ } :root { - /* Modern dark color palette */ - --primary: #3b82f6; - --primary-hover: #2563eb; - --primary-light: #60a5fa; - --accent: #8b5cf6; - --success: #10b981; + /* Primary accent: warm amber/copper to echo the gold-rust brand gradient */ + --primary: #c2862a; + --primary-hover: #a86e1c; + --primary-light: #e0a84a; + + /* Secondary/accent: deep copper-red for punch */ + --accent: #9b3a10; + + /* Status colors stay neutral/functional */ + --success: #22c55e; --danger: #ef4444; --warning: #f59e0b; - - /* Dark theme */ - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --bg-elevated: #1e293b; - - /* Borders */ - --border: #334155; - --border-light: #475569; - + + /* Dark theme backgrounds — slightly warmer tones */ + --bg-primary: #0e0f0c; + --bg-secondary: #1a1a15; + --bg-tertiary: #2a2820; + --bg-elevated: #222018; + + /* Borders — warm dark */ + --border: #38352a; + --border-light: #524e3e; + /* Text */ - --text-primary: #f1f5f9; - --text-secondary: #cbd5e1; - --text-muted: #94a3b8; - + --text-primary: #f5f0e8; + --text-secondary: #ccc4b0; + --text-muted: #8c8472; + /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6); - + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4); + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7); + + /* Champion badge colors */ + --champion-gold: #d4a017; + --champion-glow: rgba(212, 160, 23, 0.25); + --bloodline-amber: #b06010; + --bloodline-glow: rgba(176, 96, 16, 0.2); + /* Misc */ --radius: 0.5rem; --radius-sm: 0.375rem; @@ -130,14 +140,15 @@ h3 { font-size: 1.25rem; } } .btn-primary { - background: var(--primary); - color: white; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%); + color: var(--bg-primary); box-shadow: var(--shadow-sm); + font-weight: 600; } .btn-primary:hover:not(:disabled) { - background: var(--primary-hover); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); + background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%); + box-shadow: 0 4px 12px rgba(194, 134, 42, 0.4); } .btn-secondary { @@ -228,7 +239,7 @@ textarea:focus, select:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 3px rgba(194, 134, 42, 0.15); } .input::placeholder { @@ -243,7 +254,7 @@ textarea { select { cursor: pointer; appearance: none; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238c8472' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; @@ -308,15 +319,50 @@ select { } .badge-primary { - background: rgba(59, 130, 246, 0.2); + background: rgba(194, 134, 42, 0.2); color: var(--primary-light); } .badge-success { - background: rgba(16, 185, 129, 0.2); + background: rgba(34, 197, 94, 0.2); color: var(--success); } +/* Champion Badges */ +.badge-champion { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.55rem; + font-size: 0.7rem; + font-weight: 700; + border-radius: 9999px; + background: linear-gradient(135deg, rgba(212,160,23,0.25) 0%, rgba(155,58,16,0.2) 100%); + color: var(--champion-gold); + border: 1px solid rgba(212, 160, 23, 0.45); + box-shadow: 0 0 6px var(--champion-glow); + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.badge-bloodline { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.55rem; + font-size: 0.7rem; + font-weight: 700; + border-radius: 9999px; + background: linear-gradient(135deg, rgba(176,96,16,0.2) 0%, rgba(139,37,0,0.15) 100%); + color: var(--bloodline-amber); + border: 1px solid rgba(176, 96, 16, 0.4); + box-shadow: 0 0 6px var(--bloodline-glow); + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + /* Modal */ .modal-overlay { position: fixed; @@ -324,7 +370,7 @@ select { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.75); + background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); display: flex; align-items: center; @@ -475,9 +521,9 @@ select { } .risk-low { - background: rgba(16, 185, 129, 0.15); + background: rgba(34, 197, 94, 0.15); color: var(--success); - border: 1px solid rgba(16, 185, 129, 0.3); + border: 1px solid rgba(34, 197, 94, 0.3); } .risk-med { From 0573e154b17ff1d7822284bcf2e4f64147ce39fe Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:16:39 -0500 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20update=20App.css=20=E2=80=94=20na?= =?UTF-8?q?vbar=20active=20state=20uses=20brand=20gradient,=20settings=20i?= =?UTF-8?q?con=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.css | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index 8b2b5ac..227c5b4 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -27,16 +27,16 @@ gap: 0.75rem; color: var(--text-primary); font-weight: 700; - font-size: 2.25rem; /* +30% from 1.5rem */ + font-size: 2.25rem; text-decoration: none; transition: var(--transition); } .nav-brand:hover { - color: var(--primary-light); + opacity: 0.9; } -/* Square logo: doubled from 2.5rem to 5rem */ +/* Square logo */ .brand-logo { width: 5rem; height: 5rem; @@ -45,7 +45,6 @@ display: block; border-radius: 4px; flex-shrink: 0; - /* Subtle diffuse black drop shadow for depth */ filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30)); } @@ -58,7 +57,7 @@ height: 5rem; background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%); border-radius: var(--radius); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + box-shadow: 0 4px 12px rgba(194, 134, 42, 0.3); } /* Title gradient: medium-dark gold → rusty dark red-gold */ @@ -68,7 +67,6 @@ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; - /* text-shadow doesn't work with background-clip:text — use filter instead */ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30)); } @@ -76,6 +74,7 @@ .nav-links { display: flex; gap: 0.5rem; + align-items: center; } .nav-link { @@ -99,9 +98,22 @@ } .nav-link.active { - background: var(--primary); - color: white; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + background: linear-gradient(135deg, rgba(201,148,10,0.2) 0%, rgba(139,37,0,0.2) 100%); + color: var(--primary-light); + border-color: rgba(194, 134, 42, 0.4); + box-shadow: 0 2px 8px rgba(194, 134, 42, 0.15); +} + +/* Settings link — slightly different treatment, sits at end */ +.nav-link-settings { + margin-left: 0.5rem; + padding: 0.5rem; + border-radius: var(--radius-sm); + color: var(--text-muted); +} + +.nav-link-settings:hover { + color: var(--primary-light); } .main-content { @@ -114,10 +126,9 @@ } .nav-brand { - font-size: 1.625rem; /* +30% from 1.25rem */ + font-size: 1.625rem; } - /* Scale square logo down on mobile (doubled from 2rem) */ .brand-logo { width: 4rem; height: 4rem; From 3bc6b694f4672c5b3e2c55ee5d7c85f889fb9258 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:16:55 -0500 Subject: [PATCH 09/15] feat: add ChampionBadge and ChampionBloodlineBadge components --- client/src/components/ChampionBadge.jsx | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 client/src/components/ChampionBadge.jsx diff --git a/client/src/components/ChampionBadge.jsx b/client/src/components/ChampionBadge.jsx new file mode 100644 index 0000000..81a43c0 --- /dev/null +++ b/client/src/components/ChampionBadge.jsx @@ -0,0 +1,52 @@ +/** + * ChampionBadge — shown on dogs with is_champion = 1 + * ChampionBloodlineBadge — shown on dogs whose sire OR dam is a champion + * + * Usage: + * + * + */ + +export function ChampionBadge({ size = 'sm' }) { + return ( + + {/* Crown SVG inline — no extra dep */} + + CH + + ) +} + +export function ChampionBloodlineBadge({ size = 'sm' }) { + return ( + + {/* Droplet / bloodline SVG */} + + BL + + ) +} From ec249c786503f252f4e33742ba4591afbceb73fd Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:17:28 -0500 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20add=20SettingsPage=20=E2=80=94=20?= =?UTF-8?q?kennel=20name,=20tagline,=20address,=20phone,=20website,=20emai?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/SettingsPage.jsx | 160 ++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 client/src/pages/SettingsPage.jsx diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..9fbcc0f --- /dev/null +++ b/client/src/pages/SettingsPage.jsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react' +import { Settings, Save, CheckCircle } from 'lucide-react' +import { useSettings } from '../hooks/useSettings' + +const FIELDS = [ + { key: 'kennel_name', label: 'Kennel / App Name', placeholder: 'BREEDR', type: 'text', required: true }, + { key: 'kennel_tagline', label: 'Tagline', placeholder: 'Raising champions since...', type: 'text' }, + { key: 'kennel_address', label: 'Address', placeholder: '123 Main St, City, ST', type: 'text' }, + { key: 'kennel_phone', label: 'Phone', placeholder: '(555) 000-0000', type: 'tel' }, + { key: 'kennel_email', label: 'Email', placeholder: 'kennel@example.com', type: 'email'}, + { key: 'kennel_website', label: 'Website', placeholder: 'https://yourdomain.com', type: 'url' }, + { key: 'kennel_akc_id', label: 'AKC Kennel ID', placeholder: 'Optional', type: 'text' }, + { key: 'kennel_breed', label: 'Primary Breed', placeholder: 'e.g. Labrador Retriever', type: 'text' }, +] + +export default function SettingsPage() { + const { settings, saveSettings } = useSettings() + const [form, setForm] = useState({}) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setForm({ + kennel_name: settings.kennel_name || '', + kennel_tagline: settings.kennel_tagline || '', + kennel_address: settings.kennel_address || '', + kennel_phone: settings.kennel_phone || '', + kennel_email: settings.kennel_email || '', + kennel_website: settings.kennel_website || '', + kennel_akc_id: settings.kennel_akc_id || '', + kennel_breed: settings.kennel_breed || '', + }) + }, [settings]) + + const handleChange = (key, value) => { + setForm(prev => ({ ...prev, [key]: value })) + setSaved(false) + } + + const handleSubmit = async (e) => { + e.preventDefault() + if (!form.kennel_name?.trim()) { + setError('Kennel name is required.') + return + } + setSaving(true) + setError(null) + try { + await saveSettings(form) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch (err) { + setError('Failed to save settings. Please try again.') + } finally { + setSaving(false) + } + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Settings

+

+ Kennel profile & app configuration +

+
+
+ +
+ +
+
+

Kennel Information

+ + {error &&
{error}
} + +
+ {FIELDS.map(field => ( +
+ + handleChange(field.key, e.target.value)} + /> +
+ ))} +
+ +
+ + {/* Preview */} + {form.kennel_name && ( +
+

Header Preview

+
+ + {form.kennel_name} + + {form.kennel_tagline && ( + + — {form.kennel_tagline} + + )} +
+
+ )} + +
+ {saved && ( + + Saved! + + )} + +
+
+ +
+ ) +} From 9e699e308fc73010c9a1c2875cede12f21d36075 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:18:28 -0500 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20DogList=20=E2=80=94=20render=20Ch?= =?UTF-8?q?ampionBadge=20and=20ChampionBloodlineBadge=20on=20dog=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DogList.jsx | 147 +++++++++++++++-------------------- 1 file changed, 61 insertions(+), 86 deletions(-) diff --git a/client/src/pages/DogList.jsx b/client/src/pages/DogList.jsx index d1e6aed..1815348 100644 --- a/client/src/pages/DogList.jsx +++ b/client/src/pages/DogList.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react' import axios from 'axios' import DogForm from '../components/DogForm' +import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge' function DogList() { const [dogs, setDogs] = useState([]) @@ -12,13 +13,8 @@ function DogList() { const [loading, setLoading] = useState(true) const [showAddModal, setShowAddModal] = useState(false) - useEffect(() => { - fetchDogs() - }, []) - - useEffect(() => { - filterDogs() - }, [dogs, search, sexFilter]) + useEffect(() => { fetchDogs() }, []) + useEffect(() => { filterDogs() }, [dogs, search, sexFilter]) const fetchDogs = async () => { try { @@ -33,24 +29,19 @@ function DogList() { const filterDogs = () => { let filtered = dogs - if (search) { filtered = filtered.filter(dog => dog.name.toLowerCase().includes(search.toLowerCase()) || (dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase())) ) } - if (sexFilter !== 'all') { filtered = filtered.filter(dog => dog.sex === sexFilter) } - setFilteredDogs(filtered) } - const handleSave = () => { - fetchDogs() - } + const handleSave = () => { fetchDogs() } const calculateAge = (birthDate) => { if (!birthDate) return null @@ -58,17 +49,16 @@ function DogList() { const birth = new Date(birthDate) let years = today.getFullYear() - birth.getFullYear() let months = today.getMonth() - birth.getMonth() - - if (months < 0) { - years-- - months += 12 - } - + if (months < 0) { years--; months += 12 } if (years === 0) return `${months}mo` if (months === 0) return `${years}y` return `${years}y ${months}mo` } + // A dog has champion blood if sire or dam is a champion + const hasChampionBlood = (dog) => + (dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion) + if (loading) { return
Loading dogs...
} @@ -109,12 +99,9 @@ function DogList() { {(search || sexFilter !== 'all') && ( -
-

{dog.name}

+
+

{dog.name}

+ {isChampion && } + {hasBloodline && } +
{dog.breed} - + · {dog.sex === 'male' ? 'Male ♂' : 'Female ♀'} {dog.birth_date && ( <> - + · {calculateAge(dog.birth_date)} )} @@ -125,12 +121,12 @@ function DogDetail() {
- {/* Photo Section - Compact */} + {/* Photo Section */}

Photos

- - +
- + {dog.photo_urls && dog.photo_urls.length > 0 ? ( <> - {/* Main Photo */}
- {dog.name}
- - {/* Thumbnail Strip */} {dog.photo_urls.length > 1 && (
{dog.photo_urls.map((url, index) => ( @@ -187,9 +179,7 @@ function DogDetail() { alt={`${dog.name} ${index + 1}`} onClick={() => setSelectedPhoto(index)} style={{ - width: '60px', - height: '60px', - objectFit: 'cover', + width: '60px', height: '60px', objectFit: 'cover', borderRadius: 'var(--radius-sm)', cursor: 'pointer', border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)', @@ -213,18 +203,26 @@ function DogDetail() {

Details

-
Breed {dog.breed}
-
Sex {dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}
- +
+ Champion + + {isChampion + ? + : hasBloodline + ? + : + } + +
{dog.birth_date && (
Birth Date @@ -234,21 +232,18 @@ function DogDetail() {
)} - {dog.color && (
Color {dog.color}
)} - {dog.registration_number && (
Registration {dog.registration_number}
)} - {dog.microchip && (
Microchip @@ -265,9 +260,12 @@ function DogDetail() {
Sire
{dog.sire ? ( - - {dog.sire.name} - +
+ + {dog.sire.name} + + {dog.sire.is_champion && } +
) : ( Unknown )} @@ -275,9 +273,12 @@ function DogDetail() {
Dam
{dog.dam ? ( - - {dog.dam.name} - +
+ + {dog.dam.name} + + {dog.dam.is_champion && } +
) : ( Unknown )} @@ -301,19 +302,20 @@ function DogDetail() {

Offspring ({dog.offspring.length})

{dog.offspring.map(child => ( - { e.currentTarget.style.borderColor = 'var(--primary)' @@ -325,7 +327,10 @@ function DogDetail() { }} > {child.name} - {child.sex === 'male' ? '♂' : '♀'} +
+ {child.is_champion && } + {child.sex === 'male' ? '♂' : '♀'} +
))}
@@ -336,14 +341,11 @@ function DogDetail() { setShowEditModal(false)} - onSave={() => { - fetchDog() - setShowEditModal(false) - }} + onSave={() => { fetchDog(); setShowEditModal(false) }} /> )}
) } -export default DogDetail \ No newline at end of file +export default DogDetail From 6903e664190775527ca0fd75696196eb8e94ea23 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:23:41 -0500 Subject: [PATCH 13/15] feat(db): add is_champion to dogs, kennel settings columns, migrate existing rows --- server/db/init.js | 157 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/server/db/init.js b/server/db/init.js index 0228cd4..bc7d937 100644 --- a/server/db/init.js +++ b/server/db/init.js @@ -1 +1,156 @@ -Y29uc3QgRGF0YWJhc2UgPSByZXF1aXJlKCdiZXR0ZXItc3FsaXRlMycpOwpjb25zdCBwYXRoID0gcmVxdWlyZSgncGF0aCcpOwpjb25zdCBmcyA9IHJlcXVpcmUoJ2ZzJyk7CgpmdW5jdGlvbiBpbml0RGF0YWJhc2UoZGJQYXRoKSB7CiAgY29uc3QgZGlyID0gcGF0aC5kaXJuYW1lKGRiUGF0aCk7CiAgaWYgKCFmcy5leGlzdHNTeW5jKGRpcikpIHsKICAgIGZzLm1rZGlyU3luYyhkaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwogIH0KCiAgY29uc3QgZGIgPSBuZXcgRGF0YWJhc2UoZGJQYXRoKTsKICBkYi5wcmFnbWEoJ2ZvcmVpZ25fa2V5cyA9IE9OJyk7CiAgY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZSBzY2hlbWEuLi4nKTsKCiAgLy8gRG9ncyB0YWJsZSAtIGluY2x1ZGVzIGlzX2NoYW1waW9uIGZsYWcKICBkYi5leGVjKGAKICAgIENSRUFURSBUQUJMRSBJRiBOT1QgRVhJU1RTIGRvZ3MgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIG5hbWUgVEVYVCBOT1QgTlVMTCwKICAgICAgcmVnaXN0cmF0aW9uX251bWJlciBURVhUIFVOSVFVRSwKICAgICAgYnJlZWQgVEVYVCBOT1QgTlVMTCwKICAgICAgc2V4IFRFWFQgTk9UIE5VTEwgQ0hFQ0soc2V4IElOICgnbWFsZScsICdmZW1hbGUnKSksCiAgICAgIGJpcnRoX2RhdGUgREFURSwKICAgICAgY29sb3IgVEVYVCwKICAgICAgbWljcm9jaGlwIFRFWFQsCiAgICAgIHBob3RvX3VybHMgVEVYVCwKICAgICAgbm90ZXMgVEVYVCwKICAgICAgbGl0dGVyX2lkIElOVEVHRVIsCiAgICAgIGlzX2FjdGl2ZSBJTlRFR0VSIERFRkFVTFQgMSwKICAgICAgaXNfY2hhbXBpb24gSU5URUdFUiBERUZBVUxUIDAsCiAgICAgIGNyZWF0ZWRfYXQgREFURVRJTUUgREVGQVVMVCBDVVJSRU5UX1RJTUVTVEFNUCwKICAgICAgdXBkYXRlZF9hdCBEQVRFVElNRSBERUZBVUxUIENVUlJFTlRfVElNRVNUQU1QLAogICAgICBGT1JFSUdOIEtFWSAobGl0dGVyX2lkKSBSRUZFUkVOQ0VTIGxpdHRlcnMoaWQpIE9OIERFTEVURSBTRVQgTlVMTAogICAgKQogIGApOwoKICAvLyBTZXR0aW5ncyB0YWJsZSAtIGtlbm5lbCBuYW1lICsgaW5mbwogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgc2V0dGluZ3MgKAogICAgICBrZXkgVEVYVCBQUklNQVJZIEtFWSwKICAgICAgdmFsdWUgVEVYVAogICAgKQogIGApOwoKICAvLyBJbnNlcnQgZGVmYXVsdHMgaWYgbm90IHByZXNlbnQKICBkYi5wcmVwYXJlKGBJTlNFUlQgT1IgSUdOT1JFIElOVE8gc2V0dGluZ3MgKGtleSwgdmFsdWUpIFZBTFVFUyAoPywgPylgKS5ydW4oJ2tlbm5lbF9uYW1lJywgJ015IEtlbm5lbCcpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX3Bob25lJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX2VtYWlsJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX2xvY2F0aW9uJywgJycpOwogIGRiLnByZXBhcmUoYElOU0VSVCBPUiBJR05PUkUgSU5UTyBzZXR0aW5ncyAoa2V5LCB2YWx1ZSkgVkFMVUVTICg/LCA/KWApLnJ1bignS2VubmVsX25vdGVzJywgJycpOwoKICAvLyBNaWdyYXRpb246IGFkZCBpc19jaGFtcGlvbiBpZiBtaXNzaW5nIG9uIGV4aXN0aW5nIERCcwogIHRyeSB7CiAgICBkYi5leGVjKCdBTFRFUiBUQUJMRSBkb2dzIEFERCBDT0xVTU4gaXNfY2hhbXBpb24gSU5URUdFUiBERUZBVUxUIDAeKTsKICB9IGNhdGNoIChlKSB7CiAgICAvLyBDb2x1bW4gYWxyZWFkeSBleGlzdHMgLSBpZ25vcmUKICB9CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFVOSVFVRSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9kb2dzX21pY3JvY2hpcAogICAgT04gZG9ncyhtaWNyb2NoaXApCiAgICBXSEVSRSBtaWNyb2NoaXAgSVMgTk9UIE5VTEwKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyBwYXJlbnRzICgKICAgICAgaWQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPSU5DUkVNRU5ULAogICAgICBkb2dfaWQgSU5URUdFUiBOT1QgTlVMTCwKICAgICAgcGFyZW50X2lkIElOVEVHRVIgTk9UIE5VTEwsCiAgICAgIHBhcmVudF90eXBlIFRFWFQgTk9UIE5VTEwgQ0hFQ0socGFyZW50X3R5cGUgSU4gKCdzaXJlJywgJ2RhbScpKSwKICAgICAgRk9SRUlHTiBLRVkgKGRvZ19pZCkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICAgICAgRk9SRUlHTiBLRVkgKHBhcmVudF9pZCkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICAgICAgVU5JUVVFKGRvZ19pZCwgcGFyZW50X3R5cGUpCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgbGl0dGVycyAoCiAgICAgIGlkIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT0lOQ1JFTUVOVCwKICAgICAgc2lyZV9pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICBkYW1faWQgSU5URUdFUiBOT1QgTlVMTCwKICAgICAgYnJlZWRpbmdfZGF0ZSBEQVRFIE5PVCBOVUxMLAogICAgICB3aGVscGluZ19kYXRlIERBVEUsCiAgICAgIHB1cHB5X2NvdW50IElOVEVHRVIgREVGQVVMVCAwLAogICAgICBub3RlcyBURVhULAogICAgICBjcmVhdGVkX2F0IERBVEVUSU1FIERFRkFVTFQgQ1VSUkVOVF9USU1FU1RBTVAsCiAgICAgIEZPUkVJR04gS0VZIChzaXJlX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFLAogICAgICBGT1JFSUdOIEtFWSAoZGFtX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgaGVhbHRoX3JlY29yZHMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICByZWNvcmRfdHlwZSBURVhUIE5PVCBOVUxMIENIRUNLKHJlY29yZF90eXBlIElOICgndGVzdCcsICd2YWNjaW5hdGlvbicsICdleGFtJywgJ3RyZWF0bWVudCcsICdjZXJ0aWZpY2F0aW9uJykpLAogICAgICB0ZXN0X25hbWUgVEVYVCwKICAgICAgdGVzdF9kYXRlIERBVEUgTk9UIE5VTEwsCiAgICAgIHJlc3VsdCBURVhULAogICAgICBkb2N1bWVudF91cmwgVEVYVCwKICAgICAgbm90ZXMgVEVYVCwKICAgICAgY3JlYXRlZF9hdCBEQVRFVElNRSBERUZBVUxUIENVUlJFTlRfVElNRVNUQU1QLAogICAgICBGT1JFSUdOIEtFWSAoZG9nX2lkKSBSRUZFUkVOQ0VTIGRvZ3MoaWQpIE9OIERFTEVURSBDQVNDQURFCiAgICApCiAgYCk7CgogIGRiLmV4ZWMoYAogICAgQ1JFQVRFIFRBQkxFIElGIE5PVCBFWElTVFMgaGVhdF9jeWNsZXMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICBzdGFydF9kYXRlIERBVEUgTk9UIE5VTEwsCiAgICAgIGVuZF9kYXRlIERBVEUsCiAgICAgIHByb2dlc3Rlcm9uZV9wZWFrX2RhdGUgREFURSwKICAgICAgYnJlZWRpbmdfZGF0ZSBEQVRFLAogICAgICBicmVlZGluZ19zdWNjZXNzZnVsIElOVEVHRVIgREVGQVVMVCAwLAogICAgICBub3RlcyBURVhULAogICAgICBjcmVhdGVkX2F0IERBVEVUSU1FIERFRkFVTFQgQ1VSUkVOVF9USU1FU1RBTVAsCiAgICAgIEZPUkVJR04gS0VZIChkb2dfaWQpIFJFRkVSRU5DRVMgZG9ncyhpZCkgT04gREVMRVRFIENBU0NBREUKICAgICkKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyB0cmFpdHMgKAogICAgICBpZCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgICAgIGRvZ19pZCBJTlRFR0VSIE5PVCBOVUxMLAogICAgICB0cmFpdF9jYXRlZ29yeSBURVhUIE5PVCBOVUxMLAogICAgICB0cmFpdF9uYW1lIFRFWFQgTk9UIE5VTEwsCiAgICAgIHRyYWl0X3ZhbHVlIFRFWFQgTk9UIE5VTEwsCiAgICAgIGluaGVyaXRlZF9mcm9tIElOVEVHRVIsCiAgICAgIG5vdGVzIFRFWFQsCiAgICAgIEZPUkVJR04gS0VZIChkb2dfaWQpIFJFRkVSRU5DRVMgZG9ncyhpZCkgT04gREVMRVRFIENBU0NBREUsCiAgICAgIEZPUkVJR04gS0VZIChpbmhlcml0ZWRfZnJvbSkgUkVGRVJFTkNFUyBkb2dzKGlkKSBPTiBERUxFVEUgU0VUIE5VTEwKICAgICkKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfZG9nc19uYW1lIE9OIGRvZ3MobmFtZSk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfZG9nc19yZWdpc3RyYXRpb24gT04gZG9ncyhyZWdpc3RyYXRpb25fbnVtYmVyKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9kb2dzX2xpdHRlciBPTiBkb2dzKGxpdHRlcl9pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfcGFyZW50c19kb2cgT04gcGFyZW50cyhkb2dfaWQpOwogICAgQ1JFQVRFIElOREVYIElGIE5PVCBFWElTVFMgaWR4X3BhcmVudHNfcGFyZW50IE9OIHBhcmVudHMocGFyZW50X2lkKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9saXR0ZXJzX3NpcmUgT04gbGl0dGVycyhzaXJlX2lkKTsKICAgIENSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9saXR0ZXJzX2RhbSBPTiBsaXR0ZXJzKGRhbV9pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfaGVhbHRoX2RvZyBPTiBoZWFsdGhfcmVjb3Jkcyhkb2dfaWQpOwogICAgQ1JFQVRFIElOREVYIElGIE5PVCBFWElTVFMgaWR4X2hlYXRfZG9nIE9OIGhlYXRfY3ljbGVzKGRvZ19pZCk7CiAgICBDUkVBVEUgSU5ERVggSUYgTk9UIEVYSVNUUyBpZHhfdHJhaXRzX2RvZyBPTiB0cmFpdHMoZG9nX2lkKTsKICBgKTsKCiAgZGIuZXhlYyhgCiAgICBDUkVBVEUgVFJJR0dFUiBJRiBOT1QgRVhJU1RTIHVwZGF0ZV9kb2dzX3RpbWVzdGFtcAogICAgQUZURVIgVVBEQVRFIE9OIGRvZ3MKICAgIEZPUiBFQUNIIFJPVwogICAgQkVHSU4KICAgICAgVVBEQVRFIGRvZ3MgU0VUIHVwZGF0ZWRfYXQgPSBDVVJSRU5UX1RJTUVTVEFNUCBXSEVSRSBpZCA9IE5FVy5pZDsKICAgIEVORDsKICBgKTsKCiAgY29uc29sZS5sb2coJ+KckyBEYXRhYmFzZSBzY2hlbWEgaW5pdGlhbGl6ZWQgc3VjY2Vzc2Z1bGx5IScpOwogIGNvbnNvbGUubG9nKCfinJMgRG9ncyB0YWJsZTogaXNfY2hhbXBpb24gY29sdW1uIGluY2x1ZGVkJyk7CiAgY29uc29sZS5sb2coJ+KckSBTZXR0aW5ncyB0YWJsZTogS2VubmVsIG5hbWUgYW5kIGluZm8gc3VwcG9ydGVkJyk7CgogIGRiLmNsb3NlKCk7CiAgcmV0dXJuIHRydWU7Cn0KCmZ1bmN0aW9uIGdldERhdGFiYXNlKCkgewogIGNvbnN0IGRiUGF0aCA9IHByb2Nlc3MuZW52LkRCX1BBVEggfHwgcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uLy4uL2RhdGEvYnJlZWRyLmRiJyk7CiAgY29uc3QgZGIgPSBuZXcgRGF0YWJhc2UoZGJQYXRoKTsKICBkYi5wcmFnbWEoJ2ZvcmVpZ25fa2V5cyA9IE9OJyk7CiAgcmV0dXJuIGRiOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgaW5pdERhdGFiYXNlLCBnZXREYXRhYmFzZSB9OwoKaWYgKHJlcXVpcmUubWFpbiA9PT0gbW9kdWxlKSB7CiAgY29uc3QgZGJQYXRoID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vLi4vZGF0YS9icmVlZHIuZGInKTsKICBjb25zb2xlLmxvZygnXG49PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PScpOwogIGNvbnNvbGUubG9nKCdCUkVFRFIgRGF0YWJhc2UgSW5pdGlhbGl6YXRpb24nKTsKICBjb25zb2xlLmxvZygnPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09Jyk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke2RiUGF0aH1gKTsKICBjb25zb2xlLmxvZygnPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG4nKTsKICBpbml0RGF0YWJhc2UoZGJQYXRoKTsKICBjb25zb2xlLmxvZygnXG7inJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKfQo= \ No newline at end of file +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const dbPath = path.join(__dirname, '../../data'); +const db = new Database(path.join(dbPath, 'breedr.db')); + +function getDatabase() { + return db; +} + +function initDatabase() { + db.pragma('foreign_keys = ON'); + db.pragma('journal_mode = WAL'); + + // ── 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')) + ) + `); + + // 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 */ } + + // ── Parents ───────────────────────────────────────────────────────── + db.exec(` + CREATE TABLE IF NOT EXISTS parents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, + parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')), + FOREIGN KEY (dog_id) REFERENCES dogs(id), + FOREIGN KEY (parent_id) REFERENCES dogs(id) + ) + `); + + // ── 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')), + FOREIGN KEY (sire_id) REFERENCES dogs(id), + FOREIGN KEY (dam_id) REFERENCES dogs(id) + ) + `); + + // ── 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')), + FOREIGN KEY (breeding_id) REFERENCES breeding_records(id), + FOREIGN KEY (sire_id) REFERENCES dogs(id), + FOREIGN KEY (dam_id) REFERENCES dogs(id) + ) + `); + + // ── Health Records ────────────────────────────────────────────────── + 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')), + FOREIGN KEY (dog_id) REFERENCES dogs(id) + ) + `); + + // ── Settings ───────────────────────────────────────────────────────── + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kennel_name TEXT DEFAULT 'BREEDR', + kennel_tagline TEXT, + kennel_address TEXT, + kennel_phone TEXT, + kennel_email TEXT, + kennel_website TEXT, + kennel_akc_id TEXT, + kennel_breed TEXT, + owner_name TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + `); + + // migrate: add new kennel columns if missing (safe on existing DBs) + const kennelCols = [ + ['kennel_name', "TEXT DEFAULT 'BREEDR'"], + ['kennel_tagline', 'TEXT'], + ['kennel_address', 'TEXT'], + ['kennel_phone', 'TEXT'], + ['kennel_email', 'TEXT'], + ['kennel_website', 'TEXT'], + ['kennel_akc_id', 'TEXT'], + ['kennel_breed', 'TEXT'], + ['owner_name', 'TEXT'], + ]; + for (const [col, def] of kennelCols) { + 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'); + } + + console.log('✓ Database initialized successfully'); +} + +module.exports = { getDatabase, initDatabase }; From 421ea5cb586a6e081360ac71fca6889c6bb576af Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:24:39 -0500 Subject: [PATCH 14/15] feat(api): expose is_champion on all dog queries incl sire/dam/offspring joins --- server/routes/dogs.js | 268 ++++++++++++++++++------------------------ 1 file changed, 112 insertions(+), 156 deletions(-) diff --git a/server/routes/dogs.js b/server/routes/dogs.js index 3fdf934..f5178f4 100644 --- a/server/routes/dogs.js +++ b/server/routes/dogs.js @@ -5,7 +5,6 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); -// Configure multer for photo uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'); @@ -19,12 +18,10 @@ const storage = multer.diskStorage({ const upload = multer({ storage, - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit + limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { - const allowedTypes = /jpeg|jpg|png|gif|webp/; - const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); - const mimetype = allowedTypes.test(file.mimetype); - if (extname && mimetype) { + const allowed = /jpeg|jpg|png|gif|webp/; + if (allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype)) { cb(null, true); } else { cb(new Error('Only image files are allowed')); @@ -32,29 +29,41 @@ const upload = multer({ } }); -// Helper function to convert empty strings to null -const emptyToNull = (value) => { - return (value === '' || value === undefined) ? null : value; -}; +const emptyToNull = (v) => (v === '' || v === undefined) ? null : v; -// GET all dogs +// ── 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 +`; + +// ── GET all dogs ─────────────────────────────────────────────────── router.get('/', (req, res) => { try { const db = getDatabase(); const dogs = db.prepare(` - SELECT id, name, registration_number, breed, sex, birth_date, - color, microchip, photo_urls, notes, litter_id, is_active, - created_at, updated_at - FROM dogs - WHERE is_active = 1 + SELECT ${DOG_COLS} + FROM dogs + WHERE is_active = 1 ORDER BY name `).all(); - - // Parse photo_urls JSON + + // Also pull sire/dam so list page can compute bloodline status + 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); } catch (error) { console.error('Error fetching dogs:', error); @@ -62,42 +71,35 @@ router.get('/', (req, res) => { } }); -// GET single dog by ID with parents and offspring +// ── GET single dog (with parents + offspring) ─────────────────────── router.get('/:id', (req, res) => { try { const db = getDatabase(); - const dog = db.prepare(` - SELECT id, name, registration_number, breed, sex, birth_date, - color, microchip, photo_urls, notes, litter_id, is_active, - created_at, updated_at - FROM dogs - WHERE id = ? - `).get(req.params.id); - - if (!dog) { - return res.status(404).json({ error: 'Dog not found' }); - } - + const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id); + + if (!dog) return res.status(404).json({ error: 'Dog not found' }); + dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - - // Get parents from parents table + + // Parents — include is_champion so frontend can render bloodline badge const parents = db.prepare(` - SELECT p.parent_type, d.* - FROM parents p - JOIN dogs d ON p.parent_id = d.id + 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 = ? `).all(req.params.id); - + dog.sire = parents.find(p => p.parent_type === 'sire') || null; - dog.dam = parents.find(p => p.parent_type === 'dam') || null; - - // Get offspring + dog.dam = parents.find(p => p.parent_type === 'dam') || null; + + // Offspring — include is_champion for badge on offspring cards dog.offspring = db.prepare(` - SELECT d.* FROM dogs d + SELECT d.id, d.name, d.sex, d.is_champion + FROM dogs d JOIN parents p ON d.id = p.dog_id WHERE p.parent_id = ? AND d.is_active = 1 `).all(req.params.id); - + res.json(dog); } catch (error) { console.error('Error fetching dog:', error); @@ -105,66 +107,50 @@ router.get('/:id', (req, res) => { } }); -// POST create new 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 } = req.body; - - console.log('Creating dog with data:', { name, breed, sex, sire_id, dam_id, litter_id }); - + 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 }); + if (!name || !breed || !sex) { return res.status(400).json({ error: 'Name, breed, and sex are required' }); } - + const db = getDatabase(); - - // Insert dog (dogs table has NO sire/dam columns) const result = db.prepare(` - INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, litter_id, photo_urls) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, + microchip, notes, litter_id, photo_urls, is_champion) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - name, - emptyToNull(registration_number), - breed, - sex, - emptyToNull(birth_date), - emptyToNull(color), + name, + emptyToNull(registration_number), + breed, sex, + emptyToNull(birth_date), + emptyToNull(color), emptyToNull(microchip), emptyToNull(notes), emptyToNull(litter_id), - '[]' + '[]', + is_champion ? 1 : 0 ); - + const dogId = result.lastInsertRowid; console.log(`✓ Dog inserted with ID: ${dogId}`); - - // Add sire relationship if provided + if (sire_id && sire_id !== '' && sire_id !== null) { - console.log(` Adding sire relationship: dog ${dogId} -> sire ${sire_id}`); - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)'). - run(dogId, sire_id, 'sire'); - console.log(` ✓ Sire relationship added`); + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire'); } - - // Add dam relationship if provided if (dam_id && dam_id !== '' && dam_id !== null) { - console.log(` Adding dam relationship: dog ${dogId} -> dam ${dam_id}`); - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)'). - run(dogId, dam_id, 'dam'); - console.log(` ✓ Dam relationship added`); + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam'); } - - // Fetch the created dog - const dog = db.prepare(` - SELECT id, name, registration_number, breed, sex, birth_date, - color, microchip, photo_urls, notes, litter_id, is_active, - created_at, updated_at - FROM dogs - WHERE id = ? - `).get(dogId); + + const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId); dog.photo_urls = []; - - console.log(`✓ Dog created successfully: ${dog.name} (ID: ${dogId})`); + + console.log(`✓ Dog created: ${dog.name} (ID: ${dogId})`); res.status(201).json(dog); } catch (error) { console.error('Error creating dog:', error); @@ -172,66 +158,47 @@ 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 } = req.body; - - console.log(`Updating dog ${req.params.id} with data:`, { name, breed, sex, sire_id, dam_id, litter_id }); - + 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 }); + const db = getDatabase(); - - // Update dog record (dogs table has NO sire/dam columns) db.prepare(` - UPDATE dogs - SET name = ?, registration_number = ?, breed = ?, sex = ?, - birth_date = ?, color = ?, microchip = ?, notes = ?, litter_id = ? + UPDATE dogs + SET name = ?, registration_number = ?, breed = ?, sex = ?, + birth_date = ?, color = ?, microchip = ?, notes = ?, + litter_id = ?, is_champion = ?, updated_at = datetime('now') WHERE id = ? `).run( - name, - emptyToNull(registration_number), - breed, - sex, - emptyToNull(birth_date), - emptyToNull(color), + name, + emptyToNull(registration_number), + breed, sex, + emptyToNull(birth_date), + emptyToNull(color), emptyToNull(microchip), emptyToNull(notes), emptyToNull(litter_id), + is_champion ? 1 : 0, req.params.id ); - console.log(` ✓ Dog record updated`); - - // Remove existing parent relationships + + // Re-link parents db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id); - console.log(` ✓ Old parent relationships removed`); - - // Add new sire relationship if provided if (sire_id && sire_id !== '' && sire_id !== null) { - console.log(` Adding sire relationship: dog ${req.params.id} -> sire ${sire_id}`); - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)'). - run(req.params.id, sire_id, 'sire'); - console.log(` ✓ Sire relationship added`); + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire'); } - - // Add new dam relationship if provided if (dam_id && dam_id !== '' && dam_id !== null) { - console.log(` Adding dam relationship: dog ${req.params.id} -> dam ${dam_id}`); - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)'). - run(req.params.id, dam_id, 'dam'); - console.log(` ✓ Dam relationship added`); + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam'); } - - // Fetch updated dog - const dog = db.prepare(` - SELECT id, name, registration_number, breed, sex, birth_date, - color, microchip, photo_urls, notes, litter_id, is_active, - created_at, updated_at - FROM dogs - WHERE id = ? - `).get(req.params.id); + + const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id); dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - - console.log(`✓ Dog updated successfully: ${dog.name} (ID: ${req.params.id})`); + + console.log(`✓ Dog updated: ${dog.name} (ID: ${req.params.id})`); res.json(dog); } catch (error) { console.error('Error updating dog:', error); @@ -239,7 +206,7 @@ router.put('/:id', (req, res) => { } }); -// DELETE dog (soft delete) +// ── DELETE dog (soft) ─────────────────────────────────────────────── router.delete('/:id', (req, res) => { try { const db = getDatabase(); @@ -252,25 +219,19 @@ router.delete('/:id', (req, res) => { } }); -// POST upload photo for dog +// ── 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' }); - } - + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const db = getDatabase(); const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); - - if (!dog) { - return res.status(404).json({ error: 'Dog not found' }); - } - + if (!dog) return res.status(404).json({ error: 'Dog not found' }); + const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; photoUrls.push(`/uploads/${req.file.filename}`); - db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); - + res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls }); } catch (error) { console.error('Error uploading photo:', error); @@ -278,31 +239,26 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => { } }); -// DELETE photo from dog +// ── DELETE photo ───────────────────────────────────────────────────── router.delete('/:id/photos/:photoIndex', (req, res) => { try { const db = getDatabase(); const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); - - if (!dog) { - return res.status(404).json({ error: 'Dog not found' }); - } - - const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + if (!dog) return res.status(404).json({ error: 'Dog not found' }); + + const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; const photoIndex = parseInt(req.params.photoIndex); - + if (photoIndex >= 0 && photoIndex < photoUrls.length) { - const photoPath = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), path.basename(photoUrls[photoIndex])); - - // Delete file from disk - if (fs.existsSync(photoPath)) { - fs.unlinkSync(photoPath); - } - + const photoPath = path.join( + process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), + path.basename(photoUrls[photoIndex]) + ); + if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath); photoUrls.splice(photoIndex, 1); db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); } - + res.json({ photos: photoUrls }); } catch (error) { console.error('Error deleting photo:', error); From 1b59581714c72beede301532611dc2d72ff35840 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 22:25:29 -0500 Subject: [PATCH 15/15] feat(ui): add Champion toggle checkbox to DogForm --- client/src/components/DogForm.jsx | 262 +++++++++++++----------------- 1 file changed, 111 insertions(+), 151 deletions(-) diff --git a/client/src/components/DogForm.jsx b/client/src/components/DogForm.jsx index 750821a..24b6f24 100644 --- a/client/src/components/DogForm.jsx +++ b/client/src/components/DogForm.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { X } from 'lucide-react' +import { X, Award } from 'lucide-react' import axios from 'axios' function DogForm({ dog, onClose, onSave }) { @@ -12,9 +12,10 @@ function DogForm({ dog, onClose, onSave }) { color: '', microchip: '', notes: '', - sire_id: null, // Changed from '' to null - dam_id: null, // Changed from '' to null - litter_id: null // Changed from '' to null + sire_id: null, + dam_id: null, + litter_id: null, + is_champion: false, }) const [dogs, setDogs] = useState([]) const [litters, setLitters] = useState([]) @@ -36,9 +37,10 @@ function DogForm({ dog, onClose, onSave }) { color: dog.color || '', microchip: dog.microchip || '', notes: dog.notes || '', - sire_id: dog.sire?.id || null, // Ensure null, not '' - dam_id: dog.dam?.id || null, // Ensure null, not '' - litter_id: dog.litter_id || null // Ensure null, not '' + sire_id: dog.sire?.id || null, + dam_id: dog.dam?.id || null, + litter_id: dog.litter_id || null, + is_champion: !!dog.is_champion, }) setUseManualParents(!dog.litter_id) } @@ -48,8 +50,7 @@ function DogForm({ dog, onClose, onSave }) { try { const res = await axios.get('/api/dogs') setDogs(res.data || []) - } catch (error) { - console.error('Error fetching dogs:', error) + } catch (e) { setDogs([]) } } @@ -57,16 +58,11 @@ function DogForm({ dog, onClose, onSave }) { const fetchLitters = async () => { try { const res = await axios.get('/api/litters') - const litterData = res.data || [] - setLitters(litterData) - setLittersAvailable(litterData.length > 0) - // Only default to manual if no litters exist - if (litterData.length === 0) { - setUseManualParents(true) - } - } catch (error) { - console.error('Error fetching litters:', error) - // If endpoint fails, gracefully fallback to manual mode + const data = res.data || [] + setLitters(data) + setLittersAvailable(data.length > 0) + if (data.length === 0) setUseManualParents(true) + } catch (e) { setLitters([]) setLittersAvailable(false) setUseManualParents(true) @@ -74,25 +70,27 @@ function DogForm({ dog, onClose, onSave }) { } const handleChange = (e) => { - const { name, value } = e.target - - // Convert empty strings to null for ID fields - let processedValue = value - if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') { - processedValue = value === '' ? null : parseInt(value) + const { name, value, type, checked } = e.target + + if (type === 'checkbox') { + setFormData(prev => ({ ...prev, [name]: checked })) + return } - - setFormData(prev => ({ ...prev, [name]: processedValue })) - - // If litter is selected, auto-populate parents + + let processed = value + if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') { + processed = value === '' ? null : parseInt(value) + } + setFormData(prev => ({ ...prev, [name]: processed })) + if (name === 'litter_id' && value) { - const selectedLitter = litters.find(l => l.id === parseInt(value)) - if (selectedLitter) { + const sel = litters.find(l => l.id === parseInt(value)) + if (sel) { setFormData(prev => ({ ...prev, - sire_id: selectedLitter.sire_id, - dam_id: selectedLitter.dam_id, - breed: prev.breed || selectedLitter.sire_name?.split(' ')[0] || '' + sire_id: sel.sire_id, + dam_id: sel.dam_id, + breed: prev.breed || sel.sire_name?.split(' ')[0] || '' })) } } @@ -102,11 +100,10 @@ function DogForm({ dog, onClose, onSave }) { e.preventDefault() setError('') setLoading(true) - try { - const submitData = { + const submitData = { ...formData, - // Ensure null values are sent, not empty strings + is_champion: formData.is_champion ? 1 : 0, sire_id: formData.sire_id || null, dam_id: formData.dam_id || null, litter_id: useManualParents ? null : (formData.litter_id || null), @@ -114,25 +111,22 @@ function DogForm({ dog, onClose, onSave }) { birth_date: formData.birth_date || null, color: formData.color || null, microchip: formData.microchip || null, - notes: formData.notes || null + notes: formData.notes || null, } - if (dog) { - // Update existing dog await axios.put(`/api/dogs/${dog.id}`, submitData) } else { - // Create new dog await axios.post('/api/dogs', submitData) } onSave() onClose() - } catch (error) { - setError(error.response?.data?.error || 'Failed to save dog') + } catch (err) { + setError(err.response?.data?.error || 'Failed to save dog') setLoading(false) } } - const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id) + const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id) const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id) return ( @@ -140,9 +134,7 @@ function DogForm({ dog, onClose, onSave }) {
e.stopPropagation()}>

{dog ? 'Edit Dog' : 'Add New Dog'}

- +
@@ -151,48 +143,25 @@ function DogForm({ dog, onClose, onSave }) {
- +
- +
- +
- @@ -200,62 +169,77 @@ function DogForm({ dog, onClose, onSave }) {
- +
- +
- +
- {/* Litter or Manual Parent Selection */} -
+ {/* Champion Toggle */} +
setFormData(prev => ({ ...prev, is_champion: !prev.is_champion }))} + > + e.stopPropagation()} + /> + +
+
+ Champion +
+
+ Mark this dog as a titled champion — offspring will display a Champion Bloodline badge +
+
+
+ + {/* Parent Section */} +
- + {littersAvailable && (
@@ -264,12 +248,8 @@ function DogForm({ dog, onClose, onSave }) { {!useManualParents && littersAvailable ? (
- {litters.map(l => (