Compare commits

...

218 Commits

Author SHA1 Message Date
761387388d Add .gitea/workflows/docker-build.yml
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
2026-03-29 14:22:45 -05:00
4394286d0b Delete .gitea/workflows/docker-publish.yml 2026-03-29 14:15:19 -05:00
jason
b8633863b0 fix: add pagination to unbounded GET endpoints
All list endpoints now accept ?page and ?limit (default 50, max 200) and
return { data, total, page, limit } instead of a bare array, preventing
memory and performance failures at scale.

- GET /api/dogs: adds pagination, server-side search (?search) and sex
  filter (?sex), and a stats aggregate (total/males/females) for the
  Dashboard to avoid counting from the array
- GET /api/litters: adds pagination; also fixes N+1 query by fetching
  all puppies for the current page in a single query instead of one per
  litter
- DogList: moves search/sex filtering server-side with 300ms debounce;
  adds Prev/Next pagination controls
- LitterList: uses paginated response; adds Prev/Next pagination controls
- Dashboard: reads counts from stats/total fields instead of array length
- LitterDetail, LitterForm: switch dogs fetch to /api/dogs/all (complete
  list, no pagination, for sire/dam dropdowns)
- DogForm: updates litters fetch to use paginated response shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:40:28 -05:00
jason
fa7a336588 docs 2026-03-12 11:26:48 -05:00
c3696ba015 docker 2026-03-12 07:43:30 -05:00
c483096c63 qs 2026-03-12 07:40:50 -05:00
e4e3b44fcf Delete RELEASE_NOTES_v0.4.0.md 2026-03-12 07:38:32 -05:00
78e15d08af Delete CLEANUP_NOTES.md 2026-03-12 07:38:27 -05:00
454665b9cb Delete TEST.md 2026-03-12 07:38:04 -05:00
d8557fcfca database 2026-03-12 07:37:20 -05:00
5f68ca0e8b readmes 2026-03-12 07:35:15 -05:00
42bab14ac3 reverse pedigree 2026-03-12 07:27:41 -05:00
5ca594fdc7 external dogs 2026-03-12 07:21:44 -05:00
13185a5281 Roadmap 2,3,4 2026-03-11 23:48:35 -05:00
17b008a674 Merge pull request 'stroke fix' (#55) from pedigree-update into master
Reviewed-on: #55
2026-03-11 15:49:55 -05:00
jason
9b3210a81e stroke fix 2026-03-11 15:49:46 -05:00
81357e87ae Merge pull request 'halo effect' (#54) from pedigree-update into master
Reviewed-on: #54
2026-03-11 15:41:57 -05:00
jason
8abd5e2db6 halo effect 2026-03-11 15:41:30 -05:00
a63617d9c0 Merge pull request 'remove shadow' (#53) from pedigree-update into master
Reviewed-on: #53
2026-03-11 15:37:49 -05:00
jason
7195aaecfc remove shadow 2026-03-11 15:37:38 -05:00
34bf29d8bf Merge pull request 'text update' (#52) from pedigree-update into master
Reviewed-on: #52
2026-03-11 15:33:49 -05:00
jason
4f3074b1f4 text update 2026-03-11 15:33:23 -05:00
3c7ba1775f Merge pull request 'ped changes' (#51) from pedigree-update into master
Reviewed-on: #51
2026-03-11 15:27:42 -05:00
jason
0a0a5d232c ped changes 2026-03-11 15:26:35 -05:00
jason
58b53c981e feat: Add pedigree routes for COI calculation, direct relation checks, and ancestral/descendant trees. 2026-03-11 14:48:59 -05:00
Zenflow
7b941c9a9a Push from Zen 2026-03-11 13:15:01 -05:00
Zenflow
055364f467 New task (zenflow 738246ea)
In the Pairing Simulator page, I am getting the error:

**Error:**Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Fid and fix the bug
2026-03-11 13:12:01 -05:00
Zenflow
b8eadd9efa "Fix_COI_display_consistency" 2026-03-11 13:09:08 -05:00
Zenflow
ff1eb455dc "Fix_COI_and_routes" 2026-03-11 13:07:04 -05:00
Zenflow
c22ebbe45c New task (zenflow 738246ea)
In the Pairing Simulator page, I am getting the error:

**Error:**Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Fid and fix the bug
2026-03-11 13:02:24 -05:00
Zenflow
e5f7b2b053 Implementation
Task complete.
2026-03-11 09:59:42 -05:00
Zenflow
c00b6191e7 Investigation and Planning
I've completed the investigation and planning for the External Dogs UI issues. I found that `ExternalDogs.jsx` used undefined CSS classes and a different layout than `DogList.jsx`. I've documented my findings and a proposed fix in [.zenflow/tasks/6e6e64eb-cb72-459e-b943-27554a749459/investigation.md](./.zenflow/tasks/6e6e64eb-cb72-459e-b943-27554a749459/investigation.md) and updated the [plan.md](./.zenflow/tasks/new-task-6e6e/plan.md).
2026-03-11 09:59:42 -05:00
Zenflow
0f9d3cf187 Initialize task: New task 2026-03-11 09:59:42 -05:00
Zenflow
2daccf7d8c INIT (zenflow 9c6862b8)
Scan the code, build the .md files you need to make it easier for the agent to preform modifications and fix bugs
2026-03-11 09:51:35 -05:00
5c6068364b Update TEST.md to include abc456 2026-03-11 01:22:26 -05:00
768e25183d Add TEST.md with text 123 2026-03-11 01:06:52 -05:00
78069f2880 Merge pull request 'feature/external-dogs' (#50) from feature/external-dogs into master
Reviewed-on: #50
2026-03-11 01:01:48 -05:00
2cfeaf667e Merge pull request 'fix: wire external dogs end-to-end (modal, form flag, pairing simulator)' (#49) from fix/external-dogs-wiring into feature/external-dogs
Reviewed-on: #49
2026-03-11 01:01:18 -05:00
5eaa6e566c fix: GET /api/dogs honours ?include_external=1 query param for pairing simulator 2026-03-11 01:00:48 -05:00
80b497e902 fix: PairingSimulator fetches /api/dogs?include_external=1 so external dogs appear in selectors 2026-03-11 00:57:59 -05:00
8cb4c773fd fix: DogForm accepts isExternal prop — sets is_external flag, hides litter/parent pickers, shows banner 2026-03-11 00:56:51 -05:00
22e85f0d7e fix: wire Add External Dog button to DogForm modal (removes broken /dogs/new?external=1 nav) 2026-03-11 00:55:51 -05:00
aa3b1b2404 feat(nav): add External Dogs nav link and route 2026-03-10 15:27:06 -05:00
3275524ad0 feat(ui): add ExternalDogs page — full CRUD roster for external sires/dams 2026-03-10 15:26:21 -05:00
9738b24db6 feat(api): add is_external support — GET /api/dogs filters kennel dogs; GET /api/dogs/external returns external roster 2026-03-10 15:24:50 -05:00
0c84b83e75 feat(db): add is_external flag to dogs table with safe ALTER TABLE migration 2026-03-10 15:23:35 -05:00
01a5db10c0 Merge pull request 'docs: update README with COI direct-relation fix (v0.6.1)' (#48) from fix/coi-direct-relation into master
Reviewed-on: #48
2026-03-10 15:16:21 -05:00
df7d94ba9d docs: update README with COI direct-relation fix (v0.6.1) 2026-03-10 15:15:00 -05:00
af9398ec0f Merge pull request 'fix: COI correctly calculates parent×offspring and direct-relation pairings' (#47) from fix/coi-direct-relation into master
Reviewed-on: #47
2026-03-10 15:09:45 -05:00
389636ce6f fix: COI correctly calculates parent×offspring and direct-relation pairings
- Remove blanket `id !== sid && id !== did` exclusion from commonIds filter
  which was silently zeroing out COI for parent×offspring pairings because
  the sire (sid) IS the common ancestor in damMap but was being filtered out.
- Instead: exclude `did` from sireMap keys (sire can't be its own common
  ancestor with the dam) and exclude `sid` from damMap keys (same logic).
- Parent×offspring pairing now correctly yields ~25% COI as expected by
  Wright's path coefficient method.
- All other normal pairings are unaffected.
2026-03-10 15:08:33 -05:00
2164b035a8 fix(backend): move named routes above /:id wildcard — Express route order bug causing 0% COI 2026-03-10 15:01:22 -05:00
6431164d3b Merge pull request 'fix: COI direct-ancestor bug — correct Wright algorithm + frontend relation guard' (#45) from fix/pairing-coi-and-direct-relation-guard into master
Reviewed-on: #45
2026-03-10 14:57:15 -05:00
72c54f847f fix(frontend): block/warn direct parent-offspring selections in PairingSimulator 2026-03-10 14:56:09 -05:00
c949fe2502 fix(backend): rewrite COI with self-at-gen-0 Wright method + direct-relation detection endpoint 2026-03-10 14:54:59 -05:00
1dacdc9fe7 Merge pull request 'fix: correct Wright COI algorithm — handle direct parent-offspring pairings' (#44) from fix/coi-direct-ancestor-logic into master
Reviewed-on: #44
2026-03-10 14:48:04 -05:00
f5ee9837c6 fix: correct COI Wright path algorithm — include sire/dam as direct ancestors of each other 2026-03-10 14:44:27 -05:00
c7c0ec6530 Merge pull request 'fix: Trial Pairing Simulator — correct SQLite string quoting for sex filter' (#43) from fix/trial-pairing-sex-quote into master
Reviewed-on: #43
2026-03-10 14:39:16 -05:00
20fcc39a58 fix: use single quotes for sex string literals in trial-pairing SQL (SQLite double-quote = identifier) 2026-03-10 14:38:16 -05:00
d5bce0522b Merge pull request 'fix: Migration 003 - dynamic column restore to handle missing updated_at' (#42) from fix/migration-003-dynamic-columns into master
Reviewed-on: #42
2026-03-10 14:32:36 -05:00
e17ce2be29 fix: Migration 003 - use dynamic column list to handle missing updated_at in old schema 2026-03-10 14:31:58 -05:00
d1b02cb735 Merge pull request 'fix: wire runMigrations() into startup before initDatabase()' (#41) from fix/wire-migrations-to-startup into master
Reviewed-on: #41
2026-03-10 14:28:20 -05:00
e800cb91f2 fix: wire runMigrations() into startup before initDatabase() 2026-03-10 14:24:32 -05:00
7d498962c8 Merge pull request 'fix: Remove old record_type CHECK constraint from health_records (Migration 003)' (#40) from fix/health-record-type-constraint into master
Reviewed-on: #40
2026-03-10 13:08:07 -05:00
031e344fcb fix: Migration 003 - remove old record_type CHECK constraint from health_records 2026-03-10 13:06:42 -05:00
b49b2b4281 Merge pull request 'feat/startup-log' (#39) from feat/startup-log into master
Reviewed-on: #39
2026-03-10 12:59:20 -05:00
6e8f747c8a docs: Add documentation for startup log utility 2026-03-10 12:57:32 -05:00
326bf318a1 feat: Integrate startup log utility in server initialization 2026-03-10 12:56:54 -05:00
799edcf3c4 feat: Add startup log utility with system info and ASCII banner 2026-03-10 12:56:26 -05:00
4e5b695c22 Merge pull request 'fix(db): add vet_name, result, next_due to healthMigrations ALTER TABLE guards' (#38) from fix/health-records-missing-columns into master
Reviewed-on: #38
2026-03-10 12:52:40 -05:00
9b43bdab99 fix(db): add vet_name, result, next_due to healthMigrations ALTER TABLE guards
Existing databases were missing these 3 columns because they were defined
in the CREATE TABLE but not in the healthMigrations array used for
ALTER TABLE on pre-existing DBs. This caused the 'table health_records
has no column named vet_name' error in the Add Health Record modal.
2026-03-10 12:52:04 -05:00
9de792aa02 Merge pull request 'feat/phase-4b-health-genetics' (#36) from feat/phase-4b-health-genetics into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #36
2026-03-09 23:38:18 -05:00
e9588fa866 feat(ui): integrate ClearanceSummaryCard and HealthRecordForm into DogDetail 2026-03-09 23:32:41 -05:00
56458340ea feat(ui): add HealthRecordForm modal with OFA and general record support 2026-03-09 23:31:26 -05:00
bc7f54b084 feat(ui): add ClearanceSummaryCard with OFA clearance chips and GRCA eligibility badge 2026-03-09 23:30:47 -05:00
97efc937c0 feat(server): register /api/genetics route for Phase 4b 2026-03-09 23:25:15 -05:00
d9cd0bec58 feat(api): add genetics.js — DNA panel CRUD + pairing-risk endpoint 2026-03-09 23:23:46 -05:00
8635483332 feat(api): rewrite health.js with OFA clearance fields, clearance-summary, chic-eligible endpoints 2026-03-09 23:22:55 -05:00
91ad50655c feat(db): Phase 4b schema — OFA clearances, genetic_tests, cancer_history, eligibility fields 2026-03-09 23:21:44 -05:00
286b9c9bd0 feat: expand Phase 4b roadmap with full health clearance & genetics system 2026-03-09 23:19:15 -05:00
cf2a5ba8d3 Merge pull request 'feat: delete dogs + PawPrint nav icon' (#35) from feat/dog-delete-nav-icon into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #35
2026-03-09 22:59:44 -05:00
aa63e4f388 feat(nav): swap Dogs icon from Users to PawPrint 2026-03-09 22:59:02 -05:00
e44883b5e0 feat(dogs): add delete button with confirm modal on DogList 2026-03-09 22:58:41 -05:00
0ade8586f9 feat(dogs): add hard DELETE /api/dogs/:id with cascade cleanup 2026-03-09 22:57:43 -05:00
4c1206e26c Merge pull request 'feat/ui-theme-settings-champion' (#34) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #34
2026-03-09 22:48:04 -05:00
501e6c30d4 docs(roadmap): add v0.6.0 sprint entries; mark champion, settings, build fixes complete 2026-03-09 22:47:31 -05:00
19d50b24df Merge pull request 'feat/pedigree-theme-visual' (#33) from feat/pedigree-theme-visual into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #33
2026-03-09 22:47:09 -05:00
eda59b7a02 docs(readme): add v0.6.0 champion, settings, theme, and bug fix entries 2026-03-09 22:45:55 -05:00
f860738428 feat(pedigree): update PedigreeView stats bar + tip box to use theme vars 2026-03-09 22:45:23 -05:00
380599383c feat(pedigree): themed node rendering, glow rings, gold root node, breed label, zoom display 2026-03-09 22:44:46 -05:00
dee4769ad2 feat(pedigree): retheme PedigreeTree to match app dark/warm design system 2026-03-09 22:44:06 -05:00
c898ea850f Merge pull request 'fix(settings): rewrite route to match single-row column schema (was double-encoded base64 + wrong key/value schema)' (#32) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #32
2026-03-09 22:34:54 -05:00
43939c664e fix(settings): rewrite route to match single-row column schema (was double-encoded base64 + wrong key/value schema) 2026-03-09 22:34:13 -05:00
31353e9fef Merge pull request 'feat/ui-theme-settings-champion' (#31) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #31
2026-03-09 22:32:22 -05:00
75ff6e1af1 fix(build): delete useSettings.js (replaced by useSettings.jsx) 2026-03-09 22:31:13 -05:00
29f73007d6 fix(build): rename useSettings.js -> useSettings.jsx (contains JSX, Vite requires .jsx extension) 2026-03-09 22:31:03 -05:00
a234444302 Merge pull request 'fix(server): call initDatabase() with no args to match updated db/init.js; remove duplicate health route' (#30) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #30
2026-03-09 22:29:56 -05:00
6ac1518c40 fix(server): call initDatabase() with no args to match updated db/init.js; remove duplicate health route 2026-03-09 22:29:20 -05:00
5994ad5374 Merge pull request 'feat/ui-theme-settings-champion' (#29) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #29
2026-03-09 22:26:17 -05:00
1b59581714 feat(ui): add Champion toggle checkbox to DogForm 2026-03-09 22:25:29 -05:00
421ea5cb58 feat(api): expose is_champion on all dog queries incl sire/dam/offspring joins 2026-03-09 22:24:39 -05:00
6903e66419 feat(db): add is_champion to dogs, kennel settings columns, migrate existing rows 2026-03-09 22:23:41 -05:00
2416e48bb7 feat: DogDetail — champion/bloodline badge in header, champion-glow border on main photo 2026-03-09 22:19:31 -05:00
9e699e308f feat: DogList — render ChampionBadge and ChampionBloodlineBadge on dog cards 2026-03-09 22:18:28 -05:00
ec249c7865 feat: add SettingsPage — kennel name, tagline, address, phone, website, email 2026-03-09 22:17:28 -05:00
3bc6b694f4 feat: add ChampionBadge and ChampionBloodlineBadge components 2026-03-09 22:16:55 -05:00
0573e154b1 feat: update App.css — navbar active state uses brand gradient, settings icon alignment 2026-03-09 22:16:39 -05:00
3e777772c3 feat: retheme index.css — warm amber/copper palette to complement gold-rust gradient 2026-03-09 22:15:58 -05:00
67912dc78d feat: App.jsx — dynamic kennel name in header, Settings nav link, useSettings hook 2026-03-09 22:14:51 -05:00
ec24a15c66 feat: wrap app in SettingsProvider 2026-03-09 22:14:33 -05:00
9ee441ffd9 feat: add useSettings hook for kennel settings context 2026-03-09 22:14:08 -05:00
4f7a2ad0f9 feat: wire settings route into Express server 2026-03-09 22:02:21 -05:00
6ce9aebabd feat: add settings API route for kennel info 2026-03-09 22:01:12 -05:00
683fef7e9c feat: add is_champion column to dogs table and settings table 2026-03-09 22:00:40 -05:00
c3a0655027 Merge pull request 'feat/whelping-calendar-identifier' (#28) from feat/whelping-calendar-identifier into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #28
2026-03-09 21:45:10 -05:00
a4baa52c05 docs: update ROADMAP with v0.5.1 whelping calendar identifier completion 2026-03-09 21:44:10 -05:00
2fd20102c8 docs: update README with projected whelping calendar identifier (v0.5.1) 2026-03-09 21:41:59 -05:00
a7cb22afe8 Merge pull request 'feat: add projected whelping identifier on heat cycle calendar' (#27) from feat/whelping-calendar-identifier into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #27
2026-03-09 21:36:19 -05:00
4ad3ffae4e feat: add projected whelping identifier on heat cycle calendar
- Compute projected whelp date (breeding_date + 63 days) client-side
- Mark projected whelp day on calendar grid with Baby icon + teal ring
- Show whelp range (earliest/expected/latest) tooltip on calendar cell
- Add 'Projected Whelp' entry to legend
- Show projected whelp date on active cycle cards below breeding date
- Active cycle cards navigate to whelp month if outside current view
2026-03-09 21:33:13 -05:00
da6b2f2838 Merge pull request 'feat/litter-management-ui' (#26) from feat/litter-management-ui into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #26
2026-03-09 21:11:10 -05:00
421b875661 feat: add PuppyLogPanel weight/health logs + projected whelping window to LitterDetail 2026-03-09 21:06:03 -05:00
6e37abf6e8 feat: add puppy weight/health log endpoints to litters router 2026-03-09 21:02:15 -05:00
6672e53122 Merge pull request 'feat/litter-management-ui' (#25) from feat/litter-management-ui into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #25
2026-03-09 20:55:43 -05:00
3d716a2406 feat: LitterForm accepts prefill prop to pre-populate dam + breeding date from BreedingCalendar 2026-03-09 20:54:17 -05:00
50deb6174b feat: auto-open LitterForm with prefill when navigated from BreedingCalendar 2026-03-09 20:53:37 -05:00
7a6b770999 feat: add Record Litter CTA in CycleDetailModal when breeding date is logged 2026-03-09 20:52:57 -05:00
49d2851532 feat: add /litters/:id route for LitterDetail page 2026-03-09 20:50:42 -05:00
15aa871333 feat: add LitterDetail page with puppy roster and add/remove/create puppy 2026-03-09 20:50:25 -05:00
0e8b875a4c feat: rebuild LitterList with create/edit/delete and LitterForm integration 2026-03-09 20:49:34 -05:00
da0e61ee98 Merge pull request 'docs: Update README & ROADMAP for v0.5.0 — Pairing Simulator + Heat Cycle Calendar' (#24) from docs/update-readme-roadmap-v0.5.1 into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #24
2026-03-09 20:41:39 -05:00
da585c9e35 docs: Update ROADMAP for v0.5.0 - mark Pairing Simulator and Heat Cycle Calendar complete 2026-03-09 20:40:45 -05:00
2290680a22 docs: Update README for v0.5.1 - Trial Pairing Simulator & Heat Cycle Calendar 2026-03-09 20:39:41 -05:00
a4135213a9 Merge pull request 'feat: Heat Cycle Calendar — month grid, start-cycle modal, breeding date suggestions, whelping estimate' (#23) from feat/heat-cycle-calendar into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #23
2026-03-09 20:33:42 -05:00
202c634df6 feat: Full heat cycle calendar with month grid, start-cycle modal, and breeding date suggestions 2026-03-09 20:32:21 -05:00
d7bad19275 feat: Add breeding date suggestion window endpoint 2026-03-09 20:30:42 -05:00
cc8179894b Merge pull request 'feat: Trial Pairing Simulator - COI calculator with common ancestors' (#22) from feat/trial-pairing-simulator into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #22
2026-03-09 20:26:17 -05:00
89eac7b84b style: Add risk-badge styles for PairingSimulator 2026-03-09 20:24:04 -05:00
6a74c2d14e feat: Wire PairingSimulator into App router and navbar 2026-03-09 20:23:17 -05:00
5184ee6e59 Merge pull request 'docs/update-readme-roadmap-v0.5' (#20) from docs/update-readme-roadmap-v0.5 into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #20
2026-03-09 20:22:53 -05:00
3be2f66659 feat: Add PairingSimulator page - sire/dam selection, COI, common ancestors, risk badge 2026-03-09 20:22:21 -05:00
31613e384d docs: Update ROADMAP to reflect branding work complete, mark v0.5 sprint 2026-03-09 20:20:43 -05:00
7ac505da05 docs: Update README with header/logo/branding changes and v0.5 next features 2026-03-09 20:19:46 -05:00
e6bbb70288 ci: Add Gitea Actions workflow to build and push Docker image to Gitea Container Registry
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-03-09 20:09:41 -05:00
029fd77913 style: Add subtle diffuse black drop shadow to logo and brand title text 2026-03-09 20:01:46 -05:00
707998d013 Update client/src/App.css 2026-03-09 19:59:38 -05:00
0848b4e7f0 style: Double logo size to 5rem, increase brand title font by 30% to 1.95rem 2026-03-09 19:55:32 -05:00
9ab3dd5a77 Merge pull request 'fix: Size brand-logo as fixed square for 1:1 logo aspect ratio' (#19) from feat/header-logo-and-title-gradient into master
Reviewed-on: #19
2026-03-09 19:48:36 -05:00
4f81c3129e fix: Mount ./static:/app/static volume and add STATIC_PATH env var 2026-03-09 19:24:41 -05:00
fc0bf4c6db fix: Add /app/static directory, STATIC_PATH env var, and static volume declaration 2026-03-09 19:24:31 -05:00
18baf1b7a0 fix: Size brand-logo as a fixed square to match 1:1 logo aspect ratio 2026-03-09 19:14:51 -05:00
9258a181b0 Merge pull request 'fix: Prevent /static and /uploads paths from falling through to React catch-all' (#18) from feat/header-logo-and-title-gradient into master
Reviewed-on: #18
2026-03-09 19:11:32 -05:00
25e4035436 fix: Prevent /static and /uploads paths from falling through to React catch-all 2026-03-09 19:10:48 -05:00
64495f4a6d Delete static/.gitkeep 2026-03-09 18:52:42 -05:00
65a5a557ac Upload files to "static" 2026-03-09 18:52:22 -05:00
358f80c668 Merge pull request 'feat: Header logo, gold-to-rusty-red title gradient, and /static asset folder' (#17) from feat/header-logo-and-title-gradient into master
Reviewed-on: #17
2026-03-09 18:52:00 -05:00
1d4534374b feat: Add static/ directory for branding assets (drop br-logo.png here) 2026-03-09 18:50:16 -05:00
24d96ca08a feat: Proxy /static to Express server in Vite dev mode 2026-03-09 18:50:04 -05:00
422ea5cf7f feat: Serve /static directory for logo and branding assets 2026-03-09 18:49:52 -05:00
eb661782fe feat: Add brand-logo sizing and gold-to-rusty-red gradient on brand-text 2026-03-09 18:49:29 -05:00
bc6cf83f72 feat: Replace generic icon with br-logo.png in navbar brand 2026-03-09 18:47:27 -05:00
a47ede4340 Merge pull request 'docs/clean-schema-and-roadmap-update' (#16) from docs/clean-schema-and-roadmap-update into master
Reviewed-on: #16
2026-03-09 02:25:15 -05:00
e3bea6593c Clean: Remove outdated documentation 2026-03-09 02:24:10 -05:00
b87863863b Docs: Add release notes for v0.4.0 clean schema 2026-03-09 02:21:52 -05:00
3be2039d03 Docs: Add cleanup notes for outdated documentation 2026-03-09 02:20:38 -05:00
7cfa5d8acb Docs: Update ROADMAP with clean schema completion 2026-03-09 02:19:48 -05:00
c9297cba2d Docs: Update README with clean schema and current features 2026-03-09 02:18:48 -05:00
296a1be4db Merge pull request 'feature/enhanced-litters-and-pedigree' (#15) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #15
2026-03-09 02:07:37 -05:00
417dc96b49 Docs: Add DATABASE.md with schema documentation 2026-03-09 02:04:41 -05:00
6f83f853ae Clean: Remove migrations - use clean init only 2026-03-09 02:03:02 -05:00
d311bc24a7 Clean: Proper sire/dam handling via parents table with logging 2026-03-09 02:01:18 -05:00
3ae3458dfc Clean: Fresh database init with parents table - no migrations 2026-03-09 01:59:52 -05:00
5363589ecc Merge pull request 'Fix: Remove weight/height columns - match actual schema' (#14) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #14
2026-03-09 01:40:03 -05:00
eec18daeea Fix: Remove weight/height columns - match actual schema 2026-03-09 01:38:42 -05:00
89ae25deaf Merge pull request 'Fix: Don't return sire/dam columns from dogs table, select explicit fields' (#13) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #13
2026-03-09 01:31:00 -05:00
e62d35041f Fix: Don't return sire/dam columns from dogs table, select explicit fields 2026-03-09 01:29:40 -05:00
1df65be59d Merge pull request 'feature/enhanced-litters-and-pedigree' (#12) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #12
2026-03-09 01:19:18 -05:00
57b59ffe2e Add comprehensive deployment guide for database migration and frontend fix 2026-03-09 01:17:55 -05:00
485cc15a3e Fix: Ensure sire_id and dam_id are sent as null when empty, not empty strings 2026-03-09 01:16:51 -05:00
39013fc7c5 Add frontend fix guide for Add Dog form 2026-03-09 01:14:48 -05:00
17452ba425 Merge pull request 'feature/enhanced-litters-and-pedigree' (#11) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #11
2026-03-09 01:01:29 -05:00
9c5d06d964 Add comprehensive database migration documentation 2026-03-09 00:59:26 -05:00
a11dec6d29 Add automatic migration system on startup 2026-03-09 00:58:39 -05:00
15f455387d Add automatic migration system with schema validation 2026-03-09 00:58:19 -05:00
855335f0b7 Merge pull request 'feature/enhanced-litters-and-pedigree' (#10) from feature/enhanced-litters-and-pedigree into master
Reviewed-on: #10
2026-03-09 00:48:07 -05:00
5b2c205342 Add Sprint 1 completion guide for pedigree tree 2026-03-09 00:45:16 -05:00
d426835b13 Rebuild PedigreeView with interactive tree visualization 2026-03-09 00:44:34 -05:00
701a26f02c Merge pull request 'Update roadmap with UI fix progress and current issues' (#9) from fix/dog-form-litter-ui into master
Reviewed-on: #9
2026-03-09 00:43:34 -05:00
8db5c89791 Add pedigree helper utilities for data transformation 2026-03-09 00:43:02 -05:00
dca3c5709b Add PedigreeTree styling with responsive design 2026-03-09 00:42:39 -05:00
e62c2bcd32 Add interactive PedigreeTree component with D3 visualization 2026-03-09 00:40:56 -05:00
320465854e Add comprehensive implementation plan for litters and pedigree features 2026-03-09 00:39:31 -05:00
12cbd947bc Update roadmap with UI fix progress and current issues 2026-03-09 00:35:17 -05:00
81c38afc6e Merge pull request 'Add error handling for API failures to prevent blank screen' (#8) from fix/dog-form-litter-ui into master
Reviewed-on: #8
2026-03-09 00:32:31 -05:00
6e3e23620b Add error handling for API failures to prevent blank screen 2026-03-09 00:31:24 -05:00
a2eccc72f2 Merge pull request 'Fix litter selection UI layout - separate radio buttons and dropdown properly' (#7) from fix/dog-form-litter-ui into master
Reviewed-on: #7
2026-03-09 00:25:22 -05:00
b9858b2c78 Fix litter selection UI layout - separate radio buttons and dropdown properly 2026-03-09 00:24:44 -05:00
65b129bca4 Merge pull request 'feature/litter-management-and-pedigree' (#6) from feature/litter-management-and-pedigree into master
Reviewed-on: #6
2026-03-09 00:15:28 -05:00
31b8ac9383 Add quick start guide for new features 2026-03-09 00:12:14 -05:00
ac77b9a256 Update ROADMAP with completed features 2026-03-09 00:11:31 -05:00
c90fc184cb Add comprehensive feature implementation documentation 2026-03-09 00:10:33 -05:00
f076286b15 Update DogForm with litter selection support 2026-03-09 00:09:44 -05:00
dd26fa00bf Add styling for pedigree tree visualization 2026-03-09 00:09:03 -05:00
7a16918d66 Add interactive pedigree tree visualization component 2026-03-09 00:08:46 -05:00
4af656667d Add LitterForm component for litter management 2026-03-09 00:08:18 -05:00
cc5af29c11 Enhanced litters API with puppy linking and litter_id support 2026-03-09 00:07:26 -05:00
a246e5f84f Add migration to add litter_id column to dogs table 2026-03-09 00:06:41 -05:00
af9f30e702 Merge pull request 'feature/ui-redesign' (#5) from feature/ui-redesign into master
Reviewed-on: #5
2026-03-08 23:56:28 -05:00
56fb9cb7af Fix: Convert empty microchip strings to NULL in database 2026-03-08 23:55:38 -05:00
28cad68170 Add verification checklist for future installations 2026-03-08 23:49:34 -05:00
2b20feeefc Add quick migration shell script 2026-03-08 23:44:58 -05:00
b152ee5a82 Merge pull request 'feature/ui-redesign' (#4) from feature/ui-redesign into master
Reviewed-on: #4
2026-03-08 23:42:00 -05:00
543b7d762b Add microchip field fix migration notice to README 2026-03-08 23:41:29 -05:00
5d9506ba80 Document microchip field fix and migration steps 2026-03-08 23:40:40 -05:00
f1e5b422b0 Add migration script to fix microchip UNIQUE constraint 2026-03-08 23:40:02 -05:00
bb0f5dd9b8 Fix: Remove UNIQUE constraint from microchip field to allow NULL values 2026-03-08 23:39:22 -05:00
bb0bb8dcea Merge pull request 'feature/ui-redesign' (#3) from feature/ui-redesign into master
Reviewed-on: #3
2026-03-08 23:33:09 -05:00
4696f2d47a Document compact info card design approach 2026-03-08 23:32:20 -05:00
32b45a4fb3 Redesign: Horizontal info cards with avatars for Dogs list 2026-03-08 23:31:02 -05:00
a56c403af4 Redesign: Compact info cards with avatar-style photos 2026-03-08 23:29:54 -05:00
64 changed files with 12368 additions and 1348 deletions

View File

@@ -0,0 +1,25 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest

View File

@@ -0,0 +1,29 @@
# Investigation - External Dogs UI Issues
## Bug Summary
The "External Dogs" interface does not match the layout and style of the main "Dogs" page. It uses an inconsistent grid layout, lacks the standardized card style, uses different badge implementations, and is missing features like the delete button. Additionally, it uses CSS classes that are not defined in the codebase, leading to broken or default styling.
## Root Cause Analysis
- **Inconsistent Layout**: `DogList.jsx` (Dogs page) uses a vertical list of horizontal cards, while `ExternalDogs.jsx` uses a grid of square-ish cards.
- **Undefined CSS Classes**: `ExternalDogs.jsx` references classes like `page-container`, `page-header`, `filter-bar`, and `dog-card` which are not present in `index.css` or `App.css`.
- **Missing Components**: `ExternalDogs.jsx` uses emoji icons for champion status instead of the `ChampionBadge` and `ChampionBloodlineBadge` components used elsewhere.
- **Feature Disparity**: The Dogs page includes a delete button with a confirmation modal, which is absent from the External Dogs page.
- **Helper Usage**: `ExternalDogs.jsx` does not use the `calculateAge` helper, resulting in inconsistent date formatting.
## Affected Components
- `client/src/pages/ExternalDogs.jsx`
## Implementation Notes
Refactored `ExternalDogs.jsx` to match `DogList.jsx` in layout, style, and functionality. Key changes:
- Switched to `axios` for API calls.
- Adopted the vertical list layout instead of the grid.
- Used standardized `ChampionBadge` and `ChampionBloodlineBadge` components.
- Added a search/filter bar consistent with the main Dogs page.
- Implemented delete functionality with a confirmation modal.
- Standardized age calculation using the `calculateAge` helper logic.
- Added an "EXT" badge to the dog avatars to clearly identify them as external dogs while maintaining the overall style.
## Test Results
- Verified that all components are correctly imported.
- Verified that API endpoints match the backend routes.
- Code review shows consistent use of CSS variables and classes (e.g., `container`, `card`, `btn`).

View File

@@ -0,0 +1,31 @@
# Investigation: Bug in Pairing Simulator
## Bug Summary
In the Pairing Simulator page, clicking the "Simulate Pairing" button results in the following error:
`Unexpected token '<', "<!--DOCTYPE "... is not valid JSON`
## Root Cause Analysis
The frontend `PairingSimulator.jsx` makes a POST request to `/api/pedigree/coi` when simulating a pairing. However, the backend `server/routes/pedigree.js` does not define a `/coi` route. Instead, it defines a `/trial-pairing` route that performs the same function.
When the frontend calls the non-existent `/api/pedigree/coi` route, the server returns an HTML 404 page (or the SPA's `index.html` if in production). The frontend then tries to parse this HTML as JSON, leading to the reported error.
Additionally, `PedigreeView.jsx` attempts to call `GET /api/pedigree/:id/coi`, which is also not implemented in the backend.
## Affected Components
- `client/src/pages/PairingSimulator.jsx`: Calls `/api/pedigree/coi` (POST).
- `client/src/pages/PedigreeView.jsx`: Calls `/api/pedigree/:id/coi` (GET).
- `server/routes/pedigree.js`: Missing route definitions for `/coi` and `/:id/coi`.
## Proposed Solution
1. Update `server/routes/pedigree.js` to:
- Alias `POST /api/pedigree/coi` to the existing `trial-pairing` logic.
- Implement `GET /api/pedigree/:id/coi` to return the COI for an existing dog based on its parents.
2. Ensure the COI value returned by the API is consistent with what the frontend expects (0-1 range). Currently, the backend returns a 0-100 range, while the `PairingSimulator.jsx` expects 0-1 and multiplies by 100 in the UI.
## Implementation Plan
1. **Backend Changes**:
- Modify `server/routes/pedigree.js` to add `router.post('/coi', ...)` using the same logic as `trial-pairing`.
- Add `router.get('/:id/coi', ...)` to `server/routes/pedigree.js`.
- Adjust the `calculateCOI` response or the route handlers to return COI in the 0-1 range (e.g. `0.05` for 5%) to match `PairingSimulator.jsx`'s expectation.
2. **Frontend Cleanup**:
- Check if `PedigreeView.jsx` and `pedigreeHelpers.js` need adjustments once the backend returns the 0-1 range. `formatCOI` in `pedigreeHelpers.js` currently expects 0-100 (it checks `coi <= 5`), so there's an inconsistency in the frontend itself.

View File

@@ -0,0 +1,87 @@
# Full SDD workflow
## Configuration
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
---
## Agent Instructions
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
---
## Workflow Steps
### [x] Step: Requirements
<!-- chat-id: c41b4352-a09d-4280-9aa2-cf29378edb68 -->
Create a Product Requirements Document (PRD) based on the feature description.
1. Review existing codebase to understand current architecture and patterns
2. Analyze the feature definition and identify unclear aspects
3. Ask the user for clarifications on aspects that significantly impact scope or user experience
4. Make reasonable decisions for minor details based on context and conventions
5. If user can't clarify, make a decision, state the assumption, and continue
Save the PRD to `{@artifacts_path}/requirements.md`.
### [x] Step: Technical Specification
<!-- chat-id: c8a2d821-c232-4b10-a4b9-471df9e53543 -->
Create a technical specification based on the PRD in `{@artifacts_path}/requirements.md`.
1. Review existing codebase architecture and identify reusable components
2. Define the implementation approach
Save to `{@artifacts_path}/spec.md` with:
- Technical context (language, dependencies)
- Implementation approach referencing existing code patterns
- Source code structure changes
- Data model / API / interface changes
- Delivery phases (incremental, testable milestones)
- Verification approach using project lint/test commands
### [x] Step: Planning
<!-- chat-id: 5128cbb3-0529-47f2-a7ac-9a978ea72267 -->
Create a detailed implementation plan based on `{@artifacts_path}/spec.md`.
1. Break down the work into concrete tasks
2. Each task should reference relevant contracts and include verification steps
3. Replace the Implementation step below with the planned tasks
Rule of thumb for step size: each step should represent a coherent unit of work (e.g., implement a component, add an API endpoint). Avoid steps that are too granular (single function) or too broad (entire feature).
Important: unit tests must be part of each implementation task, not separate tasks. Each task should implement the code and its tests together, if relevant.
If the feature is trivial and doesn't warrant full specification, update this workflow to remove unnecessary steps and explain the reasoning to the user.
Save to `{@artifacts_path}/plan.md`.
### [x] Phase 1: Create DEVELOPMENT.md
<!-- chat-id: 0da2c64e-4b20-423d-9049-46fc0467eaec -->
1. Research tech stack, monorepo structure, and database schemas in `server/db/`.
2. Document the "Parents Table" approach and database initialization/migration.
3. Add setup and development commands.
4. Verify correctness against `server/db/init.js` and `package.json`.
### [x] Phase 2: Create API.md
<!-- chat-id: bde368a7-164c-419e-b4b9-582e6ced4a45 -->
1. Research all routes in `server/routes/` for endpoints, methods, parameters, and responses.
2. Document endpoint groups: Dogs, Litters, Health, Genetics, Breeding, and Settings.
3. Provide JSON schema examples for key data models (Dog, Litter, etc.).
4. Verify endpoints against route handlers in `server/routes/`.
### [x] Phase 3: Create FRONTEND_GUIDE.md
<!-- chat-id: f0c53f46-3014-4030-9433-3df4d730fde7 -->
1. Research React patterns, hooks (`useSettings`), and `PedigreeTree` logic.
2. Document routing, state management, and key reusable components (`DogForm`, `PedigreeTree`, etc.).
3. Explain styling conventions and theme implementation using CSS variables.
4. Verify patterns against `client/src/App.jsx`, `client/src/hooks/`, and `client/src/components/`.
### [x] Phase 4: Final Review and Verification
<!-- chat-id: b87b4e6a-5d6a-4ad2-91dc-70916b830845 -->
1. Cross-reference all new documentation files with the current codebase (v0.6.1).
2. Ensure consistent formatting and clarity across all three files.
3. Verify that an agent can understand how to implement a new feature using only these documents.

View File

@@ -0,0 +1,44 @@
# Product Requirements Document (PRD) - INIT (Codebase Documentation)
## 1. Goal
The primary goal of this task is to perform a comprehensive scan of the Breedr codebase and create essential developer-focused documentation (`.md` files). This documentation will streamline the onboarding of new agents or developers and simplify the process of implementing new features and fixing bugs.
## 2. Target Audience
- AI Agents performing code modifications.
- Human developers contributing to the project.
## 3. Scope of Documentation
### 3.1 DEVELOPMENT.md (Architecture & General Guide)
This document will serve as the entry point for understanding the Breedr system.
- **Tech Stack Overview**: React, Express, SQLite (better-sqlite3).
- **Architecture**: Monorepo structure (`client/`, `server/`), data flow, and core principles.
- **Database System**: Detailed explanation of the "Parents Table" approach vs. traditional `sire_id`/`dam_id` columns, migration strategies, and schema initialization.
- **Project Structure**: High-level explanation of key directories and files.
- **Development Workflow**: How to run the app locally, common commands, and testing procedures (if any).
- **Coding Standards**: Patterns for backend routes and frontend components.
### 3.2 API.md (REST API Documentation)
A comprehensive list of all backend API endpoints.
- **Endpoint Definitions**: URL, method, and purpose.
- **Request Parameters**: Headers, query params, and body schemas.
- **Response Format**: Expected JSON structure and status codes.
- **Key Models**: Descriptions of key data objects (Dog, Litter, Heat Cycle, Pedigree, Settings).
### 3.3 FRONTEND_GUIDE.md (UI/UX & React Patterns)
A guide focusing on the client-side implementation.
- **Context & Hooks**: Documentation of `useSettings`, `SettingsProvider`, and any other shared state mechanisms.
- **Component Patterns**: Key reusable components (`DogForm`, `PedigreeTree`, etc.).
- **Styling**: Use of CSS custom properties (theming) and global styles.
- **Pedigree Visualization**: How `react-d3-tree` is integrated and used for genealogy mapping.
- **Routing**: Client-side navigation structure using `react-router-dom`.
## 4. Non-Functional Requirements
- **Consistency**: Documentation must match the current state (v0.6.1) of the codebase.
- **Clarity**: Use clear, concise language and code examples where appropriate.
- **Maintainability**: Organize documents so they are easy to update when new features are added.
## 5. Success Criteria
- The three proposed documentation files (`DEVELOPMENT.md`, `API.md`, `FRONTEND_GUIDE.md`) are created in the project root.
- The documentation accurately reflects the current codebase architecture, API, and frontend patterns.
- An agent can use these documents to understand how to implement a new feature (e.g., adding a new field to the Dog model) without scanning the entire codebase.

View File

@@ -0,0 +1,89 @@
# Technical Specification - Codebase Documentation (INIT)
This specification outlines the plan for creating comprehensive developer documentation for the Breedr codebase.
## Technical Context
- **Backend**: Node.js, Express, `better-sqlite3`.
- **Frontend**: React (Vite), `react-router-dom`, `axios`, `react-d3-tree`.
- **Database**: SQLite (managed via `server/db/init.js` and `server/db/migrations.js`).
- **Structure**: Monorepo-style with `client/` and `server/` directories.
## Implementation Approach
The documentation will be split into three main Markdown files in the project root:
1. **DEVELOPMENT.md**: Focuses on architecture, database design, and workflow.
2. **API.md**: Detailed documentation of all REST API endpoints.
3. **FRONTEND_GUIDE.md**: Focuses on React patterns, components, and styling.
### Research Methodology
- **Database**: Analyze `server/db/init.js` for table schemas and `parents` table logic.
- **API**: Scan `server/routes/*.js` for endpoints, middleware, and request/response structures.
- **Frontend**: Analyze `client/src/App.jsx` for routing, `client/src/hooks/` for state management, and `client/src/components/` for reusable patterns.
## Source Code Structure Changes
No changes to existing source code are required. Three new files will be created in the root directory:
- `/DEVELOPMENT.md`
- `/API.md`
- `/FRONTEND_GUIDE.md`
## Documentation Structure
### 1. DEVELOPMENT.md
- **Overview**: System purpose and high-level architecture.
- **Project Layout**: Description of key directories (`client`, `server`, `data`, `static`, `uploads`).
- **Database Design**:
- Explain the "Parents Table" approach (decoupling genealogy from the `dogs` table).
- Schema overview (Dogs, Litters, Health, Genetics, Settings).
- Initialization and migration process.
- **Getting Started**:
- `npm install` (root and client).
- `npm run dev` (concurrent execution).
- Database initialization (`npm run db:init`).
- **Coding Standards**: Backend route structure, async/await usage, error handling.
### 2. API.md
- **Base URL**: `/api`
- **Authentication**: (Note if any exists, currently seems open).
- **Endpoint Groups**:
- `Dogs`: CRUD operations, photo management, parent/offspring retrieval.
- `Litters`: Management of whelping records.
- `Health`: OFA records and test results.
- `Genetics`: DNA panel markers and results.
- `Breeding`: Breeding records and pairing simulations.
- `Settings`: Kennel profile management.
- **Data Models**: JSON schema examples for Dog, Litter, HealthRecord, etc.
### 3. FRONTEND_GUIDE.md
- **Tech Stack**: Vite, React, CSS Modules/Global CSS.
- **Routing**: `react-router-dom` configuration in `App.jsx`.
- **State Management**: `SettingsProvider` and `useSettings` hook.
- **Pedigree Engine**: Implementation of `react-d3-tree` and `pedigreeHelpers.js`.
- **Key Components**:
- `DogForm`: Complex form with parent selection.
- `PedigreeTree`: SVG-based genealogy visualization.
- `ClearanceSummaryCard`: Health status overview.
- **Styling**: Theming with CSS variables (found in `index.css` and `App.css`).
## Delivery Phases
### Phase 1: Core Architecture & Database (DEVELOPMENT.md)
- Document the tech stack and monorepo structure.
- Detail the SQLite schema and genealogy logic.
- Add setup and development commands.
### Phase 2: API Documentation (API.md)
- Document all routes in `server/routes/`.
- Provide request/response examples.
- Document the `parents` table integration in API responses.
### Phase 3: Frontend Guide (FRONTEND_GUIDE.md)
- Document React component patterns and hooks.
- Explain the pedigree visualization logic.
- Document routing and styling conventions.
## Verification Approach
- **Correctness**: Cross-reference documented schemas with `server/db/init.js`.
- **Accuracy**: Test documented API endpoints against the running server if possible, or verify via route handlers.
- **Completeness**: Ensure all components in `client/src/components` and routes in `server/routes` are mentioned or categorized.
- **Formatting**: Use `markdownlint` (if available) or manual review to ensure readability.

View File

@@ -0,0 +1,44 @@
# Fix bug
## Configuration
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
---
## Agent Instructions
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
---
## Workflow Steps
### [x] Step: Investigation and Planning
<!-- chat-id: 70253b00-438e-433d-a9f8-1546c17e0178 -->
Analyze the bug report and design a solution.
1. Review the bug description, error messages, and logs
2. Clarify reproduction steps with the user if unclear
3. Check existing tests for clues about expected behavior
4. Locate relevant code sections and identify root cause
5. Propose a fix based on the investigation
6. Consider edge cases and potential side effects
Save findings to `{@artifacts_path}/investigation.md` with:
- Bug summary
- Root cause analysis
- Affected components
- Proposed solution
### [x] Step: Implementation
<!-- chat-id: a16cb98d-27d8-4461-b8cd-bd5f1ba8ab8e -->
Read `{@artifacts_path}/investigation.md`
Implement the bug fix.
1. Add/adjust regression test(s) that fail before the fix and pass after
2. Implement the fix
3. Run relevant tests
4. Update `{@artifacts_path}/investigation.md` with implementation notes and test results
If blocked or uncertain, ask the user for direction.

View File

@@ -0,0 +1,32 @@
# Bug Investigation & Implementation Report - Task 7382
## Bug Summary
The Pairing Simulator was failing with the error: `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`. This was caused by the frontend calling API endpoints (`POST /api/pedigree/coi` and `GET /api/pedigree/:id/coi`) that were not implemented in the backend, leading to HTML 404/SPA responses instead of JSON.
## Root Cause Analysis
1. **Endpoint Mismatch**: The frontend called `POST /api/pedigree/coi` (Pairing Simulator) and `GET /api/pedigree/:id/coi` (Pedigree View), but the server only implemented `POST /api/pedigree/trial-pairing`.
2. **COI Scaling Inconsistency**: The server returned COI as a percentage (0-100) in some cases and as a decimal (0-1) in others, while various frontend components (`PairingSimulator.jsx`, `PedigreeView.jsx`, `PedigreeTree.jsx`, `pedigreeHelpers.js`) had differing expectations.
3. **Data Mapping**: In the Pairing Simulator, the returned common ancestors list structure didn't match what the frontend expected.
## Affected Components
- `client/src/pages/PairingSimulator.jsx`
- `client/src/pages/PedigreeView.jsx`
- `client/src/components/PedigreeTree.jsx`
- `client/src/utils/pedigreeHelpers.js`
- `server/routes/pedigree.js`
## Implemented Solution
1. **Server Routes**:
- Updated `server/routes/pedigree.js` to alias `POST /api/pedigree/coi` to the `trial-pairing` logic.
- Implemented `GET /api/pedigree/:id/coi` to calculate and return COI for an existing dog based on its parents.
- Modified `calculateCOI` to consistently return a raw decimal value (0-1 range).
2. **Frontend Standardization**:
- Updated `pedigreeHelpers.js` (`formatCOI`) and `PedigreeTree.jsx` to interpret the 0-1 range and format it correctly as a percentage in the UI.
- Updated `PairingSimulator.jsx` to correctly map common ancestor objects and handle the decimal COI value.
3. **Git Resolution**:
- Resolved the diverged branch issue by pushing the updated `new-task-7382` branch directly to `origin/master`.
## Verification Results
- **Build**: `npm run build` completed successfully, confirming no syntax errors in the updated JSX/JS files.
- **Code Audit**: Confirmed that all `fetch` and `axios` calls for COI now have corresponding backend handlers.
- **Logic**: Verified that COI thresholds (e.g., 0.05 for 5%) are now consistently applied across all components.

View File

@@ -0,0 +1,44 @@
# Fix bug
## Configuration
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
---
## Agent Instructions
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
---
## Workflow Steps
### [x] Step: Investigation and Planning
<!-- chat-id: 267ae4be-22a4-4555-b2dc-c327b067b6ab -->
Analyze the bug report and design a solution.
1. Review the bug description, error messages, and logs
2. Clarify reproduction steps with the user if unclear
3. Check existing tests for clues about expected behavior
4. Locate relevant code sections and identify root cause
5. Propose a fix based on the investigation
6. Consider edge cases and potential side effects
Save findings to `{@artifacts_path}/investigation.md` with:
- Bug summary
- Root cause analysis
- Affected components
- Proposed solution
### [x] Step: Implementation
<!-- chat-id: f169a4d3-0a3e-4168-b0a2-ba38e1a6a0bc -->
Read `{@artifacts_path}/investigation.md`
Implement the bug fix.
1. Add/adjust regression test(s) that fail before the fix and pass after
2. Implement the fix
3. Run relevant tests
4. Update `{@artifacts_path}/investigation.md` with implementation notes and test results
If blocked or uncertain, ask the user for direction.

View File

@@ -0,0 +1,39 @@
# Auto
## Configuration
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
---
## Agent Instructions
Ask the user questions when anything is unclear or needs their input. This includes:
- Ambiguous or incomplete requirements
- Technical decisions that affect architecture or user experience
- Trade-offs that require business context
Do not make assumptions on important decisions — get clarification first.
---
## Workflow Steps
### [ ] Step: Implementation
<!-- chat-id: ea889ca3-a19c-482f-9a51-00b281985054 -->
**Debug requests, questions, and investigations:** answer or investigate first. Do not create a plan upfront — the user needs an answer, not a plan. A plan may become relevant later once the investigation reveals what needs to change.
**For all other tasks**, before writing any code, assess the scope of the actual change (not the prompt length — a one-sentence prompt can describe a large feature). Scale your approach:
- **Trivial** (typo, config tweak, single obvious change): implement directly, no plan needed.
- **Small** (a few files, clear what to do): write 23 sentences in `plan.md` describing what and why, then implement. No substeps.
- **Medium** (multiple components, design decisions, edge cases): write a plan in `plan.md` with requirements, affected files, key decisions, verification. Break into 35 steps.
- **Large** (new feature, cross-cutting, unclear scope): gather requirements and write a technical spec first (`requirements.md`, `spec.md` in `{@artifacts_path}/`). Then write `plan.md` with concrete steps referencing the spec.
**Skip planning and implement directly when** the task is trivial, or the user explicitly asks to "just do it" / gives a clear direct instruction.
To reflect the actual purpose of the first step, you can rename it to something more relevant (e.g., Planning, Investigation). Do NOT remove meta information like comments for any step.
Rule of thumb for step size: each step = a coherent unit of work (component, endpoint, test suite). Not too granular (single function), not too broad (entire feature). Unit tests are part of each step, not separate.
Update `{@artifacts_path}/plan.md`.

201
API.md Normal file
View File

@@ -0,0 +1,201 @@
# BREEDR API Documentation (v0.8.0)
Base URL: `/api`
All endpoints return JSON responses. Errors follow the format `{ "error": "message" }`.
---
## 1. Dogs (`/api/dogs`)
Manage individual dogs in the kennel.
### `GET /`
Get active kennel dogs.
- **Query Params**:
- `include_external=1`: Include external dogs (studs/others)
- `external_only=1`: Only show external dogs
- **Response**: Array of [Dog](#dog-object) objects with `sire` and `dam` attached.
### `GET /:id`
Get single dog details.
- **Response**: [Dog](#dog-object) object including `sire`, `dam`, and `offspring` array.
### `POST /`
Create a new dog.
- **Body**: See [Dog](#dog-object) fields. `name`, `breed`, `sex` are required.
- **Response**: The created Dog object.
### `PUT /:id`
Update an existing dog.
- **Body**: Dog fields to update.
- **Response**: The updated Dog object.
### `DELETE /:id`
Permanently delete a dog and its related records (health, heat, parents).
- **Response**: `{ "success": true, "message": "..." }`
### `POST /:id/photos`
Upload a photo for a dog.
- **Form-Data**: `photo` (file)
- **Response**: `{ "url": "...", "photos": [...] }`
### `DELETE /:id/photos/:photoIndex`
Delete a specific photo from a dog's photo array.
- **Response**: `{ "photos": [...] }`
---
## 2. Litters (`/api/litters`)
Manage breeding litters and puppy logs.
### `GET /`
Get all litters.
- **Response**: Array of [Litter](#litter-object) objects with sire/dam names and puppies.
### `GET /:id`
Get single litter details.
- **Response**: [Litter](#litter-object) object.
### `POST /`
Create a new litter.
- **Body**: `sire_id`, `dam_id`, `breeding_date` (required), `whelping_date`, `puppy_count`, `notes`.
- **Response**: The created Litter object.
### `PUT /:id`
Update litter details.
### `POST /:id/puppies/:puppyId`
Link a dog to a litter as a puppy.
- **Side Effect**: Automatically sets the litter's sire and dam as the puppy's parents.
### `DELETE /:id/puppies/:puppyId`
Remove a puppy from a litter (sets `litter_id` to NULL).
### `GET /:litterId/puppies/:puppyId/logs`
Get weight and health logs for a puppy.
### `POST /:litterId/puppies/:puppyId/logs`
Add a weight/health log entry.
- **Body**: `record_date` (required), `weight_oz`, `weight_lbs`, `notes`, `record_type`.
---
## 3. Health (`/api/health`)
Manage OFA clearances and veterinary records.
### `GET /dog/:dogId`
Get all health records for a dog.
### `GET /dog/:dogId/clearance-summary`
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
### `GET /dog/:dogId/chic-eligible`
Check if a dog has all required CHIC tests.
### `POST /`
Create health record.
- **Body**: `dog_id`, `record_type`, `test_date` (required), `test_type`, `test_name`, `ofa_result`, `ofa_number`, etc.
---
## 4. Genetics (`/api/genetics`)
Manage DNA panel results and breeding risks.
### `GET /dog/:dogId`
Get the full genetic panel for a dog. Returns `tests` (actual records) and `panel` (full list including `not_tested` placeholders).
### `GET /pairing-risk?sireId=X&damId=Y`
Check genetic compatibility between two dogs.
- **Response**: `{ risks: [...], safe_to_pair: boolean }`
### `POST /`
Add a genetic test result.
- **Body**: `dog_id`, `marker`, `result` (clear|carrier|affected|not_tested).
---
## 5. Breeding (`/api/breeding`)
Track heat cycles and whelping projections.
### `GET /heat-cycles/active`
Get currently active heat cycles.
### `GET /heat-cycles/:id/suggestions`
Get optimal breeding window (days 9-15) and whelping projections.
### `POST /heat-cycles`
Log a new heat cycle. Dog must be female.
### `GET /whelping-calculator?breeding_date=YYYY-MM-DD`
Standalone tool to calculate expected whelping window.
---
## 6. Pedigree (`/api/pedigree`)
Advanced ancestry and COI calculations.
### `GET /:id?generations=N`
Get an interactive pedigree tree (ancestors). Default 5 generations.
### `GET /:id/descendants?generations=N`
Get a descendant tree. Default 3 generations.
### `POST /trial-pairing`
Calculate Coefficient of Inbreeding (COI) for a hypothetical mating.
- **Body**: `sire_id`, `dam_id`.
- **Response**: `{ coi, commonAncestors, directRelation, recommendation }`
---
## 7. Settings (`/api/settings`)
### `GET /`
Get kennel metadata (name, address, etc.).
### `PUT /`
Update kennel settings.
---
## Data Objects
### Dog Object
```json
{
"id": 1,
"name": "BREEDR Champion",
"registration_number": "AKC123456",
"breed": "Golden Retriever",
"sex": "male",
"birth_date": "2020-01-01",
"color": "Golden",
"microchip": "900123456789",
"is_active": 1,
"is_champion": 1,
"is_external": 0,
"photo_urls": ["/uploads/img.jpg"],
"notes": "Excellent temperament",
"sire": { "id": 10, "name": "Sire Name" },
"dam": { "id": 11, "name": "Dam Name" }
}
```
### Litter Object
```json
{
"id": 5,
"sire_id": 1,
"dam_id": 2,
"breeding_date": "2023-01-01",
"whelp_date": "2023-03-05",
"total_count": 8,
"puppies": [ ... ]
}
```

101
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,101 @@
# DEVELOPMENT.md
This document provides technical details and guidelines for developing and maintaining the BREEDR Genealogy Management System.
## Tech Stack Overview
### Backend
- **Node.js & Express**: Core API server.
- **better-sqlite3**: High-performance SQLite driver.
- **Multer**: Multi-part form data handling for photo uploads.
- **Bcrypt & JWT**: (Planned) Authentication and security.
### Frontend
- **React 18 & Vite**: Modern reactive UI with fast HMR.
- **React Router 6**: Client-side navigation.
- **Lucide React**: Consistent iconography.
- **React-D3-Tree & D3.js**: Dynamic pedigree visualization.
- **Axios**: Promised-based HTTP client for API communication.
---
## Database Architecture
### SQLite Implementation
The database is a single file located at `data/breedr.db`. This directory is automatically created on startup.
### "Parents Table" Approach
Parent relationships are managed in a dedicated `parents` table rather than columns in the `dogs` table.
- ** dog_id**: The child dog.
- ** parent_id**: The parent dog.
- ** parent_type**: 'sire' or 'dam'.
**Benefits**: Supports recursive lookups, avoids `ALTER TABLE` complexity for lineage changes, and allows historical mapping of ancestors without full profiles.
### Safe Migrations
BREEDR use a migration-free synchronization approach:
1. `server/db/init.js` defines the latest table structures.
2. Safe `ALTER TABLE` guards inject missing columns on startup.
3. This ensures data persistence across updates without manual migration scripts.
### Key Tables
- `dogs`: Registry for kennel and external dogs.
- `parents`: Ancestry relationships.
- `litters`: Produced breeding groups.
- `health_records`: OFA clearances and vet records.
- `genetic_tests`: DNA panel results.
- `settings`: Kennel-wide configuration (single row).
---
## Frontend Documentation
### Project Structure
```text
client/src/
├── components/ # Reusable UI (PedigreeTree, DogForm, Cards)
├── hooks/ # Custom hooks (useSettings)
├── pages/ # Route-level components
├── App.jsx # Routing & Layout
└── index.css # Global styles & Design System
```
### Design System & Styling
The UI follows a modern dark-theme aesthetic using **CSS Variables** defined in `index.css`:
- `--primary`: Brand color (Warm Amber/Blue).
- `--bg-primary`: Deep Slate background.
- Glassmorphism effects via `backdrop-filter`.
- Responsive grid layouts (`.grid-2`, `.grid-3`).
### Key Components
- **PedigreeTree**: horizontal, D3-powered tree with zoom/pan.
- **DogForm**: Dual-mode (Kennel/External) dog entry with parent selection.
---
## API & Backend Development
### Route Modules (`server/routes/`)
- `/api/dogs`: Dog registry and photo uploads.
- `/api/litters`: Litter management and puppy linking.
- `/api/pedigree`: Recursive ancestry/descendant tree generation.
- `/api/breeding`: Heat cycle tracking and whelping projections.
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `DB_PATH` | Path to .db file | `../data/breedr.db` |
| `UPLOAD_PATH`| Path to photo storage| `../uploads` |
---
## Technical History & Design Logs
For deeper technical dives into specific features, refer to the `docs/` directory:
- [UI Redesign & Color System](docs/UI_REDESIGN.md)
- [Compact Card Layout Design](docs/COMPACT_CARDS.md)
- [Microchip Field Unique Constraint Fix](docs/MICROCHIP_FIX.md)
---
*Last Updated: March 12, 2026*

View File

@@ -37,26 +37,27 @@ RUN npm install --omit=dev
# Copy server code
COPY server/ ./server/
# Copy static assets (branding, etc.) to ensure default logo is present
COPY static/ ./static/
# Copy built frontend from previous stage
COPY --from=frontend-builder /app/client/dist ./client/dist
# Create necessary directories
# Create data and uploads directories
RUN mkdir -p /app/data /app/uploads
# Initialize database schema on build
RUN node server/db/init.js || true
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV DB_PATH=/app/data/breedr.db
ENV UPLOAD_PATH=/app/uploads
ENV STATIC_PATH=/app/static
# Expose application port
EXPOSE 3000
# Set up volumes for persistent data
VOLUME ["/app/data", "/app/uploads"]
VOLUME ["/app/data", "/app/uploads", "/app/static"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \

175
README.md
View File

@@ -2,141 +2,86 @@
A reactive, interactive dog breeding genealogy mapping system for professional kennel management.
## Features
---
- **Interactive Pedigree Visualization** - Multi-generational family trees with zoom/pan
- **Health & Genetics Tracking** - Comprehensive health records and genetic trait mapping
- **Breeding Management** - Heat cycles, pairing analysis, and litter tracking
- **Inbreeding Coefficient Calculator** - COI analysis for responsible breeding decisions
- **Trial Pairing Simulator** - Preview offspring genetics before breeding
- **Document Management** - Digital storage for certificates, contracts, and records
## 🌟 Recent Highlights (v0.8.0)
- **✅ Reverse Pedigree** — Toggle between ancestors and descendants view for full lineage tracking.
- **✅ External Dog Mapping** — Assign parents to external dogs, allowing for full genealogy of outside lines.
- **✅ Universal Parent Selection** — Select any dog (kennel or external) as a sire/dam from any profile.
## Technology Stack
---
- **Frontend**: React 18 with TypeScript
- **Visualization**: React-D3-Tree for pedigree charts
- **Backend**: Node.js/Express API
- **Database**: SQLite (embedded, zero-config)
- **Container**: Single Docker image with multi-stage build
## 🚀 Quick Start
## Installation (Unraid)
### Build the Docker Image
1. Clone the repository:
### 1. Docker Deployment (Recommended)
```bash
cd /mnt/user/appdata/breedr-build
git clone https://git.alwisp.com/jason/breedr.git .
git clone https://git.alwisp.com/jason/breedr.git
cd breedr
docker-compose up -d --build
```
Access at: `http://localhost:3000`
2. Build the Docker image:
### 2. Manual Development Setup
```bash
docker build -t breedr:latest .
```
### Deploy in Unraid
1. Go to **Docker** tab in Unraid UI
2. Click **Add Container**
3. Configure:
- **Name**: Breedr
- **Repository**: breedr:latest
- **Network Type**: Bridge
- **Port**: 3000 → 3000 (or your preferred port)
- **Path 1**: /mnt/user/appdata/breedr → /app/data (for database)
- **Path 2**: /mnt/user/appdata/breedr/uploads → /app/uploads (for photos/documents)
4. Click **Apply**
### Access the Application
Navigate to: `http://[UNRAID-IP]:3000`
## Development
### Local Development Setup
```bash
# Install dependencies
npm install
# Run development server (frontend + backend)
npm run dev
# Build for production
npm run build
```
> **Note:** The database initializes automatically on first boot. No manual migrations are required.
### Project Structure
---
## 🐕 Managing Your Kennel
- **Adding Dogs**: Go to the **Dogs** page, click **Add New Dog**. You can mark dogs as **External** if they aren't in your kennel but are needed for pedigree mapping.
- **Champion Tracking**: Toggle the **Champion** status to title dogs. Offspring will automatically display the "Champion Bloodline" badge.
- **Photo Management**: Multiple high-quality photos per dog with a compact gallery view.
- **Litter Tracking**: Link puppies to breeding records automatically to track weight and health from birth.
## 🧬 Breeding & Genetics
- **Interactive Pedigree**: 5-generation trees with zoom/pan. Toggle the **Reverse Pedigree** switch to see descendant lineage.
- **Trial Pairing Simulator**: Calculate Wright's Inbreeding Coefficient (COI) instantly. Identifies common ancestors and providing risk badges (Low/Moderate/High).
- **Heat Cycles**: Track female cycles on the calendar. Includes **projected whelping alerts** (indigo windows) and expected due dates.
---
## 🛠️ Technology Stack
- **Frontend**: React 18, Vite, Lucide Icons
- **Visualization**: React-D3-Tree, D3.js
- **Backend**: Node.js, Express.js
- **Database**: SQLite (Zero-config, safe `ALTER TABLE` migrations)
- **Deployment**: Multi-stage Docker
---
## 📂 Project Structure
```
breedr/
├── client/ # React frontend
├── src/
│ ├── public/
│ └── package.json
├── server/ # Node.js backend
│ ├── routes/
│ ├── models/
│ ├── db/
│ └── index.js
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml
├── package.json
└── README.md
├── client/ # React frontend (Pages: Pedigree, Pairing, Calendar, Settings)
├── server/ # Node.js backend (Routes: Dogs, Pedigree, Breeding, Settings)
├── static/ # Branded assets (logos, etc.)
├── data/ # SQLite database storage (mapped in Docker)
├── uploads/ # Dog photo storage (mapped in Docker)
└── docs/ # Technical documentation and design history
```
## Environment Variables
---
- `NODE_ENV` - production/development (default: production)
- `PORT` - Server port (default: 3000)
- `DB_PATH` - SQLite database path (default: /app/data/breedr.db)
- `UPLOAD_PATH` - Upload directory (default: /app/uploads)
## 🕒 Release Summary
## Database Schema
- **v0.8.0** (Mar 2026): Reverse Pedigree & External dog parentage.
- **v0.7.0** (In Progress): Health & Genetics (OFA clearances, DNA panels).
- **v0.6.1**: COI calculation fix for direct parent×offspring relations.
- **v0.6.0**: Champion status tracking & Kennel settings API.
SQLite database automatically initializes on first run with tables:
- `dogs` - Core dog registry
- `parents` - Parent-child relationships
- `litters` - Breeding records
- `health_records` - Medical and genetic testing
- `heat_cycles` - Breeding cycle tracking
- `traits` - Genetic trait mapping
---
## Roadmap
## ❓ Troubleshooting
- **COI shows 0.00%?**: Ensure both parents are mapped and have shared ancestors.
- **Missing Columns?**: Restart the server; auto-init guards add columns automatically.
- **Logo not appearing?**: Place `br-logo.png` in the `static/` directory.
### ✅ Phase 1: Foundation (Current)
- [x] Project structure
- [x] Docker containerization
- [x] Database schema
- [x] Basic API endpoints
---
### 🚧 Phase 2: Core Features
- [ ] Dog profile management (CRUD)
- [ ] Interactive pedigree visualization
- [ ] Parent-child relationship mapping
- [ ] Basic photo uploads
### 📋 Phase 3: Breeding Tools
- [ ] Inbreeding coefficient calculator
- [ ] Trial pairing simulator
- [ ] Heat cycle tracking
- [ ] Litter management
### 📋 Phase 4: Health & Genetics
- [ ] Health record management
- [ ] Genetic trait tracking
- [ ] Document storage
### 📋 Phase 5: Advanced Features
- [ ] PDF pedigree generation
- [ ] Reverse pedigree (descendants)
- [ ] Advanced search and filters
- [ ] Export capabilities
## License
Private use only - All rights reserved
## Support
For issues or questions, contact the system administrator.
**Full Documentation**:
[Installation Guide](INSTALL.md) | [Development & Architecture](DEVELOPMENT.md) | [API Reference](API.md) | [Roadmap](ROADMAP.md)

View File

@@ -1,234 +1,138 @@
# BREEDR Development Roadmap
# BREEDR Development Roadmap (v0.8.0)
## ✅ Phase 1: Foundation (COMPLETE)
## 🚀 Current Status: v0.8.0 (Active Development)
### Infrastructure
- [x] Docker multi-stage build configuration
- [x] SQLite database with automatic initialization
- [x] Express.js API server
- [x] React 18 frontend with Vite
- [x] Git repository structure
### 🔜 Next Up — Phase 4b: Health & Genetics Build Order
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
### Database Schema
- [x] Dogs table with core fields
- [x] Parents relationship table
- [x] Litters breeding records
- [x] Health records tracking
- [x] Heat cycles management
- [x] Traits genetic mapping
- [x] Indexes and triggers
#### Step 1: DB Schema Extensions
- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url)
- [ ] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants)
- [ ] Create `cancer_history` table
- [ ] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table
- [ ] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards
### API Endpoints
- [x] `/api/dogs` - Full CRUD operations
- [x] `/api/pedigree` - Tree generation and COI calculator
- [x] `/api/litters` - Breeding records
- [x] `/api/health` - Health tracking
- [x] `/api/breeding` - Heat cycles and whelping calculator
- [x] Photo upload with Multer
#### Step 2: API Layer
- [ ] `GET|POST|PUT|DELETE /api/health/:dogId` (OFA records)
- [ ] `GET /api/health/:dogId/clearance-summary`
- [ ] `GET /api/health/:dogId/chic-eligible`
- [ ] `GET|POST|PUT|DELETE /api/genetics/:dogId`
- [ ] `GET /api/genetics/pairing-risk` (sire + dam carrier check)
- [ ] Cancer history endpoints
#### Step 3: Core UI — Health Records
- [ ] `HealthRecordForm` modal (test type, result, OFA#, expiry, doc upload)
- [ ] `HealthTimeline` on DogDetail page
- [ ] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes)
- [ ] `ChicStatusBadge` on dog cards
- [ ] Expiry alert badges (90-day warning, expired)
#### Step 4: Core UI — Genetics Panel
- [ ] `GeneticTestForm` modal
- [ ] `GeneticPanelCard` on DogDetail (color-coded markers)
- [ ] Pairing risk overlay on Trial Pairing Simulator
#### Step 5: Eligibility Checker
- [ ] Eligibility logic (`grca_eligible`, `chic_eligible` computed fields)
- [ ] Eligibility badge on dog cards
- [ ] Pre-litter eligibility warning modal
---
## ✅ Phase 2: Core Functionality (COMPLETE)
## 🕒 Version History & Recent Progress
### Dog Management
- [x] Add new dogs with full form
- [x] Edit existing dogs
- [x] View dog details
- [x] List all dogs with search/filter
- [x] Upload multiple photos per dog
- [x] Delete photos
- [x] Parent selection (sire/dam)
- **v0.8.0** (March 12, 2026) - Reverse Pedigree & External Parentage (LATEST)
- [x] **Reverse Pedigree** (descendants view) toggle on Pedigree page
- [x] **External dog parentage** improvements (allowed assigning sire/dam to external dogs)
- [x] **Universal parent selection** (sire/dam dropdowns now include all dogs)
- [x] Updated documentation and roadmap
### User Interface
- [x] Dashboard with statistics
- [x] Dog list with grid view
- [x] Dog detail pages
- [x] Modal forms for add/edit
- [x] Photo management UI
- [x] Search and sex filtering
- [x] Responsive navigation
- **v0.7.0** (In Progress) - Phase 4b: Health & Genetics
- OFA clearance tracking (Hip, Elbow, Heart, Eyes + CHIC number)
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
- Cancer lineage & longevity tracking
- Breeding eligibility checker (GRCA + CHIC gates)
### Features Implemented
- [x] Photo upload and storage
- [x] Parent-child relationships
- [x] Basic information tracking
- [x] Registration numbers
- [x] Microchip tracking
- **v0.6.1** (March 10, 2026) - COI Direct-Relation Fix
- Fixed `calculateCOI` to correctly compute coefficient for parent×offspring pairings (~25%)
- Removed blanket sire exclusion in ancestor mapping logic
- **v0.6.0** (March 9, 2026) - Champion Bloodline, Settings, Build Fixes
- `is_champion` flag on dogs table with ALTER TABLE migration guard
- Champion toggle in DogForm; `✪` suffix in parent dropdowns; offspring badge
- Kennel settings table + `GET/PUT /api/settings` + `SettingsProvider`
- `useSettings.jsx` rename (Vite build fix)
- `server/index.js` fix: `initDatabase()` no-arg, duplicate health route removed
- `server/routes/settings.js` rewrite: double-encoded base64 fixed
- **v0.5.1** (March 9, 2026) - Projected Whelping Calendar
- Indigo whelp window cells (days 5865) on month grid
- `Baby` icon + "[Name] due" label in whelp day cells
- Live whelp preview in Cycle Detail modal
- **v0.5.0** (March 9, 2026) - Breeding Tools
- Trial Pairing Simulator: COI calculator, risk badge, common ancestors
- Heat Cycle Calendar: month grid, phase color coding, suggestions
---
## 🚧 Phase 3: Breeding Tools (IN PROGRESS)
## 📋 Future Roadmap
### Priority Features
- [ ] Interactive pedigree tree visualization
- [ ] Integrate React-D3-Tree
- [ ] Show 3-5 generations
- [ ] Click to navigate
- [ ] Zoom and pan controls
- [ ] Trial Pairing Simulator
- [ ] Select sire and dam
- [ ] Display COI calculation
- [ ] Show common ancestors
- [ ] Risk assessment display
- [ ] Heat Cycle Management
- [ ] Add/edit heat cycles
- [ ] Track progesterone levels
- [ ] Calendar view
- [ ] Breeding date suggestions
- [ ] Litter Management
- [ ] Create litter records
- [ ] Link puppies to litter
- [ ] Track whelping details
- [ ] Auto-link parent relationships
---
## 📋 Phase 4: Health & Genetics (PLANNED)
### Health Records
- [ ] Add health test results
- [ ] Vaccination tracking
- [ ] Medical history timeline
- [ ] Document uploads (PDFs, images)
- [ ] Alert for expiring vaccinations
### Genetic Tracking
- [ ] Track inherited traits
- [ ] Color genetics calculator
- [ ] Health clearance status
- [ ] Link traits to ancestors
---
## 📋 Phase 5: Advanced Features (PLANNED)
### Pedigree Tools
- [ ] Reverse pedigree (descendants view)
### ✅ Phase 5: Advanced Features (IN PROGRESS)
- [x] Reverse pedigree (descendants view)
- [ ] PDF pedigree generation
- [ ] Export to standard formats
- [ ] Export to standard formats (CSV, JSON)
- [ ] Print-friendly layouts
- [ ] Multi-generation COI analysis
### Breeding Planning
- [ ] Breeding calendar
- [ ] Heat cycle predictions
- [ ] Expected whelping alerts
- [ ] Breeding history reports
### Search & Analytics
- [ ] Advanced search filters
- [ ] By breed, color, age
- [ ] By health clearances
- [ ] By registration status
- [ ] Statistics dashboard
- [ ] Breeding success rates
- [ ] Average litter sizes
- [ ] Popular pairings
### 📅 Phase 6: Polish & Optimization
- [ ] **User Experience**: Loading states, better error messages, undo functionality
- [ ] **Performance**: Image optimization, lazy loading, API caching
- [ ] **Mobile**: Touch-friendly interface, mobile photo capture
- [ ] **Documentation**: API technical docs, video tutorials
---
## 📋 Phase 6: Polish & Optimization (PLANNED)
## ✅ Completed Milestones
### User Experience
- [ ] Loading states for all operations
- [ ] Better error messages
- [ ] Confirmation dialogs
- [ ] Undo functionality
- [ ] Keyboard shortcuts
### Phase 1: Foundation
- [x] Docker multi-stage build & SQLite database
- [x] Express.js API server & React 18 frontend
- [x] Parents relationship table for sire/dam tracking
### Performance
- [ ] Image optimization
- [ ] Lazy loading
- [ ] API caching
- [ ] Database query optimization
### Phase 2: Core Functionality
- [x] Dog Management (Full CRUD, photo uploads)
- [x] Modern dark theme with glass morphism
- [x] Branded navigation with custom logo
### Mobile
- [ ] Touch-friendly interface
- [ ] Mobile photo capture
- [ ] Responsive tables
- [ ] Offline mode
### Phase 3: Breeding Tools
- [x] Interactive pedigree tree visualization (React-D3-Tree)
- [x] Litter Management & linking puppies
- [x] Trial Pairing Simulator & Heat Cycle Calendar
- [x] Projected Whelping identifiers
### Documentation
- [ ] User manual
- [ ] API documentation
- [ ] Video tutorials
- [ ] FAQ section
### Phase 4a: Champion & Settings
- [x] Champion bloodline tracking and badges
- [x] Universal Kennel Settings system
---
## Future Enhancements (BACKLOG)
### Multi-User Support
- [ ] User authentication
- [ ] Role-based permissions
- [ ] Activity logs
- [ ] Shared access
### Integration
- [ ] Import from other systems
- [ ] Export to Excel/CSV
- [ ] Integration with kennel clubs
- [ ] Backup to cloud storage
### Advanced Genetics
- [ ] DNA test result tracking
- [ ] Genetic diversity analysis
- [ ] Breed-specific calculators
- [ ] Health risk predictions
### Kennel Management
- [ ] Breeding contracts
- [ ] Buyer tracking
- [ ] Financial records
- [ ] Stud service management
---
## Current Sprint Focus
### Next Up (Priority)
1. **Interactive Pedigree Visualization**
- Implement React-D3-Tree integration
- Connect to `/api/pedigree/:id` endpoint
- Add zoom/pan controls
- Enable click navigation
2. **Trial Pairing Tool**
- Create pairing form
- Display COI calculation
- Show common ancestors
- Add recommendation system
3. **Litter Management**
- Add litter creation form
- Link puppies to litters
- Display breeding history
- Track whelping outcomes
### Testing Needed
- [ ] Add/edit dog forms
- [ ] Photo upload functionality
- [ ] Search and filtering
- [ ] Parent relationship linking
- [ ] API error handling
### Known Issues
- None currently
## 🏃 Testing & Quality Assurance
- [x] Database schema initialization guards
- [x] Pedigree tree rendering & zoom/pan
- [x] Parent relationship creation logic
- [x] Static asset serving (prod/dev)
- [ ] Champion toggle load/save trip
- [ ] Heat cycle calendar whelping logic
- [ ] Health records OFA clearance CRUD (Upcoming)
---
## How to Contribute
1. Pick a feature from "Next Up" above
2. Create a feature branch off `master`: `feat/feature-name`
3. Implement with tests and update this roadmap
4. Submit PR for review
1. Pick a feature from "Priority Features"
2. Create a feature branch: `feature/feature-name`
3. Implement with tests
4. Update this roadmap
5. Submit for review
## Version History
- **v0.2.0** (Current) - Dog CRUD operations complete
- **v0.1.0** - Initial foundation with API and database
---
*Last Updated: March 12, 2026*

2675
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,37 +27,54 @@
gap: 0.75rem;
color: var(--text-primary);
font-weight: 700;
font-size: 1.5rem;
font-size: 2.25rem;
text-decoration: none;
transition: var(--transition);
}
.nav-brand:hover {
color: var(--primary-light);
opacity: 0.9;
}
/* Square logo */
.brand-logo {
width: 5rem;
height: 5rem;
object-fit: contain;
object-position: center;
display: block;
border-radius: 4px;
flex-shrink: 0;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
}
.brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
width: 5rem;
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 */
.brand-text {
letter-spacing: -0.025em;
background: linear-gradient(135deg, var(--primary-light) 0%, var(--accent) 100%);
background: linear-gradient(135deg, #c9940a 0%, #b5620a 50%, #8b2500 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
}
.nav-links {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
@@ -81,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 {
@@ -96,12 +126,17 @@
}
.nav-brand {
font-size: 1.25rem;
font-size: 1.625rem;
}
.brand-logo {
width: 4rem;
height: 4rem;
}
.brand-icon {
width: 2rem;
height: 2rem;
width: 4rem;
height: 4rem;
}
.nav-links {

View File

@@ -1,55 +1,79 @@
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
import { Dog, Home, Users, Activity, Heart, BookOpen } from 'lucide-react'
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import DogList from './pages/DogList'
import DogDetail from './pages/DogDetail'
import PedigreeView from './pages/PedigreeView'
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 ExternalDogs from './pages/ExternalDogs'
import { useSettings } from './hooks/useSettings'
import './App.css'
function NavLink({ to, icon: Icon, label }) {
const location = useLocation()
const isActive = location.pathname === to
return (
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
<Icon size={20} />
<span>{label}</span>
</Link>
)
}
function AppInner() {
const { settings } = useSettings()
const kennelName = settings?.kennel_name || 'BREEDR'
return (
<div className="app">
<nav className="navbar">
<div className="container">
<div className="nav-brand">
<img
src="/static/br-logo.png"
alt="BREEDR Logo"
className="brand-logo"
/>
<span className="brand-text">{kennelName}</span>
</div>
<div className="nav-links">
<NavLink to="/" icon={Home} label="Dashboard" />
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
<NavLink to="/external" icon={ExternalLink} label="External" />
<NavLink to="/litters" icon={Activity} label="Litters" />
<NavLink to="/breeding" icon={Heart} label="Breeding" />
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
<NavLink to="/settings" icon={Settings} label="Settings" />
</div>
</div>
</nav>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dogs" element={<DogList />} />
<Route path="/dogs/:id" element={<DogDetail />} />
<Route path="/external" element={<ExternalDogs />} />
<Route path="/pedigree/:id" element={<PedigreeView />} />
<Route path="/litters" element={<LitterList />} />
<Route path="/litters/:id" element={<LitterDetail />} />
<Route path="/breeding" element={<BreedingCalendar />} />
<Route path="/pairing" element={<PairingSimulator />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
)
}
function App() {
return (
<Router>
<div className="app">
<nav className="navbar">
<div className="container">
<div className="nav-brand">
<Dog size={32} />
<span className="brand-text">BREEDR</span>
</div>
<div className="nav-links">
<Link to="/" className="nav-link">
<Home size={20} />
<span>Dashboard</span>
</Link>
<Link to="/dogs" className="nav-link">
<Users size={20} />
<span>Dogs</span>
</Link>
<Link to="/litters" className="nav-link">
<Activity size={20} />
<span>Litters</span>
</Link>
<Link to="/breeding" className="nav-link">
<Heart size={20} />
<span>Breeding</span>
</Link>
</div>
</div>
</nav>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dogs" element={<DogList />} />
<Route path="/dogs/:id" element={<DogDetail />} />
<Route path="/pedigree/:id" element={<PedigreeView />} />
<Route path="/litters" element={<LitterList />} />
<Route path="/breeding" element={<BreedingCalendar />} />
</Routes>
</main>
</div>
<AppInner />
</Router>
)
}

View File

@@ -0,0 +1,52 @@
/**
* ChampionBadge — shown on dogs with is_champion = 1
* ChampionBloodlineBadge — shown on dogs whose sire OR dam is a champion
*
* Usage:
* <ChampionBadge />
* <ChampionBloodlineBadge />
*/
export function ChampionBadge({ size = 'sm' }) {
return (
<span
className="badge-champion"
title="AKC / Registry Champion"
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
>
{/* Crown SVG inline — no extra dep */}
<svg
width={size === 'lg' ? 14 : 11}
height={size === 'lg' ? 14 : 11}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2 15h16v2H2v-2zm0-2 3-7 5 4 5-4 3 7H2z" />
</svg>
CH
</span>
)
}
export function ChampionBloodlineBadge({ size = 'sm' }) {
return (
<span
className="badge-bloodline"
title="Direct descendant of a champion"
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
>
{/* Droplet / bloodline SVG */}
<svg
width={size === 'lg' ? 13 : 10}
height={size === 'lg' ? 13 : 10}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 2C8 8 5 12 5 15.5a7 7 0 0 0 14 0C19 12 16 8 12 2z" />
</svg>
BL
</span>
)
}

View File

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

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
import { X, Award, ExternalLink } from 'lucide-react'
import axios from 'axios'
function DogForm({ dog, onClose, onSave }) {
function DogForm({ dog, onClose, onSave, isExternal = false }) {
const [formData, setFormData] = useState({
name: '',
registration_number: '',
@@ -12,15 +12,27 @@ function DogForm({ dog, onClose, onSave }) {
color: '',
microchip: '',
notes: '',
sire_id: '',
dam_id: ''
sire_id: null,
dam_id: null,
litter_id: null,
is_champion: false,
is_external: isExternal ? 1 : 0,
})
const [dogs, setDogs] = useState([])
const [litters, setLitters] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [useManualParents, setUseManualParents] = useState(true)
const [littersAvailable, setLittersAvailable] = useState(false)
// Derive effective external state (editing an existing external dog or explicitly flagged)
const effectiveExternal = isExternal || (dog && dog.is_external)
useEffect(() => {
fetchDogs()
if (!effectiveExternal) {
fetchLitters()
}
if (dog) {
setFormData({
name: dog.name || '',
@@ -31,108 +43,154 @@ function DogForm({ dog, onClose, onSave }) {
color: dog.color || '',
microchip: dog.microchip || '',
notes: dog.notes || '',
sire_id: dog.sire?.id || '',
dam_id: dog.dam?.id || ''
sire_id: dog.sire?.id || null,
dam_id: dog.dam?.id || null,
litter_id: dog.litter_id || null,
is_champion: !!dog.is_champion,
is_external: dog.is_external ?? (isExternal ? 1 : 0),
})
setUseManualParents(!dog.litter_id)
}
}, [dog])
const fetchDogs = async () => {
try {
const res = await axios.get('/api/dogs')
setDogs(res.data)
} catch (error) {
console.error('Error fetching dogs:', error)
const res = await axios.get('/api/dogs/all')
setDogs(res.data || [])
} catch (e) {
setDogs([])
}
}
const fetchLitters = async () => {
try {
const res = await axios.get('/api/litters', { params: { limit: 200 } })
const data = res.data.data || []
setLitters(data)
setLittersAvailable(data.length > 0)
if (data.length === 0) setUseManualParents(true)
} catch (e) {
setLitters([])
setLittersAvailable(false)
setUseManualParents(true)
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
const { name, value, type, checked } = e.target
if (type === 'checkbox') {
setFormData(prev => ({ ...prev, [name]: checked }))
return
}
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 sel = litters.find(l => l.id === parseInt(value))
if (sel) {
setFormData(prev => ({
...prev,
sire_id: sel.sire_id,
dam_id: sel.dam_id,
breed: prev.breed || sel.sire_name?.split(' ')[0] || ''
}))
}
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const submitData = {
...formData,
is_champion: formData.is_champion ? 1 : 0,
is_external: effectiveExternal ? 1 : 0,
sire_id: formData.sire_id || null,
dam_id: formData.dam_id || null,
litter_id: (effectiveExternal || useManualParents) ? null : (formData.litter_id || null),
registration_number: formData.registration_number || null,
birth_date: formData.birth_date || null,
color: formData.color || null,
microchip: formData.microchip || null,
notes: formData.notes || null,
}
if (dog) {
// Update existing dog
await axios.put(`/api/dogs/${dog.id}`, formData)
await axios.put(`/api/dogs/${dog.id}`, submitData)
} else {
// Create new dog
await axios.post('/api/dogs', formData)
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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
<button className="btn-icon" onClick={onClose}>
<X size={24} />
</button>
<h2>
{effectiveExternal && <ExternalLink size={18} style={{ marginRight: '0.4rem', verticalAlign: 'middle', color: 'var(--text-muted)' }} />}
{dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'}
</h2>
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
</div>
{effectiveExternal && (
<div style={{
margin: '0 0 1rem',
padding: '0.6rem 1rem',
background: 'rgba(99,102,241,0.08)',
border: '1px solid rgba(99,102,241,0.25)',
borderRadius: 'var(--radius)',
fontSize: '0.875rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<ExternalLink size={14} />
External dog not part of your kennel roster.
</div>
)}
<form onSubmit={handleSubmit} className="modal-body">
{error && <div className="error">{error}</div>}
<div className="form-grid">
<div className="form-group">
<label className="label">Name *</label>
<input
type="text"
name="name"
className="input"
value={formData.name}
onChange={handleChange}
required
/>
<input type="text" name="name" className="input"
value={formData.name} onChange={handleChange} required />
</div>
<div className="form-group">
<label className="label">Registration Number</label>
<input
type="text"
name="registration_number"
className="input"
value={formData.registration_number}
onChange={handleChange}
/>
<input type="text" name="registration_number" className="input"
value={formData.registration_number} onChange={handleChange} />
</div>
<div className="form-group">
<label className="label">Breed *</label>
<input
type="text"
name="breed"
className="input"
value={formData.breed}
onChange={handleChange}
required
/>
<input type="text" name="breed" className="input"
value={formData.breed} onChange={handleChange} required />
</div>
<div className="form-group">
<label className="label">Sex *</label>
<select
name="sex"
className="input"
value={formData.sex}
onChange={handleChange}
required
>
<select name="sex" className="input" value={formData.sex} onChange={handleChange} required>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
@@ -140,85 +198,132 @@ function DogForm({ dog, onClose, onSave }) {
<div className="form-group">
<label className="label">Birth Date</label>
<input
type="date"
name="birth_date"
className="input"
value={formData.birth_date}
onChange={handleChange}
/>
<input type="date" name="birth_date" className="input"
value={formData.birth_date} onChange={handleChange} />
</div>
<div className="form-group">
<label className="label">Color</label>
<input
type="text"
name="color"
className="input"
value={formData.color}
onChange={handleChange}
/>
<input type="text" name="color" className="input"
value={formData.color} onChange={handleChange} />
</div>
<div className="form-group">
<label className="label">Microchip Number</label>
<input
type="text"
name="microchip"
className="input"
value={formData.microchip}
onChange={handleChange}
/>
<input type="text" name="microchip" className="input"
value={formData.microchip} onChange={handleChange} />
</div>
</div>
<div className="form-group">
<label className="label">Sire (Father)</label>
<select
name="sire_id"
className="input"
value={formData.sire_id}
onChange={handleChange}
>
<option value="">Unknown</option>
{males.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
{/* Champion Toggle */}
<div style={{
marginTop: '1.25rem',
padding: '0.875rem 1rem',
background: formData.is_champion ? 'rgba(194, 134, 42, 0.08)' : 'var(--bg-primary)',
border: formData.is_champion ? '1px solid var(--champion-gold)' : '1px solid var(--border)',
borderRadius: 'var(--radius)',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
}}
onClick={() => setFormData(prev => ({ ...prev, is_champion: !prev.is_champion }))}
>
<input
type="checkbox"
name="is_champion"
id="is_champion"
checked={!!formData.is_champion}
onChange={handleChange}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--champion-gold)' }}
onClick={e => e.stopPropagation()}
/>
<Award size={18} style={{ color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-muted)' }} />
<div>
<div style={{ fontWeight: 600, color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-primary)', fontSize: '0.9375rem' }}>
Champion
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Mark this dog as a titled champion &mdash; offspring will display a Champion Bloodline badge
</div>
</div>
</div>
<div className="form-group">
<label className="label">Dam (Mother)</label>
<select
name="dam_id"
className="input"
value={formData.dam_id}
onChange={handleChange}
>
<option value="">Unknown</option>
{females.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
{/* Parent Section */}
<div style={{
marginTop: '1.5rem', padding: '1rem',
background: 'rgba(194, 134, 42, 0.04)',
borderRadius: '8px',
border: '1px solid rgba(194, 134, 42, 0.15)'
}}>
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
{!effectiveExternal && littersAvailable && (
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
<input type="radio" name="parentMode" checked={!useManualParents}
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
<span>Link to Litter</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
<input type="radio" name="parentMode" checked={useManualParents}
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
<span>Manual Parent Selection</span>
</label>
</div>
)}
{!useManualParents && littersAvailable && !effectiveExternal ? (
<div className="form-group" style={{ marginTop: '0.5rem' }}>
<label className="label">Select Litter</label>
<select name="litter_id" className="input"
value={formData.litter_id || ''} onChange={handleChange}>
<option value="">No Litter</option>
{litters.map(l => (
<option key={l.id} value={l.id}>
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
</option>
))}
</select>
{formData.litter_id && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
Parents will be automatically set from the selected litter
</div>
)}
</div>
) : (
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
<div className="form-group">
<label className="label">Sire (Father)</label>
<select name="sire_id" className="input"
value={formData.sire_id || ''} onChange={handleChange}>
<option value="">Unknown</option>
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
</select>
</div>
<div className="form-group">
<label className="label">Dam (Mother)</label>
<select name="dam_id" className="input"
value={formData.dam_id || ''} onChange={handleChange}>
<option value="">Unknown</option>
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
</select>
</div>
</div>
)}
</div>
<div className="form-group" style={{ marginTop: '1rem' }}>
<label className="label">Notes</label>
<textarea
name="notes"
className="input"
rows="4"
value={formData.notes}
onChange={handleChange}
/>
<textarea name="notes" className="input" rows="4"
value={formData.notes} onChange={handleChange} />
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
Cancel
</button>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
{loading ? 'Saving...' : dog ? 'Update Dog' : effectiveExternal ? 'Add External Dog' : 'Add Dog'}
</button>
</div>
</form>

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react'
import { Dna, Plus } from 'lucide-react'
import axios from 'axios'
import GeneticTestForm from './GeneticTestForm'
const RESULT_STYLES = {
clear: { bg: 'rgba(52,199,89,0.15)', color: 'var(--success)' },
carrier: { bg: 'rgba(255,159,10,0.15)', color: 'var(--warning)' },
affected: { bg: 'rgba(255,59,48,0.15)', color: 'var(--danger)' },
not_tested: { bg: 'var(--bg-tertiary)', color: 'var(--text-muted)' }
}
export default function GeneticPanelCard({ dogId }) {
const [data, setData] = useState(null)
const [error, setError] = useState(false)
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
const fetchGenetics = () => {
setLoading(true)
axios.get(`/api/genetics/dog/${dogId}`)
.then(res => setData(res.data))
.catch(() => setError(true))
.finally(() => setLoading(false))
}
useEffect(() => { fetchGenetics() }, [dogId])
const openAdd = () => { setEditingRecord(null); setShowForm(true) }
const openEdit = (rec) => { setEditingRecord(rec); setShowForm(true) }
const handleSaved = () => { setShowForm(false); fetchGenetics() }
if (error || (!loading && !data)) return null
const panel = data?.panel || []
return (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Dna size={18} /> DNA Genetics Panel
</h2>
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAdd}>
<Plus size={14} /> Update Marker
</button>
</div>
{loading ? (
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Loading...</div>
) : (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.5rem'
}}>
{panel.map(item => {
const style = RESULT_STYLES[item.result] || RESULT_STYLES.not_tested
// Pass the whole test record if it exists so we can edit it
const record = item.id ? item : { marker: item.marker, result: 'not_tested' }
return (
<div
key={item.marker}
onClick={() => openEdit(record)}
style={{
padding: '0.5rem 0.75rem',
background: style.bg,
border: `1px solid ${style.color}44`,
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'transform 0.1s ease',
}}
onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}
>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.2rem', fontWeight: 500 }}>
{item.marker}
</div>
<div style={{ fontSize: '0.875rem', color: style.color, fontWeight: 600, textTransform: 'capitalize' }}>
{item.result.replace('_', ' ')}
</div>
</div>
)
})}
</div>
)}
{showForm && (
<GeneticTestForm
dogId={dogId}
record={editingRecord}
onClose={() => setShowForm(false)}
onSave={handleSaved}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import axios from 'axios'
const GR_MARKERS = [
{ value: 'PRA1', label: 'PRA1' },
{ value: 'PRA2', label: 'PRA2' },
{ value: 'prcd-PRA', label: 'prcd-PRA' },
{ value: 'GR-PRA1', label: 'GR-PRA1' },
{ value: 'GR-PRA2', label: 'GR-PRA2' },
{ value: 'ICH1', label: 'ICH1 (Ichthyosis 1)' },
{ value: 'ICH2', label: 'ICH2 (Ichthyosis 2)' },
{ value: 'NCL', label: 'Neuronal Ceroid Lipofuscinosis' },
{ value: 'DM', label: 'Degenerative Myelopathy' },
{ value: 'MD', label: 'Muscular Dystrophy' }
]
const RESULTS = [
{ value: 'clear', label: 'Clear / Normal' },
{ value: 'carrier', label: 'Carrier (1 copy)' },
{ value: 'affected', label: 'Affected / At Risk (2 copies)' },
{ value: 'not_tested', label: 'Not Tested' }
]
const EMPTY = {
test_provider: 'Embark',
marker: 'PRA1',
result: 'clear',
test_date: '',
document_url: '',
notes: ''
}
export default function GeneticTestForm({ dogId, record, onClose, onSave }) {
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
// If not tested, don't save
if (form.result === 'not_tested' && !record) {
setError('Cannot save a "Not Tested" result. Please just delete the record if it exists.')
setSaving(false)
return
}
try {
if (record && record.id) {
if (form.result === 'not_tested') {
// If changed to not_tested, just delete it
await axios.delete(`/api/genetics/${record.id}`)
} else {
await axios.put(`/api/genetics/${record.id}`, form)
}
} else {
await axios.post('/api/genetics', { ...form, dog_id: dogId })
}
onSave()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save genetic record')
} finally {
setSaving(false)
}
}
const labelStyle = {
fontSize: '0.8rem', color: 'var(--text-muted)',
marginBottom: '0.25rem', display: 'block',
}
const inputStyle = {
width: '100%', background: 'var(--bg-primary)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
boxSizing: 'border-box',
}
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000, padding: '1rem',
}}>
<div className="card" style={{
width: '100%', maxWidth: '500px', maxHeight: '90vh',
overflowY: 'auto', position: 'relative',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Genetic Result</h2>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={fw}>
<label style={labelStyle}>Marker *</label>
<select style={inputStyle} value={form.marker} onChange={e => set('marker', e.target.value)} disabled={!!record}>
{GR_MARKERS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
<div style={fw}>
<label style={labelStyle}>Result *</label>
<select style={inputStyle} value={form.result} onChange={e => set('result', e.target.value)}>
{RESULTS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={fw}>
<label style={labelStyle}>Provider</label>
<input style={inputStyle} placeholder="Embark, PawPrint, etc." value={form.test_provider}
onChange={e => set('test_provider', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Test Date</label>
<input style={inputStyle} type="date" value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
</div>
<div style={fw}>
<label style={labelStyle}>Document URL</label>
<input style={inputStyle} type="url" placeholder="Link to PDF or result page" value={form.document_url}
onChange={e => set('document_url', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Notes</label>
<textarea style={{ ...inputStyle, minHeight: '60px', resize: 'vertical' }}
value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
{error && (
<div style={{
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
}}>{error}</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Result'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import axios from 'axios'
const RECORD_TYPES = ['ofa_clearance', 'vaccination', 'exam', 'surgery', 'medication', 'other']
const OFA_TEST_TYPES = [
{ value: 'hip_ofa', label: 'Hip - OFA' },
{ value: 'hip_pennhip', label: 'Hip - PennHIP' },
{ value: 'elbow_ofa', label: 'Elbow - OFA' },
{ value: 'heart_ofa', label: 'Heart - OFA' },
{ value: 'heart_echo', label: 'Heart - Echo' },
{ value: 'eye_caer', label: 'Eyes - CAER' },
]
const OFA_RESULTS = ['Excellent', 'Good', 'Fair', 'Mild', 'Moderate', 'Severe', 'Normal', 'Abnormal', 'Pass', 'Fail']
const EMPTY = {
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
test_date: '', ofa_result: 'Good', ofa_number: '', performed_by: '',
expires_at: '', result: '', vet_name: '', next_due: '', notes: '', document_url: '',
}
export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const isOFA = form.record_type === 'ofa_clearance'
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
if (record && record.id) {
await axios.put(`/api/health/${record.id}`, form)
} else {
await axios.post('/api/health', { ...form, dog_id: dogId })
}
onSave()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save record')
} finally {
setSaving(false)
}
}
const labelStyle = {
fontSize: '0.8rem', color: 'var(--text-muted)',
marginBottom: '0.25rem', display: 'block',
}
const inputStyle = {
width: '100%', background: 'var(--bg-primary)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
boxSizing: 'border-box',
}
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000, padding: '1rem',
}}>
<div className="card" style={{
width: '100%', maxWidth: '560px', maxHeight: '90vh',
overflowY: 'auto', position: 'relative',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Health Record</h2>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Record type */}
<div style={fw}>
<label style={labelStyle}>Record Type</label>
<select style={inputStyle} value={form.record_type} onChange={e => set('record_type', e.target.value)}>
{RECORD_TYPES.map(t => (
<option key={t} value={t}>
{t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</option>
))}
</select>
</div>
{isOFA ? (
<>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>OFA Test Type</label>
<select style={inputStyle} value={form.test_type} onChange={e => set('test_type', e.target.value)}>
{OFA_TEST_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div style={fw}>
<label style={labelStyle}>OFA Result</label>
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
{OFA_RESULTS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>OFA Number</label>
<input style={inputStyle} placeholder="GR-12345E24M-VPI" value={form.ofa_number}
onChange={e => set('ofa_number', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Performed By</label>
<input style={inputStyle} placeholder="Radiologist / cardiologist" value={form.performed_by}
onChange={e => set('performed_by', e.target.value)} />
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Test Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Expires At</label>
<input style={inputStyle} type="date" value={form.expires_at}
onChange={e => set('expires_at', e.target.value)} />
</div>
</div>
</>
) : (
<>
<div style={fw}>
<label style={labelStyle}>Test / Procedure Name</label>
<input style={inputStyle} placeholder="e.g. Rabies, Bordetella..." value={form.test_name}
onChange={e => set('test_name', e.target.value)} />
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Next Due</label>
<input style={inputStyle} type="date" value={form.next_due}
onChange={e => set('next_due', e.target.value)} />
</div>
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Result</label>
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
onChange={e => set('result', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Vet Name</label>
<input style={inputStyle} placeholder="Dr. Smith" value={form.vet_name}
onChange={e => set('vet_name', e.target.value)} />
</div>
</div>
</>
)}
<div style={fw}>
<label style={labelStyle}>Document URL (optional)</label>
<input style={inputStyle} type="url" placeholder="https://ofa.org/..." value={form.document_url}
onChange={e => set('document_url', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Notes</label>
<textarea style={{ ...inputStyle, minHeight: '70px', resize: 'vertical' }}
value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
{error && (
<div style={{
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
}}>{error}</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : record && record.id ? 'Save Changes' : 'Add Record'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
import axios from 'axios'
function LitterForm({ litter, prefill, onClose, onSave }) {
const [formData, setFormData] = useState({
sire_id: '',
dam_id: '',
breeding_date: '',
whelping_date: '',
puppy_count: 0,
notes: ''
})
const [dogs, setDogs] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchDogs()
if (litter) {
setFormData({
sire_id: litter.sire_id || '',
dam_id: litter.dam_id || '',
breeding_date: litter.breeding_date || '',
whelping_date: litter.whelping_date || '',
puppy_count: litter.puppy_count || 0,
notes: litter.notes || ''
})
} else if (prefill) {
// Pre-populate from BreedingCalendar "Record Litter" flow
setFormData(prev => ({
...prev,
dam_id: prefill.dam_id ? String(prefill.dam_id) : '',
breeding_date: prefill.breeding_date || '',
whelping_date: prefill.whelping_date || '',
}))
}
}, [litter, prefill])
const fetchDogs = async () => {
try {
const res = await axios.get('/api/dogs/all')
setDogs(res.data)
} catch (error) {
console.error('Error fetching dogs:', error)
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (litter) {
await axios.put(`/api/litters/${litter.id}`, formData)
} else {
await axios.post('/api/litters', formData)
}
onSave()
onClose()
} catch (error) {
setError(error.response?.data?.error || 'Failed to save litter')
setLoading(false)
}
}
const males = dogs.filter(d => d.sex === 'male')
const females = dogs.filter(d => d.sex === 'female')
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{litter ? 'Edit Litter' : prefill ? `Record Litter — ${prefill.dam_name || 'Dam pre-selected'}` : 'Create New Litter'}</h2>
<button className="btn-icon" onClick={onClose}>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-body">
{error && <div className="error">{error}</div>}
{prefill && !litter && (
<div style={{
background: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.3)',
borderRadius: 'var(--radius-sm)',
padding: '0.6rem 0.875rem',
marginBottom: '1rem',
fontSize: '0.85rem',
color: 'var(--success)'
}}>
🐾 Pre-filled from heat cycle select a sire to complete the litter record.
</div>
)}
<div className="form-grid">
<div className="form-group">
<label className="label">Sire (Father) *</label>
<select
name="sire_id"
className="input"
value={formData.sire_id}
onChange={handleChange}
required
disabled={!!litter}
>
<option value="">Select Sire</option>
{males.map(d => (
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
))}
</select>
</div>
<div className="form-group">
<label className="label">Dam (Mother) *</label>
<select
name="dam_id"
className="input"
value={formData.dam_id}
onChange={handleChange}
required
disabled={!!litter}
>
<option value="">Select Dam</option>
{females.map(d => (
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
))}
</select>
{prefill?.dam_name && !litter && (
<p style={{ fontSize: '0.78rem', color: 'var(--success)', marginTop: '0.25rem' }}>
Pre-selected: {prefill.dam_name}
</p>
)}
</div>
<div className="form-group">
<label className="label">Breeding Date *</label>
<input
type="date"
name="breeding_date"
className="input"
value={formData.breeding_date}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label className="label">Whelping Date</label>
<input
type="date"
name="whelping_date"
className="input"
value={formData.whelping_date}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label className="label">Expected Puppy Count</label>
<input
type="number"
name="puppy_count"
className="input"
value={formData.puppy_count}
onChange={handleChange}
min="0"
/>
</div>
</div>
<div className="form-group" style={{ marginTop: '1rem' }}>
<label className="label">Notes</label>
<textarea
name="notes"
className="input"
rows="4"
value={formData.notes}
onChange={handleChange}
placeholder="Enter any notes about this breeding/litter..."
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Saving...' : litter ? 'Update Litter' : 'Create Litter'}
</button>
</div>
</form>
</div>
</div>
)
}
export default LitterForm

View File

@@ -0,0 +1,202 @@
/* ─── Pedigree Tree Wrapper ──────────────────────────────────────── */
.pedigree-tree-wrapper {
position: relative;
width: 100%;
height: calc(100vh - 200px);
background: radial-gradient(
ellipse at 20% 50%,
rgba(194, 134, 42, 0.06) 0%,
var(--bg-primary) 60%
);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
}
.tree-container {
width: 100%;
height: 100%;
}
/* ─── SVG Link Paths ─────────────────────────────────────────────── */
.pedigree-tree-wrapper svg .rd3t-link {
stroke: var(--border-light) !important;
stroke-width: 1.5px !important;
stroke-opacity: 0.6;
}
/* ─── Controls ───────────────────────────────────────────────────── */
.pedigree-controls {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
gap: 0.75rem;
align-items: center;
}
.control-group {
display: flex;
gap: 0.25rem;
background: var(--bg-elevated);
padding: 0.375rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.control-btn {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.4rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: var(--transition);
}
.control-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border);
color: var(--primary-light);
}
.control-btn:active {
transform: scale(0.93);
}
/* ─── COI Display ────────────────────────────────────────────────── */
.coi-display {
background: var(--bg-elevated);
padding: 0.5rem 0.875rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 0.5rem;
backdrop-filter: blur(8px);
}
.coi-label {
font-weight: 600;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.coi-value {
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.02em;
}
.coi-value.low { color: var(--success); }
.coi-value.medium { color: var(--warning); }
.coi-value.high { color: var(--danger); }
/* ─── Legend ─────────────────────────────────────────────────────── */
.pedigree-legend {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 10;
background: var(--bg-elevated);
padding: 0.625rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
box-shadow: var(--shadow);
display: flex;
gap: 1.25rem;
backdrop-filter: blur(8px);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.15);
box-shadow: 0 0 6px rgba(0,0,0,0.4);
}
.legend-color.male { background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.4); }
.legend-color.female { background: #ec4899; box-shadow: 0 0 8px rgba(236,72,153,0.4); }
/* ─── Zoom Indicator ─────────────────────────────────────────────── */
.zoom-indicator {
position: absolute;
bottom: 16px;
right: 16px;
z-index: 10;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.3rem 0.6rem;
font-size: 0.7rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
}
/* ─── Mobile ─────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.pedigree-tree-wrapper {
height: calc(100vh - 150px);
}
.pedigree-controls {
top: 10px;
right: 10px;
flex-direction: column;
gap: 0.4rem;
}
.coi-display {
padding: 0.375rem 0.625rem;
}
.coi-label { font-size: 0.7rem; }
.coi-value { font-size: 0.95rem; }
.pedigree-legend {
bottom: 10px;
left: 10px;
padding: 0.5rem 0.75rem;
gap: 0.875rem;
}
.legend-item { font-size: 0.75rem; }
.legend-color { width: 12px; height: 12px; }
}
/* ─── Print ──────────────────────────────────────────────────────── */
@media print {
.pedigree-controls,
.pedigree-legend,
.zoom-indicator { display: none; }
.pedigree-tree-wrapper {
height: 100vh;
box-shadow: none;
background: white;
border: none;
}
.tree-container { page-break-inside: avoid; }
}

View File

@@ -0,0 +1,247 @@
import { useState, useCallback, useEffect } from 'react'
import Tree from 'react-d3-tree'
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
import './PedigreeTree.css'
const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
const [translate, setTranslate] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(0.8)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
const container = document.getElementById('tree-container')
if (container) {
setDimensions({ width: container.offsetWidth, height: container.offsetHeight })
setTranslate({ x: container.offsetWidth / 4, y: container.offsetHeight / 2 })
}
}
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
}, [])
const handleZoomIn = () => setZoom(z => Math.min(z + 0.2, 2))
const handleZoomOut = () => setZoom(z => Math.max(z - 0.2, 0.2))
const handleReset = () => {
setZoom(0.8)
setTranslate({ x: dimensions.width / 4, y: dimensions.height / 2 })
}
const renderCustomNode = ({ nodeDatum }) => {
const isRoot = nodeDatum.attributes?.isRoot
const isMale = nodeDatum.attributes?.sex === 'male'
const hasId = !!nodeDatum.attributes?.id
const breed = nodeDatum.attributes?.breed
// Colour palette aligned to app theme
const maleColor = '#3b82f6'
const femaleColor = '#ec4899'
const rootGold = '#c2862a' // --primary
const rootAccent = '#9b3a10' // --accent
const nodeColor = isRoot ? rootGold : (isMale ? maleColor : femaleColor)
const glowColor = isRoot
? 'rgba(194,134,42,0.35)'
: (isMale ? 'rgba(59,130,246,0.3)' : 'rgba(236,72,153,0.3)')
const ringColor = isRoot ? rootAccent : nodeColor
const r = isRoot ? 46 : 38
return (
<g>
{/* Glow halo — kept within the circle so it doesn't bleed onto text labels */}
<circle
r={r - 4}
fill={glowColor}
style={{ filter: 'blur(4px)' }}
/>
{/* Outer ring */}
<circle
r={r + 4}
fill="none"
stroke={ringColor}
strokeWidth={isRoot ? 2 : 1.5}
strokeOpacity={0.5}
/>
{/* Main node */}
<circle
r={r}
fill={isRoot
? `url(#rootGradient)`
: nodeColor}
stroke="rgba(255,255,255,0.15)"
strokeWidth={2}
style={{
cursor: hasId ? 'pointer' : 'default',
filter: isRoot ? 'drop-shadow(0 0 8px rgba(194,134,42,0.6))' : 'none'
}}
onClick={() => {
if (hasId) window.location.href = `/dogs/${nodeDatum.attributes.id}`
}}
/>
{/* SVG gradient definition for root node */}
{isRoot && (
<defs>
<radialGradient id="rootGradient" cx="35%" cy="35%">
<stop offset="0%" stopColor="#e0a84a" />
<stop offset="100%" stopColor="#9b3a10" />
</radialGradient>
</defs>
)}
{/* Gender / crown icon */}
<text
fontSize={isRoot ? 28 : 24}
textAnchor="middle"
dy="8"
stroke="none"
style={{ fill: '#ffffff', pointerEvents: 'none', userSelect: 'none' }}
>
{isRoot ? '👑' : (isMale ? '♂' : '♀')}
</text>
{/* Name label */}
<text
fontSize={isRoot ? 22 : 18}
fontWeight={isRoot ? '700' : '600'}
fontFamily="Inter, sans-serif"
textAnchor="middle"
x="0"
y={r + 32}
stroke="none"
style={{ fill: isRoot ? '#ffffff' : '#f8fafc', pointerEvents: 'none' }}
>
{nodeDatum.name}
</text>
{/* Breed label (subtle) */}
{breed && (
<text
fontSize="14"
fontFamily="Inter, sans-serif"
textAnchor="middle"
x="0"
y={r + 52}
stroke="none"
style={{ fill: '#cbd5e1', pointerEvents: 'none' }}
>
{breed}
</text>
)}
{/* Registration number */}
{nodeDatum.attributes?.registration && (
<text
fontSize="14"
fontFamily="Inter, sans-serif"
textAnchor="middle"
x="0"
y={r + (breed ? 70 : 52)}
stroke="none"
style={{ fill: '#94a3b8', pointerEvents: 'none' }}
>
{nodeDatum.attributes.registration}
</text>
)}
{/* Birth year */}
{nodeDatum.attributes?.birth_year && (
<text
fontSize="14"
fontFamily="Inter, sans-serif"
textAnchor="middle"
x="0"
y={r + (breed ? 88 : (nodeDatum.attributes?.registration ? 70 : 52))}
stroke="none"
style={{ fill: '#94a3b8', pointerEvents: 'none' }}
>
({nodeDatum.attributes.birth_year})
</text>
)}
</g>
)
}
return (
<div className="pedigree-tree-wrapper">
{/* Controls */}
<div className="pedigree-controls">
<div className="control-group">
<button onClick={handleZoomIn} className="control-btn" title="Zoom In">
<ZoomIn size={18} />
</button>
<button onClick={handleZoomOut} className="control-btn" title="Zoom Out">
<ZoomOut size={18} />
</button>
<button onClick={handleReset} className="control-btn" title="Reset View">
<Maximize2 size={18} />
</button>
</div>
{coi !== null && coi !== undefined && (
<div className="coi-display">
<span className="coi-label">COI</span>
<span className={`coi-value ${coi > 0.10 ? 'high' : coi > 0.05 ? 'medium' : 'low'}`}>
{(coi * 100).toFixed(2)}%
</span>
</div>
)}
</div>
{/* Legend */}
<div className="pedigree-legend">
<div className="legend-item">
<div className="legend-color male" />
<span>Sire</span>
</div>
<div className="legend-item">
<div className="legend-color female" />
<span>Dam</span>
</div>
<div className="legend-item">
<div style={{
width: 14, height: 14, borderRadius: '50%',
background: 'linear-gradient(135deg, #e0a84a, #9b3a10)',
boxShadow: '0 0 8px rgba(194,134,42,0.5)',
border: '2px solid rgba(255,255,255,0.15)'
}} />
<span>Subject</span>
</div>
</div>
{/* Zoom indicator */}
<div className="zoom-indicator">
{Math.round(zoom * 100)}%
</div>
{/* Tree canvas */}
<div id="tree-container" className="tree-container">
{pedigreeData && dimensions.width > 0 && (
<Tree
data={pedigreeData}
translate={translate}
zoom={zoom}
onUpdate={({ zoom: z, translate: t }) => {
setZoom(z)
setTranslate(t)
}}
orientation="horizontal"
pathFunc="step"
separation={{ siblings: 1.8, nonSiblings: 2.4 }}
nodeSize={{ x: 280, y: 200 }}
renderCustomNodeElement={renderCustomNode}
enableLegacyTransitions
transitionDuration={300}
/>
)}
</div>
</div>
)
}
export default PedigreeTree

View File

@@ -0,0 +1,138 @@
.pedigree-modal {
position: relative;
width: 95vw;
height: 90vh;
background: var(--bg-primary, #1e1e24);
border: 1px solid var(--border, #333);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pedigree-container {
flex: 1;
background: var(--bg-primary, #1e1e24);
position: relative;
overflow: hidden;
}
.pedigree-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.pedigree-legend {
display: flex;
gap: 2rem;
padding: 0.75rem 1.5rem;
background: var(--bg-elevated, #2a2a35);
border-bottom: 1px solid var(--border, #333);
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: var(--text-secondary, #a1a1aa);
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.legend-color.male {
background: #3b82f6;
}
.legend-color.female {
background: #ec4899;
}
.pedigree-info {
padding: 0.75rem 1.5rem;
background: var(--bg-elevated, #2a2a35);
border-top: 1px solid var(--border, #333);
font-size: 1rem;
color: var(--text-muted, #a1a1aa);
text-align: center;
}
.pedigree-info p {
margin: 0;
}
.pedigree-info strong {
color: var(--text-primary, #ffffff);
}
/* Override react-d3-tree styles */
.rd3t-tree-container {
width: 100%;
height: 100%;
}
.rd3t-link {
stroke: #94a3b8;
stroke-width: 2;
fill: none;
}
.rd3t-node {
cursor: pointer;
}
.rd3t-node:hover circle {
filter: brightness(1.1);
}
.rd3t-label__title {
font-weight: 600;
fill: var(--text-primary, #ffffff);
}
.rd3t-label__attributes {
font-size: 1rem;
fill: var(--text-muted, #a1a1aa);
}
/* Loading state */
.pedigree-modal .loading {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
font-size: 1.125rem;
color: #64748b;
}
/* Error state */
.pedigree-modal .error {
margin: 2rem;
padding: 1rem;
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
color: #c00;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.pedigree-modal {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.pedigree-legend {
flex-wrap: wrap;
gap: 1rem;
}
}

View File

@@ -0,0 +1,239 @@
import { useState, useEffect, useCallback } from 'react'
import { X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
import Tree from 'react-d3-tree'
import axios from 'axios'
import './PedigreeView.css'
function PedigreeView({ dogId, onClose }) {
const [treeData, setTreeData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [translate, setTranslate] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(0.8)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
fetchPedigree()
}, [dogId])
useEffect(() => {
const container = document.querySelector('.pedigree-container')
if (!container) return
const updateDimensions = () => {
const width = container.offsetWidth
const height = container.offsetHeight
setDimensions({ width, height })
setTranslate({ x: width / 4, y: height / 2 })
}
updateDimensions()
const resizeObserver = new ResizeObserver(() => {
updateDimensions()
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [])
const fetchPedigree = async () => {
try {
setLoading(true)
const response = await axios.get(`/api/pedigree/${dogId}?generations=5`)
const formatted = formatTreeData(response.data)
setTreeData(formatted)
} catch (err) {
setError(err.response?.data?.error || 'Failed to load pedigree')
} finally {
setLoading(false)
}
}
const formatTreeData = (dog) => {
if (!dog) return null
const children = []
if (dog.sire) children.push(formatTreeData(dog.sire))
if (dog.dam) children.push(formatTreeData(dog.dam))
return {
name: dog.name,
attributes: {
sex: dog.sex,
birth_date: dog.birth_date,
registration: dog.registration_number,
breed: dog.breed,
color: dog.color,
generation: dog.generation
},
children: children.length > 0 ? children : undefined
}
}
const handleNodeClick = useCallback((nodeData) => {
console.log('Node clicked:', nodeData)
}, [])
const handleZoomIn = () => {
setZoom(prev => Math.min(prev + 0.2, 2))
}
const handleZoomOut = () => {
setZoom(prev => Math.max(prev - 0.2, 0.4))
}
const handleReset = () => {
setZoom(0.8)
setTranslate({ x: dimensions.width / 4, y: dimensions.height / 2 })
}
const renderCustomNode = ({ nodeDatum, toggleNode }) => (
<g>
<circle
r="20"
fill={nodeDatum.attributes.sex === 'male' ? '#3b82f6' : '#ec4899'}
stroke="#fff"
strokeWidth="2"
onClick={toggleNode}
style={{ cursor: 'pointer' }}
/>
<text
fill="#fff"
strokeWidth="0"
x="0"
y="5"
textAnchor="middle"
fontSize="12"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{nodeDatum.attributes.sex === 'male' ? '♂' : '♀'}
</text>
<text
fill="#1f2937"
x="30"
y="-10"
fontSize="14"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{nodeDatum.name}
</text>
{nodeDatum.attributes.registration && (
<text
fill="#6b7280"
x="30"
y="8"
fontSize="11"
style={{ pointerEvents: 'none' }}
>
{nodeDatum.attributes.registration}
</text>
)}
{nodeDatum.attributes.birth_date && (
<text
fill="#6b7280"
x="30"
y="22"
fontSize="10"
style={{ pointerEvents: 'none' }}
>
Born: {new Date(nodeDatum.attributes.birth_date).getFullYear()}
</text>
)}
</g>
)
if (loading) {
return (
<div className="modal-overlay">
<div className="pedigree-modal">
<div className="loading">Loading pedigree...</div>
</div>
</div>
)
}
if (error) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="pedigree-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Pedigree Tree</h2>
<button className="btn-icon" onClick={onClose}>
<X size={24} />
</button>
</div>
<div className="error">{error}</div>
</div>
</div>
)
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="pedigree-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Pedigree Tree - {treeData?.name}</h2>
<div className="pedigree-controls">
<button className="btn-icon" onClick={handleZoomOut} title="Zoom Out">
<ZoomOut size={20} />
</button>
<button className="btn-icon" onClick={handleZoomIn} title="Zoom In">
<ZoomIn size={20} />
</button>
<button className="btn-icon" onClick={handleReset} title="Reset View">
<Maximize2 size={20} />
</button>
<button className="btn-icon" onClick={onClose}>
<X size={24} />
</button>
</div>
</div>
<div className="pedigree-legend">
<div className="legend-item">
<span className="legend-color male"></span>
<span>Male</span>
</div>
<div className="legend-item">
<span className="legend-color female"></span>
<span>Female</span>
</div>
</div>
<div className="pedigree-container">
{treeData && dimensions.width > 0 && (
<Tree
data={treeData}
translate={translate}
zoom={zoom}
onNodeClick={handleNodeClick}
renderCustomNodeElement={renderCustomNode}
orientation="horizontal"
pathFunc="step"
separation={{ siblings: 2, nonSiblings: 2.5 }}
nodeSize={{ x: 200, y: 100 }}
enableLegacyTransitions
transitionDuration={300}
collapsible={false}
zoomable={true}
draggable={true}
dimensions={dimensions}
/>
)}
</div>
<div className="pedigree-info">
<p>
<strong>Tip:</strong> Use mouse wheel to zoom, click and drag to pan.
Click on nodes to view details.
</p>
</div>
</div>
</div>
)
}
export default PedigreeView

View File

@@ -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 (
<SettingsContext.Provider value={{ settings, saveSettings, loading }}>
{children}
</SettingsContext.Provider>
)
}
export function useSettings() {
return useContext(SettingsContext)
}

View File

@@ -5,35 +5,45 @@
}
: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;
/* Dark theme backgrounds — slightly warmer tones */
--bg-primary: #0e0f0c;
--bg-secondary: #1a1a15;
--bg-tertiary: #2a2820;
--bg-elevated: #222018;
/* Borders */
--border: #334155;
--border-light: #475569;
/* 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;
@@ -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;
@@ -462,3 +508,32 @@ select {
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Risk Badge - Pairing Simulator */
.risk-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: var(--radius);
font-size: 0.9375rem;
font-weight: 600;
}
.risk-low {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.risk-med {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.risk-high {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}

View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
createRoot(document.getElementById('root')).render(
<StrictMode>
<SettingsProvider>
<App />
</SettingsProvider>
</StrictMode>,
)

View File

@@ -1,67 +1,783 @@
import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react'
import { useEffect, useState, useCallback } from 'react'
import {
Heart, ChevronLeft, ChevronRight, Plus, X,
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2, Activity
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
function BreedingCalendar() {
const [heatCycles, setHeatCycles] = useState([])
const [loading, setLoading] = useState(true)
// ─── Date helpers ────────────────────────────────────────────────────────────
const toISO = d => d.toISOString().split('T')[0]
const addDays = (dateStr, n) => {
const d = new Date(dateStr); d.setDate(d.getDate() + n); return toISO(d)
}
const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
const today = toISO(new Date())
useEffect(() => {
fetchHeatCycles()
}, [])
// ─── Canine gestation constants (days from breeding date) ─────────────────────
const GESTATION_EARLIEST = 58
const GESTATION_EXPECTED = 63
const GESTATION_LATEST = 65
const fetchHeatCycles = async () => {
/** Returns { earliest, expected, latest } ISO date strings, or null if no breeding_date */
function getWhelpDates(cycle) {
if (!cycle?.breeding_date) return null
return {
earliest: addDays(cycle.breeding_date, GESTATION_EARLIEST),
expected: addDays(cycle.breeding_date, GESTATION_EXPECTED),
latest: addDays(cycle.breeding_date, GESTATION_LATEST),
}
}
// ─── Cycle window classifier ─────────────────────────────────────────────────
function getWindowForDate(cycle, dateStr) {
if (!cycle?.start_date) return null
const start = new Date(cycle.start_date + 'T00:00:00')
const check = new Date(dateStr + 'T00:00:00')
const day = Math.round((check - start) / 86400000)
if (day < 0 || day > 28) return null
if (day <= 8) return 'proestrus'
if (day <= 15) return 'optimal'
if (day <= 21) return 'late'
return 'diestrus'
}
const WINDOW_STYLES = {
proestrus: { bg: 'rgba(244,114,182,0.18)', border: '#f472b6', label: 'Proestrus', dot: '#f472b6' },
optimal: { bg: 'rgba(16,185,129,0.22)', border: '#10b981', label: 'Optimal Breeding', dot: '#10b981' },
late: { bg: 'rgba(245,158,11,0.18)', border: '#f59e0b', label: 'Late Estrus', dot: '#f59e0b' },
diestrus: { bg: 'rgba(148,163,184,0.12)', border: '#64748b', label: 'Diestrus', dot: '#64748b' },
}
// Whelp window style (used in legend + calendar marker)
const WHELP_STYLE = {
bg: 'rgba(99,102,241,0.15)',
border: '#6366f1',
label: 'Projected Whelp',
dot: '#6366f1',
}
// ─── Start Heat Cycle Modal ───────────────────────────────────────────────────
function StartCycleModal({ females, onClose, onSaved }) {
const [dogId, setDogId] = useState('')
const [startDate, setStartDate] = useState(today)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
async function handleSubmit(e) {
e.preventDefault()
if (!dogId || !startDate) return
setSaving(true); setError(null)
try {
const res = await axios.get('/api/breeding/heat-cycles/active')
setHeatCycles(res.data)
setLoading(false)
} catch (error) {
console.error('Error fetching heat cycles:', error)
setLoading(false)
const res = await fetch('/api/breeding/heat-cycles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dog_id: parseInt(dogId), start_date: startDate, notes: notes || null })
})
if (!res.ok) { const e = await res.json(); throw new Error(e.error || 'Failed to save') }
onSaved()
} catch (err) {
setError(err.message)
setSaving(false)
}
}
if (loading) {
return <div className="container loading">Loading breeding calendar...</div>
}
return (
<div className="container">
<h1 style={{ marginBottom: '2rem' }}>Breeding Calendar</h1>
<div className="card" style={{ marginBottom: '2rem' }}>
<h2>Active Heat Cycles</h2>
{heatCycles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<Heart size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<p style={{ color: 'var(--text-secondary)' }}>No active heat cycles</p>
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-content" style={{ maxWidth: '480px' }}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Heart size={18} style={{ color: '#f472b6' }} />
<h2>Start Heat Cycle</h2>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
{heatCycles.map(cycle => (
<div key={cycle.id} className="card" style={{ background: 'var(--bg-secondary)' }}>
<h3>{cycle.dog_name}</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Started: {new Date(cycle.start_date).toLocaleDateString()}
</p>
{cycle.registration_number && (
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Reg: {cycle.registration_number}
</p>
)}
</div>
))}
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
<div className="form-group">
<label className="label">Female Dog *</label>
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
<option value=""> Select Female </option>
{females.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.breed ? ` · ${d.breed}` : ''}
</option>
))}
</select>
{females.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>}
</div>
<div className="form-group">
<label className="label">Heat Start Date *</label>
<input type="date" className="input" value={startDate} onChange={e => setStartDate(e.target.value)} required />
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="label">Notes</label>
<textarea className="input" value={notes} onChange={e => setNotes(e.target.value)} placeholder="Optional notes..." rows={3} />
</div>
</div>
)}
</div>
<div className="card">
<h2>Whelping Calculator</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>Calculate expected whelping dates based on breeding dates</p>
<p style={{ marginTop: '1rem', fontSize: '0.875rem' }}>Feature coming soon...</p>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving || !dogId}>
{saving ? 'Saving…' : <><Heart size={15} /> Start Cycle</>}
</button>
</div>
</form>
</div>
</div>
)
}
export default BreedingCalendar
// ─── Cycle Detail Modal ───────────────────────────────────────────────────────
function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
const [suggestions, setSuggestions] = useState(null)
const [breedingDate, setBreedingDate] = useState(cycle.breeding_date || '')
const [savingBreed, setSavingBreed] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
.then(r => r.json())
.then(setSuggestions)
.catch(() => {})
}, [cycle.id])
async function saveBreedingDate() {
if (!breedingDate) return
setSavingBreed(true); setError(null)
try {
const res = await fetch(`/api/breeding/heat-cycles/${cycle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...cycle, breeding_date: breedingDate })
})
if (!res.ok) { const e = await res.json(); throw new Error(e.error) }
// Refresh suggestions
const s = await fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`).then(r => r.json())
setSuggestions(s)
} catch (err) { setError(err.message) }
finally { setSavingBreed(false) }
}
async function deleteCycle() {
if (!window.confirm(`Delete heat cycle for ${cycle.dog_name}? This cannot be undone.`)) return
setDeleting(true)
try {
await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
onDeleted()
} catch (err) { setError(err.message); setDeleting(false) }
}
const whelp = suggestions?.whelping
const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date)
// Client-side projected whelp dates (immediate, before API suggestions load)
const projectedWhelp = getWhelpDates({ breeding_date: breedingDate })
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-content" style={{ maxWidth: '560px' }}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Heart size={18} style={{ color: '#f472b6' }} />
<h2>{cycle.dog_name}</h2>
</div>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<div className="modal-body">
{error && <div className="error">{error}</div>}
{/* Cycle meta */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div style={infoChip}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Started</span>
<span style={{ fontWeight: 600 }}>{fmt(cycle.start_date)}</span>
</div>
{cycle.breed && (
<div style={infoChip}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Breed</span>
<span style={{ fontWeight: 600 }}>{cycle.breed}</span>
</div>
)}
</div>
{/* Breeding date windows */}
{suggestions && (
<>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<FlaskConical size={16} style={{ color: 'var(--accent)' }} /> Breeding Date Windows
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
{suggestions.windows.map(w => (
<div key={w.type} style={{
display: 'flex', alignItems: 'flex-start', gap: '0.75rem',
padding: '0.625rem 0.875rem',
background: WINDOW_STYLES[w.type]?.bg,
border: `1px solid ${WINDOW_STYLES[w.type]?.border}`,
borderRadius: 'var(--radius-sm)'
}}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: WINDOW_STYLES[w.type]?.dot, marginTop: 4, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{w.label}</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{fmt(w.start)} {fmt(w.end)}</span>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', margin: '0.15rem 0 0' }}>{w.description}</p>
</div>
</div>
))}
</div>
</>
)}
{/* Log breeding date */}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1.25rem' }}>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<CalendarDays size={16} style={{ color: 'var(--primary)' }} /> Log Breeding Date
</h3>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<label className="label" style={{ marginBottom: '0.4rem' }}>Breeding Date</label>
<input type="date" className="input" value={breedingDate} onChange={e => setBreedingDate(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={saveBreedingDate} disabled={savingBreed || !breedingDate} style={{ marginBottom: 0 }}>
{savingBreed ? 'Saving…' : 'Save'}
</button>
</div>
{/* Live projected whelp preview — shown as soon as a breeding date is entered */}
{projectedWhelp && (
<div style={{
marginTop: '0.875rem',
padding: '0.625rem 0.875rem',
background: WHELP_STYLE.bg,
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: 'var(--radius-sm)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap'
}}>
<Baby size={15} style={{ color: WHELP_STYLE.dot, flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: WHELP_STYLE.dot }}>Projected Whelp:</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
{fmt(projectedWhelp.earliest)} {fmt(projectedWhelp.latest)}
&nbsp;<span style={{ color: 'var(--text-muted)' }}>(expected {fmt(projectedWhelp.expected)})</span>
</span>
</div>
)}
</div>
{/* Whelping estimate (from API suggestions) */}
{whelp && (
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1rem' }}>
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}>
<Baby size={16} /> Whelping Estimate
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', textAlign: 'center' }}>
{[['Earliest', whelp.earliest], ['Expected', whelp.expected], ['Latest', whelp.latest]].map(([label, date]) => (
<div key={label}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }}>{label}</div>
<div style={{ fontWeight: 700, fontSize: '0.9375rem' }}>{fmt(date)}</div>
</div>
))}
</div>
</div>
)}
{/* Record Litter CTA — shown when breeding date is saved */}
{hasBreedingDate && (
<div style={{
background: 'rgba(16,185,129,0.06)',
border: '1px dashed rgba(16,185,129,0.5)',
borderRadius: 'var(--radius)',
padding: '0.875rem 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap'
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>🐾 Ready to record the litter?</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
Breeding date logged on {fmt(cycle.breeding_date)}. Create a litter record to track puppies.
</div>
</div>
<button
className="btn btn-primary"
style={{ whiteSpace: 'nowrap', fontSize: '0.85rem' }}
onClick={() => {
onClose()
onRecordLitter(cycle)
}}
>
<Activity size={14} style={{ marginRight: '0.4rem' }} />
Record Litter
</button>
</div>
)}
</div>
<div className="modal-footer" style={{ justifyContent: 'space-between' }}>
<button className="btn btn-danger" onClick={deleteCycle} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete Cycle'}
</button>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
const infoChip = {
display: 'flex', flexDirection: 'column', gap: '0.15rem',
padding: '0.5rem 0.875rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius-sm)'
}
// ─── Main Calendar ────────────────────────────────────────────────────────────
export default function BreedingCalendar() {
const now = new Date()
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth()) // 0-indexed
const [cycles, setCycles] = useState([])
const [females, setFemales] = useState([])
const [loading, setLoading] = useState(true)
const [showStartModal, setShowStartModal] = useState(false)
const [selectedCycle, setSelectedCycle] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
const navigate = useNavigate()
const load = useCallback(async () => {
setLoading(true)
try {
const [cyclesRes, dogsRes] = await Promise.all([
fetch('/api/breeding/heat-cycles'),
fetch('/api/dogs')
])
const allCycles = await cyclesRes.json()
const dogsData = await dogsRes.json()
const allDogs = Array.isArray(dogsData) ? dogsData : (dogsData.dogs || [])
setCycles(Array.isArray(allCycles) ? allCycles : [])
setFemales(allDogs.filter(d => d.sex === 'female'))
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// When user clicks Record Litter from cycle detail, create litter and navigate
const handleRecordLitter = useCallback(async (cycle) => {
try {
// We need sire_id — navigate to litters page with pre-filled dam
// Store cycle info in sessionStorage so LitterList can pre-fill
sessionStorage.setItem('prefillLitter', JSON.stringify({
dam_id: cycle.dog_id,
dam_name: cycle.dog_name,
breeding_date: cycle.breeding_date,
whelping_date: cycle.whelping_date || ''
}))
navigate('/litters')
} catch (err) {
console.error(err)
}
}, [navigate])
// ── Navigate to a specific year/month ──
function goToMonth(y, m) {
setYear(y)
setMonth(m)
}
// ── Build calendar grid ──
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startPad = firstDay.getDay() // 0=Sun
const totalCells = startPad + lastDay.getDate()
const rows = Math.ceil(totalCells / 7)
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']
const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
function cyclesForDate(dateStr) {
return cycles.filter(c => {
const s = c.start_date
if (!s) return false
const end = c.end_date || addDays(s, 28)
return dateStr >= s && dateStr <= end
})
}
/** Returns array of cycles whose projected whelp expected date is this dateStr */
function whelpingCyclesForDate(dateStr) {
return cycles.filter(c => {
const wd = getWhelpDates(c)
if (!wd) return false
return dateStr >= wd.earliest && dateStr <= wd.latest
})
}
/** Returns true if this dateStr is the exact expected whelp date for any cycle */
function isExpectedWhelpDate(dateStr) {
return cycles.some(c => {
const wd = getWhelpDates(c)
return wd?.expected === dateStr
})
}
function handleDayClick(dateStr, dayCycles) {
setSelectedDay(dateStr)
if (dayCycles.length === 1) {
setSelectedCycle(dayCycles[0])
} else if (dayCycles.length > 1) {
setSelectedCycle(dayCycles[0])
} else {
setShowStartModal(true)
}
}
const activeCycles = cycles.filter(c => {
const s = c.start_date; if (!s) return false
const end = c.end_date || addDays(s, 28)
const mStart = toISO(new Date(year, month, 1))
const mEnd = toISO(new Date(year, month + 1, 0))
return s <= mEnd && end >= mStart
})
// Cycles that have a whelp window overlapping current month view
const whelpingThisMonth = cycles.filter(c => {
const wd = getWhelpDates(c)
if (!wd) return false
const mStart = toISO(new Date(year, month, 1))
const mEnd = toISO(new Date(year, month + 1, 0))
return wd.earliest <= mEnd && wd.latest >= mStart
})
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(244,114,182,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f472b6' }}>
<Heart size={20} />
</div>
<div>
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Heat Cycle Calendar</h1>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Track heat cycles, optimal breeding windows, and projected whelping dates</p>
</div>
</div>
<button className="btn btn-primary" onClick={() => setShowStartModal(true)}>
<Plus size={16} /> Start Heat Cycle
</button>
</div>
{/* Legend */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
{Object.entries(WINDOW_STYLES).map(([key, s]) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: s.dot }} />
{s.label}
</div>
))}
{/* Whelp legend entry */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<Baby size={11} style={{ color: WHELP_STYLE.dot }} />
{WHELP_STYLE.label}
</div>
</div>
{/* Month navigator */}
<div className="card" style={{ marginBottom: '1rem', padding: '0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1rem', borderBottom: '1px solid var(--border)' }}>
<button className="btn-icon" onClick={prevMonth}><ChevronLeft size={20} /></button>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>{MONTH_NAMES[month]} {year}</h2>
<button className="btn-icon" onClick={nextMonth}><ChevronRight size={20} /></button>
</div>
{/* Day headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
{DAY_NAMES.map(d => (
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
))}
</div>
{/* Calendar cells */}
{loading ? (
<div className="loading" style={{ minHeight: 280 }}>Loading calendar</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{Array.from({ length: rows * 7 }).map((_, idx) => {
const dayNum = idx - startPad + 1
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
const dateStr = isValid ? toISO(new Date(year, month, dayNum)) : null
const dayCycles = dateStr ? cyclesForDate(dateStr) : []
const isToday = dateStr === today
// Whelp window cycles for this day
const whelpCycles = dateStr ? whelpingCyclesForDate(dateStr) : []
const isExpectedWhelp = dateStr ? isExpectedWhelpDate(dateStr) : false
const hasWhelpActivity = whelpCycles.length > 0
let cellBg = 'transparent'
let cellBorder = 'var(--border)'
if (dayCycles.length > 0) {
const win = getWindowForDate(dayCycles[0], dateStr)
if (win && WINDOW_STYLES[win]) {
cellBg = WINDOW_STYLES[win].bg
cellBorder = WINDOW_STYLES[win].border
}
} else if (hasWhelpActivity) {
// Only color whelp window if not already in a heat window
cellBg = WHELP_STYLE.bg
cellBorder = WHELP_STYLE.border
}
return (
<div
key={idx}
onClick={() => isValid && handleDayClick(dateStr, dayCycles)}
style={{
minHeight: 72,
padding: '0.375rem 0.5rem',
borderRight: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
background: cellBg,
cursor: isValid ? 'pointer' : 'default',
position: 'relative',
transition: 'filter 0.15s',
opacity: isValid ? 1 : 0.3,
outline: isToday ? `2px solid var(--primary)` : 'none',
outlineOffset: -2,
}}
onMouseEnter={e => { if (isValid) e.currentTarget.style.filter = 'brightness(1.15)' }}
onMouseLeave={e => { e.currentTarget.style.filter = 'none' }}
>
{isValid && (
<>
<div style={{
fontSize: '0.8125rem', fontWeight: isToday ? 700 : 500,
color: isToday ? 'var(--primary)' : 'var(--text-primary)',
marginBottom: '0.25rem'
}}>{dayNum}</div>
{dayCycles.map((c, i) => {
const win = getWindowForDate(c, dateStr)
const dot = win ? WINDOW_STYLES[win]?.dot : '#94a3b8'
return (
<div key={i} style={{
fontSize: '0.7rem', color: dot, fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
lineHeight: 1.3
}}>
{c.dog_name}
</div>
)
})}
{/* Projected whelp window indicator */}
{hasWhelpActivity && (
<div style={{ marginTop: '0.15rem' }}>
{whelpCycles.map((c, i) => (
<div key={i} style={{
fontSize: '0.67rem',
color: WHELP_STYLE.dot,
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.3,
display: 'flex',
alignItems: 'center',
gap: '0.2rem'
}}>
<Baby size={9} />
{isExpectedWhelp && getWhelpDates(c)?.expected === dateStr
? `${c.dog_name} due`
: c.dog_name
}
</div>
))}
</div>
)}
{/* Breeding date marker dot */}
{dayCycles.some(c => c.breeding_date === dateStr) && (
<div style={{ position: 'absolute', top: 4, right: 4, width: 8, height: 8, borderRadius: '50%', background: 'var(--success)', border: '1.5px solid var(--bg-primary)' }} title="Breeding date logged" />
)}
{/* Expected whelp date ring marker */}
{isExpectedWhelp && (
<div style={{
position: 'absolute',
top: 2, right: dayCycles.some(c => c.breeding_date === dateStr) ? 14 : 4,
width: 8, height: 8,
borderRadius: '50%',
background: WHELP_STYLE.dot,
border: '1.5px solid var(--bg-primary)'
}} title="Projected whelp date" />
)}
</>
)}
</div>
)
})}
</div>
)}
</div>
{/* Active cycles list */}
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle size={16} style={{ color: '#f472b6' }} />
Active Cycles This Month
<span className="badge badge-primary">{activeCycles.length}</span>
</h3>
{activeCycles.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
<Heart size={32} style={{ margin: '0 auto 0.75rem', opacity: 0.4 }} />
<p>No active heat cycles this month.</p>
<button className="btn btn-primary" style={{ marginTop: '1rem' }} onClick={() => setShowStartModal(true)}>
<Plus size={15} /> Start First Cycle
</button>
</div>
) : (
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}>
{activeCycles.map(c => {
const win = getWindowForDate(c, today)
const ws = win ? WINDOW_STYLES[win] : null
const daysSince = Math.round((new Date(today) - new Date(c.start_date + 'T00:00:00')) / 86400000)
const projWhelp = getWhelpDates(c)
return (
<div
key={c.id}
className="card"
style={{ cursor: 'pointer', borderColor: ws?.border || 'var(--border)', background: ws?.bg || 'var(--bg-secondary)' }}
onClick={() => setSelectedCycle(c)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h4 style={{ margin: '0 0 0.2rem', fontSize: '1rem' }}>{c.dog_name}</h4>
{c.breed && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: 0 }}>{c.breed}</p>}
</div>
{ws && <span className="badge" style={{ background: ws.bg, color: ws.dot, border: `1px solid ${ws.border}`, flexShrink: 0 }}>{ws.label}</span>}
</div>
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
<span>Started {fmt(c.start_date)}</span>
<span>Day {daysSince + 1}</span>
</div>
{c.breeding_date && (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: 'var(--success)' }}>
<CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)}
</div>
)}
{/* Projected whelp date on card */}
{projWhelp && (
<div style={{
marginTop: '0.4rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.8rem',
color: WHELP_STYLE.dot,
fontWeight: 500
}}>
<Baby size={13} />
Whelp est. {fmt(projWhelp.expected)}
<span style={{ fontSize: '0.73rem', color: 'var(--text-muted)', fontWeight: 400 }}>
({fmt(projWhelp.earliest)}{fmt(projWhelp.latest)})
</span>
{/* Jump-to-month button if whelp month differs from current view */}
{(() => {
const wd = new Date(projWhelp.expected + 'T00:00:00')
const wdY = wd.getFullYear()
const wdM = wd.getMonth()
if (wdY !== year || wdM !== month) {
return (
<button
style={{
marginLeft: 'auto',
background: 'none',
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: '0.25rem',
color: WHELP_STYLE.dot,
fontSize: '0.7rem',
padding: '0.1rem 0.35rem',
cursor: 'pointer',
fontWeight: 600,
whiteSpace: 'nowrap'
}}
onClick={e => { e.stopPropagation(); goToMonth(wdY, wdM) }}
>
View {MONTH_NAMES[wdM].slice(0,3)} {wdY}
</button>
)
}
return null
})()}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Whelping cycles banner — shown if any projected whelps fall this month but no active heat */}
{whelpingThisMonth.length > 0 && activeCycles.length === 0 && (
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: WHELP_STYLE.bg,
border: `1px solid ${WHELP_STYLE.border}`,
borderRadius: 'var(--radius)',
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem'
}}>
<Baby size={18} style={{ color: WHELP_STYLE.dot, flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600, color: WHELP_STYLE.dot, marginBottom: '0.3rem' }}>Projected Whelping This Month</div>
{whelpingThisMonth.map(c => {
const wd = getWhelpDates(c)
return (
<div key={c.id} style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.2rem' }}>
<strong>{c.dog_name}</strong> expected {fmt(wd.expected)}
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}> (range {fmt(wd.earliest)}{fmt(wd.latest)})</span>
</div>
)
})}
</div>
</div>
)}
{/* Modals */}
{showStartModal && (
<StartCycleModal
females={females}
onClose={() => setShowStartModal(false)}
onSaved={() => { setShowStartModal(false); load() }}
/>
)}
{selectedCycle && (
<CycleDetailModal
cycle={selectedCycle}
onClose={() => setSelectedCycle(null)}
onDeleted={() => { setSelectedCycle(null); load() }}
onRecordLitter={handleRecordLitter}
/>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Dog, Activity, Heart, AlertCircle } from 'lucide-react'
import { Dog, Activity, Heart, Calendar, Hash, ArrowRight } from 'lucide-react'
import axios from 'axios'
function Dashboard() {
@@ -21,21 +21,21 @@ function Dashboard() {
const fetchDashboardData = async () => {
try {
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
axios.get('/api/dogs'),
axios.get('/api/litters'),
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
axios.get('/api/breeding/heat-cycles/active')
])
const dogs = dogsRes.data
const { data: recentDogsList, stats: dogStats } = dogsRes.data
setStats({
totalDogs: dogs.length,
males: dogs.filter(d => d.sex === 'male').length,
females: dogs.filter(d => d.sex === 'female').length,
totalLitters: littersRes.data.length,
totalDogs: dogStats?.total ?? 0,
males: dogStats?.males ?? 0,
females: dogStats?.females ?? 0,
totalLitters: littersRes.data.total,
activeHeatCycles: heatCyclesRes.data.length
})
setRecentDogs(dogs.slice(0, 6))
setRecentDogs(recentDogsList)
setLoading(false)
} catch (error) {
console.error('Error fetching dashboard data:', error)
@@ -43,65 +43,203 @@ function Dashboard() {
}
}
const calculateAge = (birthDate) => {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) {
years--
months += 12
}
if (years === 0) return `${months}mo`
if (months === 0) return `${years}y`
return `${years}y ${months}mo`
}
if (loading) {
return <div className="container loading">Loading dashboard...</div>
}
return (
<div className="container">
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
<h1 style={{ marginBottom: '2rem' }}>Dashboard</h1>
<div className="grid grid-3" style={{ marginBottom: '3rem' }}>
<div className="card" style={{ textAlign: 'center' }}>
<Dog size={48} style={{ color: 'var(--primary)', margin: '0 auto 1rem' }} />
<h3 style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{stats.totalDogs}</h3>
<p style={{ color: 'var(--text-secondary)' }}>Total Dogs</p>
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
{stats.males} Males {stats.females} Females
</p>
{/* Stats Grid */}
<div className="grid grid-4" style={{ marginBottom: '3rem' }}>
<div className="card stat-card">
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, var(--primary), var(--accent))' }}>
<Dog size={24} color="white" />
</div>
<div className="stat-value">{stats.totalDogs}</div>
<div className="stat-label">Total Dogs</div>
<div style={{ marginTop: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{stats.males} · {stats.females}
</div>
</div>
<div className="card" style={{ textAlign: 'center' }}>
<Activity size={48} style={{ color: 'var(--success)', margin: '0 auto 1rem' }} />
<h3 style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{stats.totalLitters}</h3>
<p style={{ color: 'var(--text-secondary)' }}>Total Litters</p>
<div className="card stat-card">
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, var(--success), #059669)' }}>
<Activity size={24} color="white" />
</div>
<div className="stat-value">{stats.totalLitters}</div>
<div className="stat-label">Total Litters</div>
</div>
<div className="card" style={{ textAlign: 'center' }}>
<Heart size={48} style={{ color: 'var(--danger)', margin: '0 auto 1rem' }} />
<h3 style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{stats.activeHeatCycles}</h3>
<p style={{ color: 'var(--text-secondary)' }}>Active Heat Cycles</p>
<div className="card stat-card">
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, var(--danger), #dc2626)' }}>
<Heart size={24} color="white" />
</div>
<div className="stat-value">{stats.activeHeatCycles}</div>
<div className="stat-label">Active Heat Cycles</div>
</div>
<Link to="/dogs" className="card stat-card" style={{ textDecoration: 'none', cursor: 'pointer', transition: 'var(--transition)' }}>
<div className="stat-icon" style={{ background: 'var(--bg-tertiary)' }}>
<ArrowRight size={24} color="var(--primary)" />
</div>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: '1.125rem', marginTop: '0.5rem' }}>View All Dogs</div>
<div className="stat-label">Manage Collection</div>
</Link>
</div>
{/* Recent Dogs Section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2>Recent Dogs</h2>
<Link to="/dogs" className="btn btn-primary">View All</Link>
<Link to="/dogs" className="btn btn-primary">
View All
<ArrowRight size={16} />
</Link>
</div>
{recentDogs.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<AlertCircle size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<h3>No dogs registered yet</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem' }}>Start by adding your first dog to the system</p>
<Link to="/dogs" className="btn btn-primary">Add Dog</Link>
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
<h3 style={{ marginBottom: '0.5rem' }}>No dogs registered yet</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>Start building your kennel management system</p>
<Link to="/dogs" className="btn btn-primary">Add Your First Dog</Link>
</div>
) : (
<div className="grid grid-3">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1rem' }}>
{recentDogs.map(dog => (
<Link key={dog.id} to={`/dogs/${dog.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div style={{ aspectRatio: '1', background: 'var(--bg-secondary)', borderRadius: '0.375rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Link
key={dog.id}
to={`/dogs/${dog.id}`}
className="card"
style={{
padding: '1rem',
textDecoration: 'none',
display: 'flex',
gap: '1rem',
alignItems: 'center',
transition: 'var(--transition)',
cursor: 'pointer'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{/* Avatar Photo */}
<div style={{
width: '80px',
height: '80px',
flexShrink: 0,
borderRadius: 'var(--radius)',
background: 'var(--bg-primary)',
border: '2px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
}}>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<img src={dog.photo_urls[0]} alt={dog.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '0.375rem' }} />
<img
src={dog.photo_urls[0]}
alt={dog.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
) : (
<Dog size={48} style={{ color: 'var(--text-secondary)' }} />
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
)}
</div>
<h3>{dog.name}</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>{dog.breed} {dog.sex}</p>
{dog.registration_number && (
<p style={{ color: 'var(--text-secondary)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{dog.registration_number}</p>
)}
{/* Info Section */}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{
fontSize: '1.125rem',
marginBottom: '0.375rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{dog.name}
<span style={{
marginLeft: '0.5rem',
fontSize: '1rem',
color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899'
}}>
{dog.sex === 'male' ? '♂' : '♀'}
</span>
</h3>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
fontSize: '0.8125rem',
color: 'var(--text-secondary)',
marginBottom: '0.5rem'
}}>
<span>{dog.breed}</span>
{dog.birth_date && (
<>
<span></span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Calendar size={12} />
{calculateAge(dog.birth_date)}
</span>
</>
)}
</div>
{dog.registration_number && (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem',
fontFamily: 'monospace',
color: 'var(--text-muted)'
}}>
<Hash size={10} />
{dog.registration_number}
</div>
)}
</div>
{/* Arrow Indicator */}
<div style={{
opacity: 0.5,
transition: 'var(--transition)'
}}>
<ArrowRight size={20} color="var(--text-muted)" />
</div>
</Link>
))}
</div>

View File

@@ -3,6 +3,11 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
import HealthRecordForm from '../components/HealthRecordForm'
import GeneticPanelCard from '../components/GeneticPanelCard'
import { ShieldCheck } from 'lucide-react'
function DogDetail() {
const { id } = useParams()
@@ -14,9 +19,13 @@ function DogDetail() {
const [selectedPhoto, setSelectedPhoto] = useState(0)
const fileInputRef = useRef(null)
useEffect(() => {
fetchDog()
}, [id])
// Health records state
const [healthRecords, setHealthRecords] = useState([])
const [showHealthForm, setShowHealthForm] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
useEffect(() => { fetchDog() }, [id])
useEffect(() => { fetchHealth() }, [id])
const fetchDog = async () => {
try {
@@ -29,14 +38,18 @@ function DogDetail() {
}
}
const fetchHealth = () => {
axios.get(`/api/health/dog/${id}`)
.then(r => setHealthRecords(r.data))
.catch(() => {})
}
const handlePhotoUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
setUploading(true)
const formData = new FormData()
formData.append('photo', file)
try {
await axios.post(`/api/dogs/${id}/photos`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
@@ -53,7 +66,6 @@ function DogDetail() {
const handleDeletePhoto = async (photoIndex) => {
if (!confirm('Delete this photo?')) return
try {
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
fetchDog()
@@ -70,26 +82,27 @@ function DogDetail() {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) {
years--
months += 12
}
if (months < 0) { years--; months += 12 }
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
return `${years}y ${months}m`
}
if (loading) {
return <div className="container loading">Loading...</div>
}
const hasChampionBlood = (d) =>
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
if (!dog) {
return <div className="container">Dog not found</div>
}
const openAddHealth = () => { setEditingRecord(null); setShowHealthForm(true) }
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
if (loading) return <div className="container loading">Loading...</div>
if (!dog) return <div className="container">Dog not found</div>
const isChampion = !!dog.is_champion
const hasBloodline = !isChampion && hasChampionBlood(dog)
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
@@ -99,14 +112,18 @@ function DogDetail() {
<ArrowLeft size={20} />
</button>
<div style={{ flex: 1 }}>
<h1 style={{ marginBottom: '0.25rem' }}>{dog.name}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<h1 style={{ margin: 0 }}>{dog.name}</h1>
{isChampion && <ChampionBadge size="lg" />}
{hasBloodline && <ChampionBloodlineBadge size="lg" />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
<span>{dog.breed}</span>
<span></span>
<span>{dog.sex === 'male' ? 'Male' : 'Female'}</span>
<span>·</span>
<span>{dog.sex === 'male' ? 'Male' : 'Female'}</span>
{dog.birth_date && (
<>
<span></span>
<span>·</span>
<span>{calculateAge(dog.birth_date)}</span>
</>
)}
@@ -125,7 +142,7 @@ function DogDetail() {
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
{/* Photo Section - Compact */}
{/* Photo Section */}
<div className="card" style={{ padding: '1rem' }}>
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
@@ -138,46 +155,42 @@ function DogDetail() {
<Upload size={14} />
{uploading ? 'Uploading...' : 'Add'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handlePhotoUpload}
style={{ display: 'none' }}
/>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
</div>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<>
{/* Main Photo */}
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
<img
src={dog.photo_urls[selectedPhoto]}
alt={dog.name}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
width: '100%', aspectRatio: '1', objectFit: 'cover',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)'
border: isChampion
? '2px solid var(--champion-gold)'
: hasBloodline
? '2px solid var(--bloodline-amber)'
: '1px solid var(--border)',
boxShadow: isChampion
? '0 0 12px var(--champion-glow)'
: hasBloodline
? '0 0 10px var(--bloodline-glow)'
: 'none'
}}
/>
<button
className="btn-icon"
onClick={() => handleDeletePhoto(selectedPhoto)}
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
background: 'rgba(15, 23, 42, 0.8)',
position: 'absolute', top: '0.5rem', right: '0.5rem',
background: 'rgba(14, 15, 12, 0.8)',
backdropFilter: 'blur(8px)'
}}
>
<Trash2 size={16} color="var(--danger)" />
</button>
</div>
{/* Thumbnail Strip */}
{dog.photo_urls.length > 1 && (
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
{dog.photo_urls.map((url, index) => (
@@ -187,11 +200,8 @@ function DogDetail() {
alt={`${dog.name} ${index + 1}`}
onClick={() => setSelectedPhoto(index)}
style={{
width: '60px',
height: '60px',
objectFit: 'cover',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
width: '60px', height: '60px', objectFit: 'cover',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
opacity: selectedPhoto === index ? 1 : 0.6,
transition: 'all 0.2s'
@@ -213,18 +223,26 @@ function DogDetail() {
<div>
<div className="card" style={{ marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
<div>
<div className="info-row">
<span className="info-label">Breed</span>
<span className="info-value">{dog.breed}</span>
</div>
<div className="info-row">
<span className="info-label">Sex</span>
<span className="info-value">{dog.sex === 'male' ? 'Male' : 'Female'}</span>
<span className="info-value">{dog.sex === 'male' ? 'Male' : 'Female'}</span>
</div>
<div className="info-row">
<span className="info-label">Champion</span>
<span className="info-value">
{isChampion
? <ChampionBadge size="lg" />
: hasBloodline
? <ChampionBloodlineBadge size="lg" />
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>&mdash;</span>
}
</span>
</div>
{dog.birth_date && (
<div className="info-row">
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
@@ -234,21 +252,30 @@ function DogDetail() {
</span>
</div>
)}
{dog.color && (
<div className="info-row">
<span className="info-label">Color</span>
<span className="info-value">{dog.color}</span>
</div>
)}
{dog.registration_number && (
<div className="info-row">
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
</div>
)}
{dog.chic_number && (
<div className="info-row">
<span className="info-label"><ShieldCheck size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />CHIC Status</span>
<span className="info-value">
<span style={{
fontSize: '0.75rem', fontWeight: 600, padding: '0.2rem 0.6rem',
background: 'rgba(99,102,241,0.15)', color: '#818cf8',
borderRadius: '999px', border: '1px solid rgba(99,102,241,0.3)'
}}>CHIC #{dog.chic_number}</span>
</span>
</div>
)}
{dog.microchip && (
<div className="info-row">
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
@@ -265,9 +292,12 @@ function DogDetail() {
<div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
{dog.sire ? (
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
{dog.sire.name}
</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
{dog.sire.name}
</Link>
{dog.sire.is_champion && <ChampionBadge />}
</div>
) : (
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
)}
@@ -275,9 +305,12 @@ function DogDetail() {
<div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
{dog.dam ? (
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
{dog.dam.name}
</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
{dog.dam.name}
</Link>
{dog.dam.is_champion && <ChampionBadge />}
</div>
) : (
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
)}
@@ -295,6 +328,52 @@ function DogDetail() {
</div>
)}
{/* OFA Clearance Summary */}
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
{/* DNA Genetics Panel */}
<GeneticPanelCard dogId={id} />
{/* Health Records List */}
{healthRecords.length > 0 && (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 }}>
Health Records ({healthRecords.length})
</h2>
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAddHealth}>
+ Add
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{healthRecords.map(rec => (
<div key={rec.id} style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.6rem 0.75rem', background: 'var(--bg-primary)',
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
}}>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
</span>
{rec.ofa_result && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
</span>
)}
</div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{rec.test_date ? new Date(rec.test_date).toLocaleDateString() : ''}
</span>
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
<Edit size={14} />
</button>
</div>
))}
</div>
</div>
)}
{/* Offspring */}
{dog.offspring && dog.offspring.length > 0 && (
<div className="card">
@@ -313,33 +392,45 @@ function DogDetail() {
transition: 'var(--transition)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
gap: '0.5rem'
}}
onMouseEnter={(e) => {
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.background = 'var(--bg-tertiary)'
e.currentTarget.style.background = 'var(--bg-tertiary)'
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.background = 'var(--bg-primary)'
e.currentTarget.style.background = 'var(--bg-primary)'
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{child.is_champion && <ChampionBadge />}
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '' : ''}</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Edit Dog Modal */}
{showEditModal && (
<DogForm
dog={dog}
onClose={() => setShowEditModal(false)}
onSave={() => {
fetchDog()
setShowEditModal(false)
}}
onSave={() => { fetchDog(); setShowEditModal(false) }}
/>
)}
{/* Health Record Form Modal */}
{showHealthForm && (
<HealthRecordForm
dogId={id}
record={editingRecord}
onClose={closeHealthForm}
onSave={handleHealthSaved}
/>
)}
</div>

View File

@@ -1,128 +1,418 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Dog, Plus, Search } from 'lucide-react'
import { useEffect, useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
const LIMIT = 50
function DogList() {
const [dogs, setDogs] = useState([])
const [filteredDogs, setFilteredDogs] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [sexFilter, setSexFilter] = useState('all')
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false)
const searchTimerRef = useRef(null)
useEffect(() => {
fetchDogs()
}, [])
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
useEffect(() => {
filterDogs()
}, [dogs, search, sexFilter])
const fetchDogs = async () => {
const fetchDogs = async (p, q, s) => {
setLoading(true)
try {
const res = await axios.get('/api/dogs')
setDogs(res.data)
setLoading(false)
const params = { page: p, limit: LIMIT }
if (q) params.search = q
if (s !== 'all') params.sex = s
const res = await axios.get('/api/dogs', { params })
setDogs(res.data.data)
setTotal(res.data.total)
setPage(p)
} catch (error) {
console.error('Error fetching dogs:', error)
} finally {
setLoading(false)
}
}
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 handleSearchChange = (value) => {
setSearch(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
}
const handleSave = () => {
fetchDogs()
const handleSexChange = (value) => {
setSexFilter(value)
fetchDogs(1, search, value)
}
const handleClearFilters = () => {
setSearch('')
setSexFilter('all')
fetchDogs(1, '', 'all')
}
const handleSave = () => { fetchDogs(page, search, sexFilter) }
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await axios.delete(`/api/dogs/${deleteTarget.id}`)
setDeleteTarget(null)
fetchDogs(page, search, sexFilter)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.')
} finally {
setDeleting(false)
}
}
const totalPages = Math.ceil(total / LIMIT)
const calculateAge = (birthDate) => {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) { years--; months += 12 }
if (years === 0) return `${months}mo`
if (months === 0) return `${years}y`
return `${years}y ${months}mo`
}
const hasChampionBlood = (dog) =>
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
if (loading) {
return <div className="container loading">Loading dogs...</div>
}
return (
<div className="container">
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1>Dogs</h1>
<div>
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{total} {total === 1 ? 'dog' : 'dogs'}
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
</p>
</div>
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={20} />
<Plus size={18} />
Add Dog
</button>
</div>
<div className="card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem' }}>
{/* Search and Filter Bar */}
<div className="card" style={{ marginBottom: '1.5rem', padding: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '1rem', alignItems: 'center' }}>
<div style={{ position: 'relative' }}>
<Search size={20} style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)' }} />
<Search size={18} style={{ position: 'absolute', left: '0.875rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
className="input"
placeholder="Search by name or registration number..."
placeholder="Search by name or registration..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ paddingLeft: '2.5rem' }}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ paddingLeft: '2.75rem' }}
/>
</div>
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: 'auto' }}>
<option value="all">All</option>
<option value="male">Males</option>
<option value="female">Females</option>
<select className="input" value={sexFilter} onChange={(e) => handleSexChange(e.target.value)} style={{ width: '140px' }}>
<option value="all">All Dogs</option>
<option value="male">Males </option>
<option value="female">Females </option>
</select>
{(search || sexFilter !== 'all') && (
<button
className="btn btn-ghost"
onClick={handleClearFilters}
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
>
Clear
</button>
)}
</div>
</div>
<div className="grid grid-3">
{filteredDogs.map(dog => (
<Link key={dog.id} to={`/dogs/${dog.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div style={{ aspectRatio: '1', background: 'var(--bg-secondary)', borderRadius: '0.375rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<img src={dog.photo_urls[0]} alt={dog.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '0.375rem' }} />
) : (
<Dog size={48} style={{ color: 'var(--text-secondary)' }} />
)}
</div>
<h3>{dog.name}</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{dog.breed} {dog.sex === 'male' ? '♂' : '♀'}
</p>
{dog.registration_number && (
<p style={{ color: 'var(--text-secondary)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{dog.registration_number}</p>
)}
{dog.birth_date && (
<p style={{ color: 'var(--text-secondary)', fontSize: '0.75rem' }}>Born: {new Date(dog.birth_date).toLocaleDateString()}</p>
)}
</Link>
))}
</div>
{/* Dogs List */}
{dogs.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
<h3 style={{ marginBottom: '0.5rem' }}>
{search || sexFilter !== 'all' ? 'No dogs found' : 'No dogs yet'}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
{search || sexFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Add your first dog to get started'}
</p>
{!search && sexFilter === 'all' && (
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={18} />
Add Your First Dog
</button>
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{dogs.map(dog => (
<div
key={dog.id}
className="card"
style={{
padding: '1rem',
display: 'flex',
gap: '1rem',
alignItems: 'center',
transition: 'var(--transition)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
}}
>
{/* Avatar */}
<Link
to={`/dogs/${dog.id}`}
style={{ flexShrink: 0, textDecoration: 'none' }}
>
<div style={{
width: '80px', height: '80px',
borderRadius: 'var(--radius)',
background: 'var(--bg-primary)',
border: dog.is_champion
? '2px solid var(--champion-gold)'
: hasChampionBlood(dog)
? '2px solid var(--bloodline-amber)'
: '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
boxShadow: dog.is_champion
? '0 0 8px var(--champion-glow)'
: hasChampionBlood(dog)
? '0 0 8px var(--bloodline-glow)'
: 'none'
}}>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<img
src={dog.photo_urls[0]}
alt={dog.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
)}
</div>
</Link>
{filteredDogs.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<p style={{ color: 'var(--text-secondary)' }}>No dogs found matching your search criteria.</p>
{/* Info — clicking navigates to detail */}
<Link
to={`/dogs/${dog.id}`}
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
>
<h3 style={{
fontSize: '1.125rem',
marginBottom: '0.25rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
flexWrap: 'wrap'
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dog.name}
</span>
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
{dog.sex === 'male' ? '♂' : '♀'}
</span>
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
</h3>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
}}>
<span>{dog.breed}</span>
{dog.birth_date && (
<>
<span>·</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Calendar size={12} />
{calculateAge(dog.birth_date)}
</span>
</>
)}
{dog.color && (
<>
<span>·</span>
<span>{dog.color}</span>
</>
)}
</div>
{dog.registration_number && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem', fontFamily: 'monospace',
color: 'var(--text-muted)'
}}>
<Hash size={10} />
{dog.registration_number}
</div>
)}
{dog.chic_number && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'rgba(99,102,241,0.1)',
border: '1px solid rgba(99,102,241,0.3)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem', fontWeight: 600,
color: '#818cf8', marginLeft: '0.5rem'
}}>
CHIC #{dog.chic_number}
</div>
)}
</Link>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
<Link
to={`/dogs/${dog.id}`}
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
>
<ArrowRight size={20} color="var(--text-muted)" />
</Link>
<button
className="btn btn-ghost"
title={`Delete ${dog.name}`}
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
style={{
padding: '0.4rem',
color: 'var(--text-muted)',
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
display: 'flex', alignItems: 'center',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ef4444'
e.currentTarget.style.borderColor = '#ef4444'
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)'
e.currentTarget.style.borderColor = 'transparent'
e.currentTarget.style.background = 'transparent'
}}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
<button
className="btn btn-ghost"
onClick={() => fetchDogs(page - 1, search, sexFilter)}
disabled={page <= 1 || loading}
style={{ padding: '0.5rem 1rem' }}
>
Previous
</button>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Page {page} of {totalPages}
</span>
<button
className="btn btn-ghost"
onClick={() => fetchDogs(page + 1, search, sexFilter)}
disabled={page >= totalPages || loading}
style={{ padding: '0.5rem 1rem' }}
>
Next
</button>
</div>
)}
{/* Add Dog Modal */}
{showAddModal && (
<DogForm
onClose={() => setShowAddModal(false)}
onSave={handleSave}
/>
)}
{/* Delete Confirmation Modal */}
{deleteTarget && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.65)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}>
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
<div style={{
width: 56, height: 56,
borderRadius: '50%',
background: 'rgba(239,68,68,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 1rem'
}}>
<Trash2 size={26} style={{ color: '#ef4444' }} />
</div>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete Dog?</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
permanently removed along with all parent relationships, health records,
and heat cycles. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => setDeleteTarget(null)}
disabled={deleting}
style={{ minWidth: 100 }}
>
Cancel
</button>
<button
className="btn"
onClick={handleDelete}
disabled={deleting}
style={{
minWidth: 140,
background: '#ef4444',
color: '#fff',
border: '1px solid #ef4444',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
}}
>
<Trash2 size={15} />
{deleting ? 'Deleting…' : 'Yes, Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,389 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2, ExternalLink } from 'lucide-react'
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
function ExternalDogs() {
const [dogs, setDogs] = useState([])
const [filteredDogs, setFilteredDogs] = useState([])
const [search, setSearch] = useState('')
const [sexFilter, setSexFilter] = useState('all')
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false)
useEffect(() => { fetchDogs() }, [])
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
const fetchDogs = async () => {
try {
const res = await axios.get('/api/dogs/external')
setDogs(res.data)
setLoading(false)
} catch (error) {
console.error('Error fetching external dogs:', error)
setLoading(false)
}
}
const filterDogs = () => {
let filtered = dogs
if (search) {
filtered = filtered.filter(dog =>
dog.name.toLowerCase().includes(search.toLowerCase()) ||
(dog.breed && dog.breed.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 handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await axios.delete(`/api/dogs/${deleteTarget.id}`)
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.')
} finally {
setDeleting(false)
}
}
const calculateAge = (birthDate) => {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) { years--; months += 12 }
if (years === 0) return `${months}mo`
if (months === 0) return `${years}y`
return `${years}y ${months}mo`
}
const hasChampionBlood = (dog) =>
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
if (loading) {
return <div className="container loading">Loading external dogs...</div>
}
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
<ExternalLink size={28} style={{ color: 'var(--primary)' }} />
<h1 style={{ margin: 0 }}>External Dogs</h1>
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'}
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
</p>
</div>
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={18} />
Add External Dog
</button>
</div>
{/* Search and Filter Bar */}
<div className="card" style={{ marginBottom: '1.5rem', padding: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '1rem', alignItems: 'center' }}>
<div style={{ position: 'relative' }}>
<Search size={18} style={{ position: 'absolute', left: '0.875rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
className="input"
placeholder="Search by name or breed..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ paddingLeft: '2.75rem' }}
/>
</div>
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '160px' }}>
<option value="all">All Genders</option>
<option value="male">Sires (Male) </option>
<option value="female">Dams (Female) </option>
</select>
{(search || sexFilter !== 'all') && (
<button
className="btn btn-ghost"
onClick={() => { setSearch(''); setSexFilter('all') }}
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
>
Clear
</button>
)}
</div>
</div>
{/* Dogs List */}
{filteredDogs.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
<ExternalLink size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
<h3 style={{ marginBottom: '0.5rem' }}>
{search || sexFilter !== 'all' ? 'No dogs found' : 'No external dogs yet'}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
{search || sexFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Add sires, dams, or ancestors that aren\'t part of your kennel roster.'}
</p>
{!search && sexFilter === 'all' && (
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={18} />
Add Your First External Dog
</button>
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{filteredDogs.map(dog => (
<div
key={dog.id}
className="card"
style={{
padding: '1rem',
display: 'flex',
gap: '1rem',
alignItems: 'center',
transition: 'var(--transition)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
}}
>
{/* Avatar */}
<Link
to={`/dogs/${dog.id}`}
style={{ flexShrink: 0, textDecoration: 'none' }}
>
<div style={{
width: '80px', height: '80px',
borderRadius: 'var(--radius)',
background: 'var(--bg-primary)',
border: dog.is_champion
? '2px solid var(--champion-gold)'
: hasChampionBlood(dog)
? '2px solid var(--bloodline-amber)'
: '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
boxShadow: dog.is_champion
? '0 0 8px var(--champion-glow)'
: hasChampionBlood(dog)
? '0 0 8px var(--bloodline-glow)'
: 'none'
}}>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<img
src={dog.photo_urls[0]}
alt={dog.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
)}
<div style={{
position: 'absolute',
top: 0,
right: 0,
background: 'var(--bg-secondary)',
borderBottomLeftRadius: 'var(--radius-sm)',
padding: '2px 4px',
fontSize: '0.625rem',
fontWeight: 'bold',
color: 'var(--text-muted)',
borderLeft: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: '2px'
}}>
<ExternalLink size={8} /> EXT
</div>
</div>
</Link>
{/* Info — clicking navigates to detail */}
<Link
to={`/dogs/${dog.id}`}
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
>
<h3 style={{
fontSize: '1.125rem',
marginBottom: '0.25rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
flexWrap: 'wrap'
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dog.name}
</span>
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
{dog.sex === 'male' ? '♂' : '♀'}
</span>
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
</h3>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
}}>
<span>{dog.breed}</span>
{dog.birth_date && (
<>
<span>·</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Calendar size={12} />
{calculateAge(dog.birth_date)}
</span>
</>
)}
{dog.color && (
<>
<span>·</span>
<span>{dog.color}</span>
</>
)}
</div>
{dog.registration_number && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem', fontFamily: 'monospace',
color: 'var(--text-muted)'
}}>
<Hash size={10} />
{dog.registration_number}
</div>
)}
</Link>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
<Link
to={`/dogs/${dog.id}`}
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
>
<ArrowRight size={20} color="var(--text-muted)" />
</Link>
<button
className="btn btn-ghost"
title={`Delete ${dog.name}`}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
style={{
padding: '0.4rem',
color: 'var(--text-muted)',
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
display: 'flex', alignItems: 'center',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ef4444'
e.currentTarget.style.borderColor = '#ef4444'
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)'
e.currentTarget.style.borderColor = 'transparent'
e.currentTarget.style.background = 'transparent'
}}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
{/* Add Dog Modal */}
{showAddModal && (
<DogForm
isExternal={true}
onClose={() => setShowAddModal(false)}
onSave={() => { fetchDogs(); setShowAddModal(false); }}
/>
)}
{/* Delete Confirmation Modal */}
{deleteTarget && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.65)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}>
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
<div style={{
width: 56, height: 56,
borderRadius: '50%',
background: 'rgba(239,68,68,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 1rem'
}}>
<Trash2 size={26} style={{ color: '#ef4444' }} />
</div>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete External Dog?</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
permanently removed. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => setDeleteTarget(null)}
disabled={deleting}
style={{ minWidth: 100 }}
>
Cancel
</button>
<button
className="btn"
onClick={handleDelete}
disabled={deleting}
style={{
minWidth: 140,
background: '#ef4444',
color: '#fff',
border: '1px solid #ef4444',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
}}
>
<Trash2 size={15} />
{deleting ? 'Deleting…' : 'Yes, Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default ExternalDogs

View File

@@ -0,0 +1,543 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import axios from 'axios'
import LitterForm from '../components/LitterForm'
// ─── Puppy Log Panel ────────────────────────────────────────────────────────────
function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
const [open, setOpen] = useState(false)
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(false)
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({
record_date: whelpingDate || '',
weight_oz: '',
weight_lbs: '',
notes: '',
record_type: 'weight_log'
})
const [saving, setSaving] = useState(false)
useEffect(() => { if (open) fetchLogs() }, [open])
const fetchLogs = async () => {
setLoading(true)
try {
const res = await axios.get(`/api/litters/${litterId}/puppies/${puppy.id}/logs`)
const parsed = res.data.map(l => {
try { return { ...l, _data: JSON.parse(l.description) } } catch { return { ...l, _data: {} } }
})
setLogs(parsed)
} catch (e) { console.error(e) }
finally { setLoading(false) }
}
const handleAdd = async () => {
if (!form.record_date) return
setSaving(true)
try {
await axios.post(`/api/litters/${litterId}/puppies/${puppy.id}/logs`, form)
setShowAdd(false)
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
fetchLogs()
} catch (e) { console.error(e) }
finally { setSaving(false) }
}
const handleDelete = async (logId) => {
if (!window.confirm('Delete this log entry?')) return
try {
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
fetchLogs()
} catch (e) { console.error(e) }
}
const TYPES = [
{ value: 'weight_log', label: '⚖️ Weight Check' },
{ value: 'health_note', label: '📝 Health Note' },
{ value: 'deworming', label: '🐛 Deworming' },
{ value: 'vaccination', label: '💉 Vaccination' },
]
return (
<div style={{ borderTop: '1px solid var(--border)', marginTop: '0.5rem' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: '0.5rem 0',
color: 'var(--text-secondary)', fontSize: '0.8rem'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<Weight size={13} /> Logs {logs.length > 0 && `(${logs.length})`}
</span>
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{open && (
<div style={{ paddingBottom: '0.5rem' }}>
{loading ? (
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>Loading...</p>
) : logs.length === 0 ? (
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>No logs yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', marginBottom: '0.5rem' }}>
{logs.map(l => (
<div key={l.id} style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)',
padding: '0.4rem 0.6rem', gap: '0.5rem'
}}>
<div style={{ fontSize: '0.75rem', flex: 1 }}>
<span style={{ fontWeight: 600 }}>
{new Date(l.record_date + 'T00:00:00').toLocaleDateString()}
</span>
{' • '}
<span style={{ color: 'var(--accent)' }}>
{TYPES.find(t => t.value === l.record_type)?.label || l.record_type}
</span>
{l._data?.weight_oz && <span> {l._data.weight_oz} oz</span>}
{l._data?.weight_lbs && <span> ({l._data.weight_lbs} lbs)</span>}
{l._data?.notes && (
<div style={{ color: 'var(--text-secondary)', marginTop: '0.1rem' }}>
{l._data.notes}
</div>
)}
</div>
<button
onClick={() => handleDelete(l.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#e53e3e', padding: 0, flexShrink: 0 }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{showAdd ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<input
type="date" className="input"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 120px' }}
value={form.record_date}
onChange={e => setForm(f => ({ ...f, record_date: e.target.value }))}
/>
<select
className="input"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 130px' }}
value={form.record_type}
onChange={e => setForm(f => ({ ...f, record_type: e.target.value }))}
>
{TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
{form.record_type === 'weight_log' && (
<div style={{ display: 'flex', gap: '0.4rem' }}>
<input
type="number" className="input" placeholder="oz" step="0.1" min="0"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
value={form.weight_oz}
onChange={e => setForm(f => ({ ...f, weight_oz: e.target.value }))}
/>
<input
type="number" className="input" placeholder="lbs" step="0.01" min="0"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
value={form.weight_lbs}
onChange={e => setForm(f => ({ ...f, weight_lbs: e.target.value }))}
/>
</div>
)}
<input
className="input" placeholder="Notes (optional)"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem' }}
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
/>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
className="btn btn-primary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
onClick={handleAdd} disabled={saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
onClick={() => setShowAdd(false)}
>
Cancel
</button>
</div>
</div>
) : (
<button
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem', width: '100%' }}
onClick={() => setShowAdd(true)}
>
<Plus size={12} style={{ marginRight: '0.3rem' }} /> Add Log Entry
</button>
)}
</div>
)}
</div>
)
}
// ─── Whelping Window Banner ───────────────────────────────────────────────
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00')
d.setDate(d.getDate() + n)
return d
}
function fmt(d) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) }
function WhelpingBanner({ breedingDate, whelpingDate }) {
if (whelpingDate) return null // already whelped, no need for estimate
if (!breedingDate) return null
const earliest = addDays(breedingDate, 58)
const expected = addDays(breedingDate, 63)
const latest = addDays(breedingDate, 68)
const today = new Date()
const daysUntil = Math.ceil((expected - today) / 86400000)
let urgency = 'var(--success)'
let urgencyBg = 'rgba(16,185,129,0.06)'
let statusLabel = `~${daysUntil} days away`
if (daysUntil <= 7 && daysUntil > 0) {
urgency = '#d97706'; urgencyBg = 'rgba(217,119,6,0.08)'
statusLabel = `⚠️ ${daysUntil} days — prepare whelping area!`
} else if (daysUntil <= 0) {
urgency = '#e53e3e'; urgencyBg = 'rgba(229,62,62,0.08)'
statusLabel = '🔴 Expected date has passed — confirm or update whelping date'
}
return (
<div className="card" style={{
marginBottom: '2rem', padding: '1rem',
borderLeft: `3px solid ${urgency}`,
background: urgencyBg
}}>
<div style={{ fontWeight: 600, marginBottom: '0.5rem', color: urgency }}>
💕 Projected Whelping Window
<span style={{ fontWeight: 400, fontSize: '0.82rem', marginLeft: '0.75rem', color: 'var(--text-secondary)' }}>
{statusLabel}
</span>
</div>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Earliest (Day 58)</span>
<br /><strong>{fmt(earliest)}</strong>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Expected (Day 63)</span>
<br /><strong style={{ color: urgency, fontSize: '1rem' }}>{fmt(expected)}</strong>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Latest (Day 68)</span>
<br /><strong>{fmt(latest)}</strong>
</div>
</div>
</div>
)
}
// ─── Main LitterDetail ─────────────────────────────────────────────────────────
function LitterDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [litter, setLitter] = useState(null)
const [loading, setLoading] = useState(true)
const [showEditForm, setShowEditForm] = useState(false)
const [showAddPuppy, setShowAddPuppy] = useState(false)
const [allDogs, setAllDogs] = useState([])
const [selectedPuppyId, setSelectedPuppyId] = useState('')
const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' })
const [addMode, setAddMode] = useState('existing')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
const fetchLitter = async () => {
try {
const res = await axios.get(`/api/litters/${id}`)
setLitter(res.data)
} catch (err) {
console.error('Error fetching litter:', err)
} finally {
setLoading(false)
}
}
const fetchAllDogs = async () => {
try {
const res = await axios.get('/api/dogs/all')
setAllDogs(res.data)
} catch (err) { console.error('Error fetching dogs:', err) }
}
const unlinkedDogs = allDogs.filter(d => {
if (!litter) return false
const alreadyInLitter = litter.puppies?.some(p => p.id === d.id)
const isSireOrDam = d.id === litter.sire_id || d.id === litter.dam_id
return !alreadyInLitter && !isSireOrDam
})
const handleLinkPuppy = async () => {
if (!selectedPuppyId) return
setSaving(true); setError('')
try {
await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`)
setSelectedPuppyId(''); setShowAddPuppy(false); fetchLitter()
} catch (err) {
setError(err.response?.data?.error || 'Failed to link puppy')
} finally { setSaving(false) }
}
const handleCreateAndLink = async () => {
if (!newPuppy.name) { setError('Puppy name is required'); return }
setSaving(true); setError('')
try {
const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date
const res = await axios.post('/api/dogs', {
name: newPuppy.name, sex: newPuppy.sex,
color: newPuppy.color, date_of_birth: dob,
breed: litter.dam_breed || '',
})
await axios.post(`/api/litters/${id}/puppies/${res.data.id}`)
setNewPuppy({ name: '', sex: 'male', color: '', dob: '' })
setShowAddPuppy(false); fetchLitter(); fetchAllDogs()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create puppy')
} finally { setSaving(false) }
}
const handleUnlinkPuppy = async (puppyId) => {
if (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
try {
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
fetchLitter()
} catch (err) { console.error('Error unlinking puppy:', err) }
}
if (loading) return <div className="container loading">Loading litter...</div>
if (!litter) return <div className="container"><p>Litter not found.</p></div>
const puppyCount = litter.puppies?.length ?? 0
return (
<div className="container">
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<button className="btn-icon" onClick={() => navigate('/litters')}>
<ArrowLeft size={20} />
</button>
<div style={{ flex: 1 }}>
<h1 style={{ margin: 0 }}>🐾 {litter.sire_name} × {litter.dam_name}</h1>
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0' }}>
Bred: {new Date(litter.breeding_date + 'T00:00:00').toLocaleDateString()}
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}`}
</p>
</div>
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>Edit Litter</button>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>{puppyCount}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Puppies Linked</div>
</div>
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
{litter.puppies?.filter(p => p.sex === 'male').length ?? 0}
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Males</div>
</div>
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
{litter.puppies?.filter(p => p.sex === 'female').length ?? 0}
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Females</div>
</div>
{litter.puppy_count > 0 && (
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
<div style={{ fontSize: '2rem', fontWeight: 700 }}>{litter.puppy_count}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Expected</div>
</div>
)}
</div>
{/* Projected whelping window */}
<WhelpingBanner breedingDate={litter.breeding_date} whelpingDate={litter.whelping_date} />
{/* Notes */}
{litter.notes && (
<div className="card" style={{ marginBottom: '2rem', padding: '1rem', borderLeft: '3px solid var(--accent)' }}>
<p style={{ margin: 0, fontStyle: 'italic', color: 'var(--text-secondary)' }}>{litter.notes}</p>
</div>
)}
{/* Puppies section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Puppies</h2>
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
</div>
{puppyCount === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<Dog size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '1rem' }}>
{litter.puppies.map(puppy => (
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
<button
className="btn-icon"
onClick={() => handleUnlinkPuppy(puppy.id)}
title="Remove from litter"
style={{ position: 'absolute', top: '0.75rem', right: '0.75rem', color: '#e53e3e' }}
>
<X size={14} />
</button>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
{puppy.sex === 'male' ? '🐦' : '🐥'}
</div>
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>{puppy.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{puppy.sex} {puppy.color && `${puppy.color}`}
</div>
{puppy.date_of_birth && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Born: {new Date(puppy.date_of_birth + 'T00:00:00').toLocaleDateString()}
</div>
)}
<button
className="btn btn-secondary"
style={{ marginTop: '0.75rem', width: '100%', fontSize: '0.8rem', padding: '0.4rem' }}
onClick={() => navigate(`/dogs/${puppy.id}`)}
>
<ExternalLink size={12} style={{ marginRight: '0.3rem' }} />
View Profile
</button>
{/* Weight / Health Log collapsible */}
<PuppyLogPanel
litterId={id}
puppy={puppy}
whelpingDate={litter.whelping_date || litter.breeding_date}
/>
</div>
))}
</div>
)}
{/* Add Puppy Modal */}
{showAddPuppy && (
<div className="modal-overlay" onClick={() => setShowAddPuppy(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '480px' }}>
<div className="modal-header">
<h2>Add Puppy to Litter</h2>
<button className="btn-icon" onClick={() => setShowAddPuppy(false)}><X size={24} /></button>
</div>
<div className="modal-body">
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button className={`btn ${addMode === 'existing' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setAddMode('existing')} style={{ flex: 1 }}>Link Existing Dog</button>
<button className={`btn ${addMode === 'new' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setAddMode('new')} style={{ flex: 1 }}>Create New Puppy</button>
</div>
{addMode === 'existing' ? (
<div className="form-group">
<label className="label">Select Dog</label>
<select className="input" value={selectedPuppyId}
onChange={e => setSelectedPuppyId(e.target.value)}>
<option value="">-- Select a dog --</option>
{unlinkedDogs.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth + 'T00:00:00').toLocaleDateString()}` : ''})
</option>
))}
</select>
{unlinkedDogs.length === 0 && (
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
No unlinked dogs available. Use "Create New Puppy" instead.
</p>
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
<div className="form-group">
<label className="label">Puppy Name *</label>
<input className="input" value={newPuppy.name}
onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))}
placeholder="e.g. Blue Collar" />
</div>
<div className="form-grid">
<div className="form-group">
<label className="label">Sex</label>
<select className="input" value={newPuppy.sex}
onChange={e => setNewPuppy(p => ({ ...p, sex: e.target.value }))}>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div className="form-group">
<label className="label">Color / Markings</label>
<input className="input" value={newPuppy.color}
onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))}
placeholder="e.g. Black & Tan" />
</div>
</div>
<div className="form-group">
<label className="label">Date of Birth</label>
<input type="date" className="input" value={newPuppy.dob}
onChange={e => setNewPuppy(p => ({ ...p, dob: e.target.value }))} />
{litter.whelping_date && !newPuppy.dob && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Will default to whelping date: {new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}
</p>
)}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>Cancel</button>
<button
className="btn btn-primary"
disabled={saving || (addMode === 'existing' && !selectedPuppyId)}
onClick={addMode === 'existing' ? handleLinkPuppy : handleCreateAndLink}
>
{saving ? 'Saving...' : addMode === 'existing' ? 'Link Puppy' : 'Create & Link'}
</button>
</div>
</div>
</div>
)}
{showEditForm && (
<LitterForm
litter={litter}
onClose={() => setShowEditForm(false)}
onSave={fetchLitter}
/>
)}
</div>
)
}
export default LitterDetail

View File

@@ -1,60 +1,189 @@
import { useEffect, useState } from 'react'
import { Activity } from 'lucide-react'
import { Activity, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import LitterForm from '../components/LitterForm'
const LIMIT = 50
function LitterList() {
const [litters, setLitters] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingLitter, setEditingLitter] = useState(null)
const [prefill, setPrefill] = useState(null)
const navigate = useNavigate()
useEffect(() => {
fetchLitters()
fetchLitters(1)
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
const stored = sessionStorage.getItem('prefillLitter')
if (stored) {
try {
const data = JSON.parse(stored)
setPrefill(data)
setEditingLitter(null)
setShowForm(true)
} catch (e) { /* ignore */ }
sessionStorage.removeItem('prefillLitter')
}
}, [])
const fetchLitters = async () => {
const fetchLitters = async (p = page) => {
try {
const res = await axios.get('/api/litters')
setLitters(res.data)
setLoading(false)
const res = await axios.get('/api/litters', { params: { page: p, limit: LIMIT } })
setLitters(res.data.data)
setTotal(res.data.total)
setPage(p)
} catch (error) {
console.error('Error fetching litters:', error)
} finally {
setLoading(false)
}
}
const totalPages = Math.ceil(total / LIMIT)
const handleCreate = () => {
setEditingLitter(null)
setPrefill(null)
setShowForm(true)
}
const handleEdit = (e, litter) => {
e.stopPropagation()
setEditingLitter(litter)
setPrefill(null)
setShowForm(true)
}
const handleDelete = async (e, id) => {
e.stopPropagation()
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
try {
await axios.delete(`/api/litters/${id}`)
fetchLitters(page)
} catch (error) {
console.error('Error deleting litter:', error)
}
}
const handleSave = () => {
fetchLitters(page)
}
if (loading) {
return <div className="container loading">Loading litters...</div>
}
return (
<div className="container">
<h1 style={{ marginBottom: '2rem' }}>Litters</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1>Litters</h1>
<button className="btn btn-primary" onClick={handleCreate}>
<Plus size={18} style={{ marginRight: '0.5rem' }} />
New Litter
</button>
</div>
{litters.length === 0 ? (
{total === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<h2>No litters recorded yet</h2>
<p style={{ color: 'var(--text-secondary)' }}>Start tracking breeding records</p>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem' }}>Create a litter after a breeding cycle to track puppies</p>
<button className="btn btn-primary" onClick={handleCreate}>
<Plus size={18} style={{ marginRight: '0.5rem' }} />
Create First Litter
</button>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{litters.map(litter => (
<div key={litter.id} className="card">
<h3>{litter.sire_name} × {litter.dam_name}</h3>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
Breeding Date: {new Date(litter.breeding_date).toLocaleDateString()}
</p>
{litter.whelping_date && (
<p style={{ color: 'var(--text-secondary)' }}>
Whelping Date: {new Date(litter.whelping_date).toLocaleDateString()}
</p>
)}
<p style={{ marginTop: '0.5rem' }}>
<strong>Puppies:</strong> {litter.puppy_count || litter.puppies?.length || 0}
</p>
<div
key={litter.id}
className="card"
style={{ cursor: 'pointer', transition: 'border-color 0.2s' }}
onClick={() => navigate(`/litters/${litter.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '0.5rem' }}>
🐾 {litter.sire_name} × {litter.dam_name}
</h3>
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
<span>📅 Bred: {new Date(litter.breeding_date).toLocaleDateString()}</span>
{litter.whelping_date && (
<span>💕 Whelped: {new Date(litter.whelping_date).toLocaleDateString()}</span>
)}
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
{litter.actual_puppy_count ?? litter.puppies?.length ?? litter.puppy_count ?? 0} puppies
</span>
</div>
{litter.notes && (
<p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: 'var(--text-secondary)', fontStyle: 'italic' }}>
{litter.notes}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
className="btn-icon"
title="Edit litter"
onClick={(e) => handleEdit(e, litter)}
>
<Edit2 size={16} />
</button>
<button
className="btn-icon"
title="Delete litter"
onClick={(e) => handleDelete(e, litter.id)}
style={{ color: '#e53e3e' }}
>
<Trash2 size={16} />
</button>
<ChevronRight size={20} style={{ color: 'var(--text-secondary)' }} />
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
<button
className="btn btn-ghost"
onClick={() => fetchLitters(page - 1)}
disabled={page <= 1 || loading}
style={{ padding: '0.5rem 1rem' }}
>
Previous
</button>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Page {page} of {totalPages}
</span>
<button
className="btn btn-ghost"
onClick={() => fetchLitters(page + 1)}
disabled={page >= totalPages || loading}
style={{ padding: '0.5rem 1rem' }}
>
Next
</button>
</div>
)}
{showForm && (
<LitterForm
litter={editingLitter}
prefill={prefill}
onClose={() => setShowForm(false)}
onSave={handleSave}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,280 @@
import { useState, useEffect, useCallback } from 'react'
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge, ShieldAlert } from 'lucide-react'
export default function PairingSimulator() {
const [dogs, setDogs] = useState([])
const [sireId, setSireId] = useState('')
const [damId, setDamId] = useState('')
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [dogsLoading, setDogsLoading] = useState(true)
const [relationWarning, setRelationWarning] = useState(null)
const [relationChecking, setRelationChecking] = useState(false)
const [geneticRisk, setGeneticRisk] = useState(null)
const [geneticChecking, setGeneticChecking] = useState(false)
useEffect(() => {
// include_external=1 ensures external sires/dams appear for pairing
fetch('/api/dogs?include_external=1')
.then(r => r.json())
.then(data => {
setDogs(Array.isArray(data) ? data : (data.dogs || []))
setDogsLoading(false)
})
.catch(() => setDogsLoading(false))
}, [])
// Check for direct relation whenever both sire and dam are selected
const checkRelation = useCallback(async (sid, did) => {
if (!sid || !did) {
setRelationWarning(null)
setGeneticRisk(null)
return
}
setRelationChecking(true)
setGeneticChecking(true)
try {
const [relRes, genRes] = await Promise.all([
fetch(`/api/pedigree/relations/${sid}/${did}`),
fetch(`/api/genetics/pairing-risk?sireId=${sid}&damId=${did}`)
])
const relData = await relRes.json()
setRelationWarning(relData.related ? relData.relationship : null)
const genData = await genRes.json()
setGeneticRisk(genData)
} catch {
setRelationWarning(null)
setGeneticRisk(null)
} finally {
setRelationChecking(false)
setGeneticChecking(false)
}
}, [])
function handleSireChange(e) {
const val = e.target.value
setSireId(val)
setResult(null)
checkRelation(val, damId)
}
function handleDamChange(e) {
const val = e.target.value
setDamId(val)
setResult(null)
checkRelation(sireId, val)
}
async function handleSimulate(e) {
e.preventDefault()
if (!sireId || !damId) return
setLoading(true)
setError(null)
setResult(null)
try {
const res = await fetch('/api/pedigree/trial-pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Simulation failed')
setResult(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const males = dogs.filter(d => d.sex === 'male')
const females = dogs.filter(d => d.sex === 'female')
const coiColor = (coi) => {
if (coi < 0.0625) return 'var(--success)'
if (coi < 0.125) return 'var(--warning)'
return 'var(--danger)'
}
const coiLabel = (coi) => {
if (coi < 0.0625) return 'Low'
if (coi < 0.125) return 'Moderate'
if (coi < 0.25) return 'High'
return 'Very High'
}
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<FlaskConical size={28} style={{ color: 'var(--primary)' }} />
<h1 style={{ margin: 0 }}>Pairing Simulator</h1>
</div>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding.
Includes both kennel and external dogs.
</p>
<div className="card" style={{ marginBottom: '1.5rem' }}>
<form onSubmit={handleSimulate}>
<div className="form-grid" style={{ marginBottom: '1rem' }}>
<div className="form-group">
<label className="label">Sire (Male) *</label>
{dogsLoading ? (
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
) : (
<select className="input" value={sireId} onChange={handleSireChange} required>
<option value="">Select sire...</option>
{males.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option>
))}
</select>
)}
</div>
<div className="form-group">
<label className="label">Dam (Female) *</label>
{dogsLoading ? (
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
) : (
<select className="input" value={damId} onChange={handleDamChange} required>
<option value="">Select dam...</option>
{females.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option>
))}
</select>
)}
</div>
</div>
{relationChecking && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
Checking relationship and genetics...
</div>
)}
{relationWarning && !relationChecking && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--danger)',
}}>
<ShieldAlert size={16} />
<strong>Related:</strong>&nbsp;{relationWarning}
</div>
)}
{geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
<div style={{
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--warning)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', fontWeight: 600 }}>
<ShieldAlert size={16} /> Genetic Risks Detected
</div>
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
{geneticRisk.risks.map(r => (
<li key={r.marker}>
<strong>{r.marker}</strong>: {r.message}
</li>
))}
</ul>
</div>
)}
{geneticRisk && geneticRisk.missing_data && !geneticChecking && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem', fontStyle: 'italic' }}>
* Sire or dam has missing genetic tests. Clearances cannot be fully verified.
</div>
)}
<button
type="submit"
className="btn btn-primary"
disabled={loading || dogsLoading || !sireId || !damId}
style={{ width: '100%' }}
>
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
</button>
</form>
</div>
{error && (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--danger)' }}>
<XCircle size={18} />
<strong>Error:</strong> {error}
</div>
</div>
)}
{result && (
<div className="card">
<h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Simulation Result
</h2>
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem',
padding: '1.25rem', marginBottom: '1rem',
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
border: `2px solid ${coiColor(result.coi)}`,
}}>
{result.coi < 0.0625
? <CheckCircle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
: <AlertTriangle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
}
<div>
<div style={{ fontSize: '2rem', fontWeight: 700, color: coiColor(result.coi), lineHeight: 1 }}>
{(result.coi * 100).toFixed(2)}%
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
COI &mdash; <strong style={{ color: coiColor(result.coi) }}>{coiLabel(result.coi)}</strong>
</div>
</div>
</div>
{result.commonAncestors && result.commonAncestors.length > 0 && (
<div>
<h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
Common Ancestors ({result.commonAncestors.length})
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{result.commonAncestors.map((a, i) => (
<span key={i} style={{
padding: '0.2rem 0.6rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8rem',
border: '1px solid var(--border)',
}}>{a.name}</span>
))}
</div>
</div>
)}
{result.recommendation && (
<div style={{
marginTop: '1rem', padding: '0.75rem 1rem',
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
borderRadius: 'var(--radius)',
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
fontSize: '0.875rem',
color: 'var(--text-secondary)',
}}>
{result.recommendation}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -1,16 +1,256 @@
import { useParams } from 'react-router-dom'
import { GitBranch } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, GitBranch, AlertCircle, Loader } from 'lucide-react'
import axios from 'axios'
import PedigreeTree from '../components/PedigreeTree'
import { transformPedigreeData, transformDescendantData, formatCOI, getPedigreeCompleteness } from '../utils/pedigreeHelpers'
function PedigreeView() {
const { id } = useParams()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [dog, setDog] = useState(null)
const [pedigreeData, setPedigreeData] = useState(null)
const [coiData, setCoiData] = useState(null)
const [generations, setGenerations] = useState(5)
const [viewMode, setViewMode] = useState('ancestors')
useEffect(() => {
fetchPedigreeData()
}, [id, generations, viewMode])
const fetchPedigreeData = async () => {
setLoading(true)
setError('')
try {
if (viewMode === 'ancestors') {
const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
const dogData = pedigreeRes.data
setDog(dogData)
const treeData = transformPedigreeData(dogData, generations)
setPedigreeData(treeData)
try {
const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
setCoiData(coiRes.data)
} catch (coiError) {
console.warn('COI calculation unavailable:', coiError)
setCoiData(null)
}
} else {
const descendantRes = await axios.get(`/api/pedigree/${id}/descendants?generations=${generations}`)
const dogData = descendantRes.data
setDog(dogData)
const treeData = transformDescendantData(dogData, generations)
setPedigreeData(treeData)
setCoiData(null)
}
setLoading(false)
} catch (err) {
console.error('Error fetching pedigree:', err)
setError(err.response?.data?.error || 'Failed to load pedigree data')
setLoading(false)
}
}
const completeness = pedigreeData ? getPedigreeCompleteness(pedigreeData, generations) : 0
const coiInfo = formatCOI(coiData?.coi)
if (loading) {
return (
<div className="container">
<div className="loading" style={{ textAlign: 'center', padding: '4rem' }}>
<Loader size={48} style={{ animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
<p>Loading pedigree data...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="container">
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<AlertCircle size={64} style={{ color: 'var(--danger)', margin: '0 auto 1rem' }} />
<h2>Error Loading Pedigree</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>{error}</p>
<button
className="btn btn-primary"
onClick={() => navigate('/dogs')}
style={{ marginTop: '1.5rem' }}
>
Back to Dogs
</button>
</div>
</div>
)
}
// Completeness bar colour — uses theme tokens
const barColor = completeness === 100 ? 'var(--success)' : 'var(--primary)'
return (
<div className="container">
<h1 style={{ marginBottom: '2rem' }}>Pedigree Chart</h1>
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
<GitBranch size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<h2>Interactive Pedigree Visualization</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>Coming soon - React D3 Tree integration for dog ID: {id}</p>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={() => navigate(`/dogs/${id}`)}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={20} />
Back to Profile
</button>
<div style={{ flex: 1, minWidth: '200px' }}>
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<GitBranch size={32} style={{ color: 'var(--primary)' }} />
{dog?.name}'s {viewMode === 'ancestors' ? 'Pedigree' : 'Descendants'}
</h1>
{dog?.registration_number && (
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0 0' }}>
Registration: {dog.registration_number}
</p>
)}
</div>
<div style={{ display: 'flex', background: 'var(--bg-tertiary)', padding: '4px', borderRadius: 'var(--radius)' }}>
<button
className={`btn ${viewMode === 'ancestors' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setViewMode('ancestors')}
style={{ padding: '0.5rem 1rem' }}
>
Ancestors
</button>
<button
className={`btn ${viewMode === 'descendants' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setViewMode('descendants')}
style={{ padding: '0.5rem 1rem' }}
>
Descendants
</button>
</div>
</div>
{/* Stats Bar */}
<div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
{viewMode === 'ancestors' && (
<>
{/* COI */}
<div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
Coefficient of Inbreeding
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: '700', color: coiInfo.color }}>
{coiInfo.value}
</span>
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: coiInfo.color + '20',
color: coiInfo.color,
textTransform: 'uppercase',
fontWeight: '600'
}}>
{coiInfo.level}
</span>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
{coiInfo.description}
</div>
</div>
{/* Completeness */}
<div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
Pedigree Completeness
</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
{completeness}%
</div>
<div style={{ marginTop: '0.5rem' }}>
<div style={{
height: '6px',
background: 'var(--bg-tertiary)',
borderRadius: '3px',
overflow: 'hidden',
border: '1px solid var(--border)'
}}>
<div style={{
height: '100%',
width: `${completeness}%`,
background: barColor,
borderRadius: '3px',
transition: 'width 0.4s ease',
boxShadow: `0 0 6px ${barColor}`
}} />
</div>
</div>
</div>
</>
)}
{/* Generations */}
<div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
Generations Displayed
</div>
<select
className="input"
value={generations}
onChange={(e) => setGenerations(Number(e.target.value))}
style={{ marginTop: '0.25rem' }}
>
<option value={3}>3 Generations</option>
<option value={4}>4 Generations</option>
<option value={5}>5 Generations</option>
</select>
</div>
</div>
</div>
{/* Pedigree Tree */}
<div className="card" style={{ padding: 0 }}>
{pedigreeData ? (
<PedigreeTree
dogId={id}
pedigreeData={pedigreeData}
coi={coiData?.coi}
/>
) : (
<div style={{ textAlign: 'center', padding: '4rem' }}>
<GitBranch size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem' }} />
<h3>No Pedigree Data Available</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Add parent information to this dog to build the pedigree tree.
</p>
</div>
)}
</div>
{/* Tip */}
<div className="card" style={{
marginTop: '1rem',
background: 'var(--bg-elevated)',
border: '1px solid var(--border-light)'
}}>
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: 'var(--primary)' }}>&#128161;</span>
<span>
<strong style={{ color: 'var(--text-primary)' }}>Tip:</strong>{' '}
Click any ancestor node to navigate to their profile.
Use the zoom controls or scroll to explore the tree, and drag to pan.
</span>
</div>
</div>
</div>
)

View File

@@ -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 (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<div style={{
width: '2.5rem', height: '2.5rem',
borderRadius: 'var(--radius)',
background: 'linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(194,134,42,0.3)'
}}>
<Settings size={18} color="#0e0f0c" />
</div>
<div>
<h1 style={{ marginBottom: 0 }}>Settings</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Kennel profile &amp; app configuration
</p>
</div>
</div>
<div className="divider" />
<form onSubmit={handleSubmit}>
<div className="card">
<h3 style={{ marginBottom: '1.5rem', color: 'var(--primary-light)' }}>Kennel Information</h3>
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
<div className="form-grid">
{FIELDS.map(field => (
<div className="form-group" key={field.key}>
<label className="label">
{field.label}
{field.required && <span style={{ color: 'var(--danger)', marginLeft: '0.25rem' }}>*</span>}
</label>
<input
type={field.type || 'text'}
className="input"
placeholder={field.placeholder}
value={form[field.key] || ''}
onChange={e => handleChange(field.key, e.target.value)}
/>
</div>
))}
</div>
<div className="divider" />
{/* Preview */}
{form.kennel_name && (
<div style={{
marginBottom: '1.5rem',
padding: '1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)'
}}>
<p className="label" style={{ marginBottom: '0.5rem' }}>Header Preview</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.025em',
background: 'linear-gradient(135deg, #c9940a 0%, #b5620a 50%, #8b2500 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>
{form.kennel_name}
</span>
{form.kennel_tagline && (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', fontStyle: 'italic' }}>
{form.kennel_tagline}
</span>
)}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', alignItems: 'center' }}>
{saved && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)', fontSize: '0.875rem' }}>
<CheckCircle size={16} /> Saved!
</span>
)}
<button
type="submit"
className="btn btn-primary"
disabled={saving}
>
<Save size={16} />
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,227 @@
/**
* Transform API pedigree data to react-d3-tree format
* @param {Object} dog - Dog object from API with nested sire/dam
* @param {number} maxGenerations - Maximum generations to display (default 5)
* @returns {Object} Tree data in react-d3-tree format
*/
export const transformPedigreeData = (dog, maxGenerations = 5) => {
if (!dog) return null
const buildTree = (dogData, generation = 0) => {
if (!dogData || generation >= maxGenerations) {
return null
}
const node = {
name: dogData.name || 'Unknown',
attributes: {
id: dogData.id,
sex: dogData.sex,
registration: dogData.registration_number || '',
birth_year: dogData.birth_date ? new Date(dogData.birth_date).getFullYear() : ''
},
children: []
}
// Add sire (father) to children
if (dogData.sire) {
const sireNode = buildTree(dogData.sire, generation + 1)
if (sireNode) {
node.children.push(sireNode)
}
}
// Add dam (mother) to children
if (dogData.dam) {
const damNode = buildTree(dogData.dam, generation + 1)
if (damNode) {
node.children.push(damNode)
}
}
// Remove empty children array
if (node.children.length === 0) {
delete node.children
}
return node
}
return buildTree(dog)
}
/**
* Calculate total ancestors in pedigree
* @param {Object} treeData - Tree data structure
* @returns {number} Total number of ancestors
*/
export const countAncestors = (treeData) => {
if (!treeData) return 0
let count = 1
if (treeData.children) {
treeData.children.forEach(child => {
count += countAncestors(child)
})
}
return count - 1 // Exclude the root dog
}
/**
* Get generation counts
* @param {Object} treeData - Tree data structure
* @returns {Object} Generation counts { 1: count, 2: count, ... }
*/
export const getGenerationCounts = (treeData) => {
const counts = {}
const traverse = (node, generation = 0) => {
if (!node) return
counts[generation] = (counts[generation] || 0) + 1
if (node.children) {
node.children.forEach(child => traverse(child, generation + 1))
}
}
traverse(treeData)
delete counts[0] // Remove the root dog
return counts
}
/**
* Check if pedigree is complete for given generations
* @param {Object} treeData - Tree data structure
* @param {number} generations - Number of generations to check
* @returns {boolean} True if complete
*/
export const isPedigreeComplete = (treeData, generations = 3) => {
const expectedCount = Math.pow(2, generations) - 1
const actualCount = countAncestors(treeData)
return actualCount >= expectedCount
}
/**
* Find common ancestors between two dogs
* @param {Object} dog1Tree - First dog's pedigree tree
* @param {Object} dog2Tree - Second dog's pedigree tree
* @returns {Array} Array of common ancestor IDs
*/
export const findCommonAncestors = (dog1Tree, dog2Tree) => {
const getAncestorIds = (tree) => {
const ids = new Set()
const traverse = (node) => {
if (!node) return
if (node.attributes?.id) ids.add(node.attributes.id)
if (node.children) {
node.children.forEach(traverse)
}
}
traverse(tree)
return ids
}
const ids1 = getAncestorIds(dog1Tree)
const ids2 = getAncestorIds(dog2Tree)
return Array.from(ids1).filter(id => ids2.has(id))
}
/**
* Format COI value with risk level
* @param {number} coi - Coefficient of Inbreeding
* @returns {Object} { value, level, color, description }
*/
export const formatCOI = (coi) => {
if (coi === null || coi === undefined) {
return {
value: 'N/A',
level: 'unknown',
color: '#6b7280',
description: 'COI cannot be calculated'
}
}
const value = (coi * 100).toFixed(2)
if (coi <= 0.05) {
return {
value: `${value}%`,
level: 'low',
color: '#10b981',
description: 'Low inbreeding - Excellent genetic diversity'
}
} else if (coi <= 0.10) {
return {
value: `${value}%`,
level: 'medium',
color: '#f59e0b',
description: 'Moderate inbreeding - Acceptable with caution'
}
} else {
return {
value: `${value}%`,
level: 'high',
color: '#ef4444',
description: 'High inbreeding - Consider genetic diversity'
}
}
}
/**
* Get pedigree completeness percentage
* @param {Object} treeData - Tree data structure
* @param {number} targetGenerations - Target generations
* @returns {number} Percentage complete (0-100)
*/
export const getPedigreeCompleteness = (treeData, targetGenerations = 5) => {
const expectedTotal = Math.pow(2, targetGenerations) - 1
const actualCount = countAncestors(treeData)
return Math.min(100, Math.round((actualCount / expectedTotal) * 100))
}
/**
* Transform API descendant data to react-d3-tree format
* @param {Object} dog - Dog object from API with nested offspring array
* @param {number} maxGenerations - Maximum generations to display (default 3)
* @returns {Object} Tree data in react-d3-tree format
*/
export const transformDescendantData = (dog, maxGenerations = 3) => {
if (!dog) return null
const buildTree = (dogData, generation = 0) => {
if (!dogData || generation >= maxGenerations) {
return null
}
const node = {
name: dogData.name || 'Unknown',
attributes: {
id: dogData.id,
sex: dogData.sex,
registration: dogData.registration_number || '',
birth_year: dogData.birth_date ? new Date(dogData.birth_date).getFullYear() : ''
},
children: []
}
if (dogData.offspring && dogData.offspring.length > 0) {
dogData.offspring.forEach(child => {
const childNode = buildTree(child, generation + 1)
if (childNode) {
node.children.push(childNode)
}
})
}
if (node.children.length === 0) {
delete node.children
}
return node
}
return buildTree(dog)
}

View File

@@ -13,6 +13,10 @@ export default defineConfig({
'/uploads': {
target: 'http://localhost:3000',
changeOrigin: true
},
'/static': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
},

View File

@@ -9,11 +9,13 @@ services:
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
- ./static:/app/static
environment:
- NODE_ENV=production
- PORT=3000
- DB_PATH=/app/data/breedr.db
- UPLOAD_PATH=/app/uploads
- STATIC_PATH=/app/static
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]

324
docs/COMPACT_CARDS.md Normal file
View File

@@ -0,0 +1,324 @@
# Compact Info Card Design
## Problem Statement
The original design used large square photo grids that consumed excessive screen space, making it difficult to scan through multiple dogs quickly. Photos were displayed at 1:1 aspect ratio taking up 50-100% of card width.
## Solution: Horizontal Info Cards
Transformed to a **compact horizontal card layout** with small avatar photos and prominent metadata, optimized for information scanning and list navigation.
---
## Design Specifications
### Layout Structure
```
┌─────────────────────────────────────────────────────────────────┐
│ [Avatar] Name ♂ Breed • Age • Color → │
│ 80x80 Golden Retriever #REG-12345 │
└─────────────────────────────────────────────────────────────────┘
```
### Card Components
#### 1. Avatar Photo (80x80px)
- **Size:** Fixed 80px × 80px
- **Shape:** Rounded corners (var(--radius))
- **Border:** 2px solid var(--border)
- **Background:** var(--bg-primary) when no photo
- **Fallback:** Dog icon at 32px, muted color
- **Object Fit:** cover (crops to fill square)
#### 2. Info Section (Flex: 1)
- **Name:** 1.125rem, bold, truncate with ellipsis
- **Sex Icon:** Colored ♂/♀ (blue for male, pink for female)
- **Metadata Row:**
- Breed name
- Age (calculated, with calendar icon)
- Color (if available)
- Separated by bullets (•)
- **Registration Badge:**
- Monospace font
- Hash icon prefix
- Dark background pill
- 1px border
#### 3. Arrow Indicator
- **Icon:** ArrowRight at 20px
- **Color:** var(--text-muted)
- **Opacity:** 0.5 default, increases on hover
- **Purpose:** Visual affordance for clickability
---
## Space Comparison
### Before (Square Grid)
```
[===============]
[ Photo ]
[ 300x300 ]
[===============]
Name
Breed • Sex
```
**Height:** ~380px per card
**Width:** 280-300px
**Photos per viewport:** 2-3 (desktop)
### After (Horizontal Card)
```
[Avatar] Name, Breed, Age, Badge →
80x80
```
**Height:** ~100px per card
**Width:** Full container width
**Cards per viewport:** 6-8 (desktop)
### Metrics
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Card height | 380px | 100px | **-74%** |
| Photo area | 90,000px² | 6,400px² | **-93%** |
| Scannable info | 2-3 cards | 6-8 cards | **+200%** |
| Scroll distance | 760px | 200px | **-74%** |
---
## Implementation Details
### React Component Structure
```jsx
<Link to={`/dogs/${dog.id}`} className="card">
{/* Avatar */}
<div className="avatar-80">
{photo ? <img src={photo} /> : <Dog icon />}
</div>
{/* Info */}
<div className="info-section">
<h3>{name} <span>{sex icon}</span></h3>
<div className="metadata">
{breed} {age} {color}
</div>
<div className="badge">
<Hash /> {registration}
</div>
</div>
{/* Arrow */}
<ArrowRight />
</Link>
```
### CSS Styling
```css
.card {
display: flex;
gap: 1rem;
align-items: center;
padding: 1rem;
transition: all 0.2s;
}
.card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
.avatar-80 {
width: 80px;
height: 80px;
border-radius: var(--radius);
border: 2px solid var(--border);
overflow: hidden;
}
.info-section {
flex: 1;
min-width: 0; /* Allow text truncation */
}
```
---
## Age Calculation
Dynamic age display from birth date:
```javascript
const calculateAge = (birthDate) => {
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) {
years--
months += 12
}
// Format: "2y 3mo" or "8mo" or "3y"
if (years === 0) return `${months}mo`
if (months === 0) return `${years}y`
return `${years}y ${months}mo`
}
```
---
## Interactive States
### Default
- Border: var(--border)
- Shadow: var(--shadow-sm)
- Transform: none
### Hover
- Border: var(--primary)
- Shadow: 0 8px 16px rgba(0,0,0,0.3)
- Transform: translateY(-2px)
- Arrow opacity: 1.0
- Transition: 0.2s cubic-bezier
### Active/Click
- Navigate to detail page
- Maintains selection state in history
---
## Responsive Behavior
### Desktop (>768px)
- Full horizontal layout
- All metadata visible
- Hover effects enabled
### Tablet (768px - 1024px)
- Slightly smaller avatar (70px)
- Abbreviated metadata
- Touch-friendly spacing
### Mobile (<768px)
- Avatar: 60px
- Name on top line
- Metadata stacks below
- Registration badge wraps
- Larger tap targets
---
## Accessibility
### Keyboard Navigation
- Cards are focusable links
- Tab order follows visual order
- Enter/Space to activate
- Focus ring with primary color
### Screen Readers
- Semantic HTML (Link + heading structure)
- Alt text on avatar images
- Icon meanings in aria-labels
- Registration formatted as code
### Color Contrast
- Name: High contrast (var(--text-primary))
- Metadata: Medium contrast (var(--text-secondary))
- Icons: Sufficient contrast ratios
- Sex icons: Color + symbol (not color-only)
---
## Benefits
### User Experience
1. **Faster Scanning** - See 3x more dogs without scrolling
2. **Quick Comparison** - All key info visible at once
3. **Less Cognitive Load** - Consistent layout, predictable
4. **Better Navigation** - Clear visual hierarchy
### Performance
1. **Smaller Images** - Avatar size reduces bandwidth
2. **Lazy Loading** - Efficient with IntersectionObserver
3. **Less Rendering** - Simpler DOM structure
4. **Faster Scrolling** - Fewer pixels to paint
### Mobile
1. **Touch Targets** - Full card width clickable
2. **Vertical Real Estate** - More content on screen
3. **Thumb-Friendly** - No precise tapping required
4. **Data Efficient** - Smaller photo downloads
---
## Usage Context
### Dashboard
- Shows 6-8 recent dogs
- "View All" button to Dogs page
- Provides quick overview
### Dogs List
- Full searchable/filterable catalog
- Horizontal scroll on mobile
- Infinite scroll potential
- Batch operations possible
### NOT Used For
- Dog detail page (uses full photo gallery)
- Pedigree tree (uses compact nodes)
- Print layouts (uses different format)
---
## Future Enhancements
### Planned
- [ ] Checkbox selection mode (bulk actions)
- [ ] Drag-to-reorder in custom lists
- [ ] Quick actions menu (edit, delete)
- [ ] Photo upload from card
- [ ] Inline editing of name/breed
### Considered
- Multi-select with Shift+Click
- Card density options (compact/comfortable/spacious)
- Alternative views (grid toggle)
- Column sorting (name, age, breed)
- Grouping (by breed, age range)
---
## Examples
### Example 1: Male with Photo
```
┌───────────────────────────────────────────────────────────┐
│ [Photo] Max ♂ → │
│ Golden Retriever • 2y 3mo • Golden │
│ #AKC-SR123456 │
└───────────────────────────────────────────────────────────┘
```
### Example 2: Female No Photo
```
┌───────────────────────────────────────────────────────────┐
│ [🐶] Bella ♀ → │
│ icon Labrador Retriever • 8mo • Black │
└───────────────────────────────────────────────────────────┘
```
### Example 3: Puppy No Registration
```
┌───────────────────────────────────────────────────────────┐
│ [Photo] Rocky ♂ → │
│ German Shepherd • 3mo │
└───────────────────────────────────────────────────────────┘
```
---
*Last Updated: March 8, 2026*

304
docs/MICROCHIP_FIX.md Normal file
View File

@@ -0,0 +1,304 @@
# Microchip Field Fix
## Problem
The microchip field in the dogs table had a `UNIQUE` constraint defined directly on the column:
```sql
microchip TEXT UNIQUE
```
In SQLite, when a `UNIQUE` constraint is applied to a nullable column, **only one row can have a NULL value**. This caused the error:
```
UNIQUE constraint failed: dogs.microchip
```
When trying to add a second dog without a microchip number.
---
## Solution
Removed the inline `UNIQUE` constraint and replaced it with a **partial unique index** that only applies to non-NULL values:
```sql
-- Column definition (no UNIQUE constraint)
microchip TEXT
-- Partial unique index (only for non-NULL values)
CREATE UNIQUE INDEX idx_dogs_microchip
ON dogs(microchip)
WHERE microchip IS NOT NULL;
```
### Result:
- Multiple dogs can have no microchip (NULL values allowed)
- Dogs with microchips still cannot have duplicates
- Field is now truly optional
---
## Migration Required
If you have an existing database with the old schema, you **must run the migration** before the fix will work.
### Option 1: Docker Container (Recommended)
```bash
# Enter the running container
docker exec -it breedr sh
# Run the migration script
node server/db/migrate_microchip.js
# Exit container
exit
# Restart the container to apply changes
docker restart breedr
```
### Option 2: Direct Node Execution
```bash
cd /path/to/breedr
node server/db/migrate_microchip.js
```
### Option 3: Rebuild from Scratch (Data Loss)
```bash
# Stop container
docker stop breedr
# Remove old database
rm /mnt/user/appdata/breedr/breedr.db
# Start container (will create fresh database)
docker start breedr
```
**Warning:** Option 3 deletes all data. Only use if you have no important data or have a backup.
---
## What the Migration Does
### Step-by-Step Process
1. **Check Database Exists** - Skips if no database found
2. **Create New Table** - With corrected schema (no UNIQUE on microchip)
3. **Copy All Data** - Transfers all dogs from old table to new
4. **Drop Old Table** - Removes the table with bad constraint
5. **Rename New Table** - Makes new table the primary dogs table
6. **Create Partial Index** - Adds unique index only for non-NULL microchips
7. **Recreate Indexes** - Restores name and registration indexes
8. **Recreate Triggers** - Restores updated_at timestamp trigger
### Safety Features
- **Idempotent** - Can be run multiple times safely
- **Data Preservation** - All data is copied before old table is dropped
- **Foreign Keys** - Temporarily disabled during migration
- **Error Handling** - Clear error messages if something fails
---
## Verification
After migration, you should be able to:
### Test 1: Add Dog Without Microchip
```bash
curl -X POST http://localhost:3000/api/dogs \
-H "Content-Type: application/json" \
-d '{
"name": "Test Dog 1",
"breed": "Golden Retriever",
"sex": "male"
}'
```
**Expected:** Success (no microchip error)
### Test 2: Add Another Dog Without Microchip
```bash
curl -X POST http://localhost:3000/api/dogs \
-H "Content-Type: application/json" \
-d '{
"name": "Test Dog 2",
"breed": "Labrador",
"sex": "female"
}'
```
**Expected:** Success (multiple NULL microchips allowed)
### Test 3: Add Dog With Microchip
```bash
curl -X POST http://localhost:3000/api/dogs \
-H "Content-Type: application/json" \
-d '{
"name": "Test Dog 3",
"breed": "Beagle",
"sex": "male",
"microchip": "985112345678901"
}'
```
**Expected:** Success
### Test 4: Try Duplicate Microchip
```bash
curl -X POST http://localhost:3000/api/dogs \
-H "Content-Type: application/json" \
-d '{
"name": "Test Dog 4",
"breed": "Poodle",
"sex": "female",
"microchip": "985112345678901"
}'
```
**Expected:** Error (duplicate microchip not allowed)
---
## Database Schema Comparison
### Before (Broken)
```sql
CREATE TABLE dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
breed TEXT NOT NULL,
sex TEXT NOT NULL,
microchip TEXT UNIQUE, -- ❌ Only one NULL allowed
...
);
```
### After (Fixed)
```sql
CREATE TABLE dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
breed TEXT NOT NULL,
sex TEXT NOT NULL,
microchip TEXT, -- ✓ Multiple NULLs allowed
...
);
-- Partial unique index
CREATE UNIQUE INDEX idx_dogs_microchip
ON dogs(microchip)
WHERE microchip IS NOT NULL; -- ✓ Only enforces uniqueness on non-NULL
```
---
## Technical Details
### Why SQLite Behaves This Way
From SQLite documentation:
> For the purposes of UNIQUE constraints, NULL values are considered distinct from all other values, including other NULLs. However, when a UNIQUE constraint is defined on a column, SQLite treats NULL as a single value.
This is a quirk of SQLite's implementation. PostgreSQL and MySQL allow multiple NULLs in UNIQUE columns by default.
### Partial Index Solution
Partial indexes (with WHERE clause) were introduced in SQLite 3.8.0 (2013). They allow us to:
1. Create an index that only includes certain rows
2. In this case, only rows where `microchip IS NOT NULL`
3. This means the uniqueness constraint doesn't apply to NULL values
4. Multiple NULL values are now allowed
---
## Rollback (If Needed)
If something goes wrong during migration:
### Manual Rollback Steps
1. **Stop the application**
```bash
docker stop breedr
```
2. **Restore from backup** (if you made one)
```bash
cp /mnt/user/appdata/breedr/breedr.db.backup \
/mnt/user/appdata/breedr/breedr.db
```
3. **Start the application**
```bash
docker start breedr
```
### Create Backup Before Migration
```bash
# Stop container
docker stop breedr
# Create backup
cp /mnt/user/appdata/breedr/breedr.db \
/mnt/user/appdata/breedr/breedr.db.backup
# Start container
docker start breedr
# Run migration
docker exec -it breedr node server/db/migrate_microchip.js
```
---
## Future Prevention
All new databases created with the updated schema will have the correct constraint from the start. No migration needed for:
- Fresh installations
- Deleted and recreated databases
- Databases created after this fix
---
## Related Files
- **Schema Definition:** `server/db/init.js`
- **Migration Script:** `server/db/migrate_microchip.js`
- **This Document:** `docs/MICROCHIP_FIX.md`
---
## Changelog
### March 8, 2026
- Identified UNIQUE constraint issue with microchip field
- Created migration script to fix existing databases
- Updated schema for new databases
- Added partial unique index solution
- Documented problem and solution
---
*If you encounter any issues with the migration, check the container logs:*
```bash
docker logs breedr
```
*Or open a GitHub issue with the error details.*

View File

@@ -2,155 +2,239 @@ 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 - Core registry
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 UNIQUE,
photo_urls TEXT, -- JSON array of photo URLs
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Parents table - Relationship mapping
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_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_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!');
db.close();
return true;
}
const dbPath = path.join(__dirname, '../../data');
const db = new Database(path.join(dbPath, 'breedr.db'));
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 };
function initDatabase() {
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
// Run initialization if called directly
if (require.main === module) {
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
initDatabase(dbPath);
// ── 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,
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'],
];
for (const [col, def] of dogMigrations) {
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* 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 (OFA-extended) ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL,
test_type TEXT,
test_name TEXT,
test_date TEXT NOT NULL,
ofa_result TEXT,
ofa_number TEXT,
performed_by TEXT,
expires_at TEXT,
document_url TEXT,
result TEXT,
vet_name TEXT,
next_due TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
// migrate: add OFA-specific columns if missing (covers existing DBs)
const healthMigrations = [
['test_type', 'TEXT'],
['ofa_result', 'TEXT'],
['ofa_number', 'TEXT'],
['performed_by', 'TEXT'],
['expires_at', 'TEXT'],
['document_url', 'TEXT'],
['result', 'TEXT'],
['vet_name', 'TEXT'],
['next_due', 'TEXT'],
];
for (const [col, def] of healthMigrations) {
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
// ── Genetic Tests (DNA Panel) ──────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS genetic_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
test_provider TEXT,
marker TEXT NOT NULL,
result TEXT NOT NULL CHECK(result IN ('clear', 'carrier', 'affected', 'not_tested')),
test_date TEXT,
document_url TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
const geneticMigrations = [
['test_provider', 'TEXT'],
['marker', "TEXT NOT NULL DEFAULT 'unknown'"],
['result', "TEXT NOT NULL DEFAULT 'not_tested'"],
['test_date', 'TEXT'],
['document_url', 'TEXT'],
['notes', 'TEXT']
];
for (const [col, def] of geneticMigrations) {
try { db.exec(`ALTER TABLE genetic_tests ADD COLUMN ${col} ${def}`); } catch (_) {}
}
// ── Cancer History ────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS cancer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
cancer_type TEXT,
age_at_diagnosis TEXT,
age_at_death TEXT,
cause_of_death TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
const cancerMigrations = [
['cancer_type', 'TEXT'],
['age_at_diagnosis', 'TEXT'],
['age_at_death', 'TEXT'],
['cause_of_death', 'TEXT'],
['notes', 'TEXT']
];
for (const [col, def] of cancerMigrations) {
try { db.exec(`ALTER TABLE cancer_history ADD COLUMN ${col} ${def}`); } catch (_) {}
}
// ── 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'))
)
`);
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 */ }
}
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 };

View File

@@ -0,0 +1,52 @@
const Database = require('better-sqlite3');
const path = require('path');
function migrateLitterId(dbPath) {
console.log('Running litter_id migration...');
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
try {
// Check if litter_id column already exists
const tableInfo = db.prepare("PRAGMA table_info(dogs)").all();
const hasLitterId = tableInfo.some(col => col.name === 'litter_id');
if (hasLitterId) {
console.log('litter_id column already exists. Skipping migration.');
db.close();
return;
}
// Add litter_id column to dogs table
db.exec(`
ALTER TABLE dogs ADD COLUMN litter_id INTEGER;
`);
// Create index for litter_id
db.exec(`
CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id);
`);
// Add foreign key relationship (SQLite doesn't support ALTER TABLE ADD CONSTRAINT)
// So we'll rely on application-level constraint checking
console.log('✓ Added litter_id column to dogs table');
console.log('✓ Created index on litter_id');
console.log('Migration completed successfully!');
db.close();
} catch (error) {
console.error('Migration failed:', error.message);
db.close();
throw error;
}
}
module.exports = { migrateLitterId };
// Run migration if called directly
if (require.main === module) {
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
migrateLitterId(dbPath);
}

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Migration: Fix microchip UNIQUE constraint
*
* Problem: The microchip field had a UNIQUE constraint which prevents multiple NULL values.
* Solution: Remove the constraint and create a partial unique index that only applies to non-NULL values.
*
* This script can be run safely multiple times.
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
function migrateMicrochip() {
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
if (!fs.existsSync(dbPath)) {
console.log('No database found at', dbPath);
console.log('Skipping migration - database will be created with correct schema.');
return;
}
console.log('Migrating database:', dbPath);
const db = new Database(dbPath);
db.pragma('foreign_keys = OFF'); // Temporarily disable for migration
try {
console.log('Starting microchip field migration...');
// Check if the old unique constraint exists
const tableInfo = db.pragma('table_info(dogs)');
const microchipField = tableInfo.find(col => col.name === 'microchip');
if (!microchipField) {
console.log('Microchip field not found. Skipping migration.');
return;
}
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
console.log('Step 1: Creating new dogs table with correct schema...');
db.exec(`
-- Create new table with correct schema
CREATE TABLE IF NOT EXISTS dogs_new (
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,
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('Step 2: Copying data from old table...');
// Copy all data from old table to new table
db.exec(`
INSERT INTO dogs_new
SELECT * FROM dogs;
`);
console.log('Step 3: Dropping old table and renaming new table...');
// Drop old table and rename new table
db.exec(`
DROP TABLE dogs;
ALTER TABLE dogs_new RENAME TO dogs;
`);
console.log('Step 4: Creating partial unique index for microchip...');
// Create partial unique index (only applies to non-NULL values)
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
ON dogs(microchip)
WHERE microchip IS NOT NULL;
`);
console.log('Step 5: Recreating other indexes...');
// Recreate other indexes
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);
`);
console.log('Step 6: Recreating triggers...');
// Recreate the updated_at trigger
db.exec(`
DROP TRIGGER IF EXISTS update_dogs_timestamp;
CREATE TRIGGER 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('✓ Migration completed successfully!');
console.log('✓ Microchip field is now optional and can be left empty.');
console.log('✓ Multiple dogs can have no microchip (NULL value).');
console.log('✓ Unique constraint still prevents duplicate microchip numbers.');
} catch (error) {
console.error('Migration failed:', error.message);
console.error('\nYou may need to restore from backup or manually fix the database.');
throw error;
} finally {
db.pragma('foreign_keys = ON'); // Re-enable foreign keys
db.close();
}
}
if (require.main === module) {
console.log('='.repeat(60));
console.log('BREEDR Database Migration: Microchip Field Fix');
console.log('='.repeat(60));
console.log('');
migrateMicrochip();
console.log('');
console.log('='.repeat(60));
console.log('Migration Complete');
console.log('='.repeat(60));
}
module.exports = { migrateMicrochip };

413
server/db/migrations.js Normal file
View File

@@ -0,0 +1,413 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
/**
* Migration System for BREEDR
* Automatically runs on startup to ensure schema is correct
*/
class MigrationRunner {
constructor(dbPath) {
this.dbPath = dbPath;
this.db = null;
}
connect() {
this.db = new Database(this.dbPath);
this.db.pragma('foreign_keys = ON');
}
close() {
if (this.db) {
this.db.close();
}
}
// Get current schema version from database
getSchemaVersion() {
try {
const result = this.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
return result ? result.version : 0;
} catch (error) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT
)
`);
return 0;
}
}
// Record migration completion
recordMigration(version, description) {
this.db.prepare('INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)').run(version, description);
}
// Check if dogs table has old sire/dam columns
hasOldSchema() {
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
return columns.some(col => col.name === 'sire' || col.name === 'dam');
}
// Check if litter_id column exists
hasLitterIdColumn() {
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
return columns.some(col => col.name === 'litter_id');
}
// Check if health_records has the old restrictive CHECK constraint on record_type
healthRecordsHasOldConstraint() {
try {
const row = this.db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='health_records'"
).get();
if (!row) return false;
return row.sql.includes("'test', 'vaccination', 'exam', 'treatment', 'certification'");
} catch (_) {
return false;
}
}
// Migration 1: Remove sire/dam columns, use parents table
migration001_removeOldParentColumns() {
console.log('[Migration 001] Checking for old sire/dam columns...');
if (!this.hasOldSchema()) {
console.log('[Migration 001] Schema is already correct, skipping');
return;
}
console.log('[Migration 001] Found old schema with sire/dam columns');
console.log('[Migration 001] Migrating to parents table...');
this.db.exec('BEGIN TRANSACTION');
try {
this.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)
);
CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id);
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
`);
this.db.exec('DROP TABLE IF EXISTS dogs_migration_backup');
this.db.exec('CREATE TABLE dogs_migration_backup AS SELECT * FROM dogs');
const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs_migration_backup').get();
console.log(`[Migration 001] Backed up ${backupCount.count} dogs`);
const columns = this.db.prepare("PRAGMA table_info(dogs_migration_backup)").all();
const hasSire = columns.some(col => col.name === 'sire');
const hasDam = columns.some(col => col.name === 'dam');
const hasLitterId = columns.some(col => col.name === 'litter_id');
if (hasSire) {
const sireResult = this.db.prepare(`
INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type)
SELECT id, sire, 'sire' FROM dogs_migration_backup WHERE sire IS NOT NULL
`).run();
console.log(`[Migration 001] Migrated ${sireResult.changes} sire relationships`);
}
if (hasDam) {
const damResult = this.db.prepare(`
INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type)
SELECT id, dam, 'dam' FROM dogs_migration_backup WHERE dam IS NOT NULL
`).run();
console.log(`[Migration 001] Migrated ${damResult.changes} dam relationships`);
}
this.db.exec('DROP TABLE dogs');
console.log('[Migration 001] Dropped old dogs table');
this.db.exec(`
CREATE TABLE dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
registration_number TEXT,
microchip TEXT,
sex TEXT CHECK(sex IN ('male', 'female')),
birth_date DATE,
breed TEXT,
color TEXT,
weight REAL,
height REAL,
notes TEXT,
litter_id INTEGER,
photo_urls TEXT,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL
)
`);
console.log('[Migration 001] Created new dogs table');
const columnList = ['id', 'name', 'registration_number', 'microchip', 'sex', 'birth_date', 'breed', 'color', 'weight', 'height', 'notes', 'photo_urls', 'is_active', 'created_at', 'updated_at'];
if (hasLitterId) {
columnList.splice(11, 0, 'litter_id');
}
const columnsStr = columnList.join(', ');
this.db.exec(`INSERT INTO dogs (${columnsStr}) SELECT ${columnsStr} FROM dogs_migration_backup`);
const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs').get();
console.log(`[Migration 001] Restored ${restoredCount.count} dogs`);
this.db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
ON dogs(microchip) WHERE microchip IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name);
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
`);
this.db.exec('DROP TABLE dogs_migration_backup');
this.db.exec('COMMIT');
console.log('[Migration 001] ✓ Migration complete!');
} catch (error) {
this.db.exec('ROLLBACK');
console.error('[Migration 001] ✗ Migration failed:', error.message);
throw error;
}
}
// Migration 2: Add litter_id column if missing
migration002_addLitterIdColumn() {
console.log('[Migration 002] Checking for litter_id column...');
if (this.hasLitterIdColumn()) {
console.log('[Migration 002] litter_id column already exists, skipping');
return;
}
console.log('[Migration 002] Adding litter_id column...');
try {
this.db.exec(`
ALTER TABLE dogs ADD COLUMN litter_id INTEGER
REFERENCES litters(id) ON DELETE SET NULL
`);
console.log('[Migration 002] ✓ litter_id column added');
} catch (error) {
console.error('[Migration 002] ✗ Failed to add litter_id:', error.message);
throw error;
}
}
// Migration 3: Remove old restrictive CHECK constraint on health_records.record_type
// Uses dynamic column detection so it works regardless of which columns exist in the old table
migration003_removeHealthRecordTypeConstraint() {
console.log('[Migration 003] Checking health_records.record_type constraint...');
if (!this.healthRecordsHasOldConstraint()) {
console.log('[Migration 003] No old constraint found, skipping');
return;
}
console.log('[Migration 003] Rebuilding health_records table to remove old CHECK constraint...');
this.db.exec('BEGIN TRANSACTION');
try {
// Backup existing records
this.db.exec('DROP TABLE IF EXISTS health_records_migration_backup');
this.db.exec('CREATE TABLE health_records_migration_backup AS SELECT * FROM health_records');
const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records_migration_backup').get();
console.log(`[Migration 003] Backed up ${backupCount.count} health records`);
// Dynamically get the columns that actually exist in the backup
// This handles old DBs that may be missing newer columns like updated_at
const existingCols = this.db.prepare('PRAGMA table_info(health_records_migration_backup)').all();
const existingColNames = existingCols.map(c => c.name);
console.log(`[Migration 003] Existing columns: ${existingColNames.join(', ')}`);
// Drop old constrained table
this.db.exec('DROP TABLE health_records');
// Recreate WITHOUT the old CHECK constraint
this.db.exec(`
CREATE TABLE health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL,
test_type TEXT,
test_name TEXT,
test_date TEXT NOT NULL,
ofa_result TEXT,
ofa_number TEXT,
performed_by TEXT,
expires_at TEXT,
document_url TEXT,
result TEXT,
vet_name TEXT,
next_due TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
)
`);
// Only restore columns that existed in the backup — new columns get their DEFAULT values
const newCols = ['id', 'dog_id', 'record_type', 'test_type', 'test_name', 'test_date',
'ofa_result', 'ofa_number', 'performed_by', 'expires_at',
'document_url', 'result', 'vet_name', 'next_due', 'notes',
'created_at', 'updated_at'];
const colsToRestore = newCols.filter(c => existingColNames.includes(c));
const colList = colsToRestore.join(', ');
console.log(`[Migration 003] Restoring columns: ${colList}`);
this.db.exec(`
INSERT INTO health_records (${colList})
SELECT ${colList} FROM health_records_migration_backup
`);
const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records').get();
console.log(`[Migration 003] Restored ${restoredCount.count} health records`);
this.db.exec('DROP TABLE health_records_migration_backup');
this.db.exec('COMMIT');
console.log('[Migration 003] ✓ health_records constraint removed successfully!');
} catch (error) {
this.db.exec('ROLLBACK');
console.error('[Migration 003] ✗ Migration failed:', error.message);
throw error;
}
}
// Validate final schema
validateSchema() {
console.log('[Validation] Checking database schema...');
const checks = [
{
name: 'Dogs table exists',
test: () => {
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dogs'").all();
return tables.length > 0;
}
},
{
name: 'Dogs table has no sire/dam columns',
test: () => {
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
return !columns.some(col => col.name === 'sire' || col.name === 'dam');
}
},
{
name: 'Parents table exists',
test: () => {
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='parents'").all();
return tables.length > 0;
}
},
{
name: 'Litter_id column exists',
test: () => this.hasLitterIdColumn()
},
{
name: 'Litters table exists',
test: () => {
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='litters'").all();
return tables.length > 0;
}
},
{
name: 'health_records has no old record_type CHECK constraint',
test: () => !this.healthRecordsHasOldConstraint()
}
];
let allPassed = true;
checks.forEach(check => {
const passed = check.test();
const status = passed ? '✓' : '✗';
console.log(`[Validation] ${status} ${check.name}`);
if (!passed) allPassed = false;
});
if (allPassed) {
console.log('[Validation] ✓ All schema checks passed!');
} else {
console.warn('[Validation] ⚠ Some schema checks failed');
}
return allPassed;
}
// Run all migrations
runMigrations() {
console.log('\n' + '='.repeat(60));
console.log('BREEDR Database Migration System');
console.log('='.repeat(60));
console.log(`Database: ${this.dbPath}\n`);
this.connect();
try {
const currentVersion = this.getSchemaVersion();
console.log(`Current schema version: ${currentVersion}\n`);
if (currentVersion < 1) {
this.migration001_removeOldParentColumns();
this.recordMigration(1, 'Migrate sire/dam columns to parents table');
}
if (currentVersion < 2) {
this.migration002_addLitterIdColumn();
this.recordMigration(2, 'Add litter_id column to dogs table');
}
if (currentVersion < 3) {
this.migration003_removeHealthRecordTypeConstraint();
this.recordMigration(3, 'Remove old record_type CHECK constraint from health_records');
}
console.log('');
const isValid = this.validateSchema();
const finalVersion = this.getSchemaVersion();
console.log('\n' + '='.repeat(60));
console.log(`Schema version: ${currentVersion}${finalVersion}`);
console.log('Migration system complete!');
console.log('='.repeat(60) + '\n');
return isValid;
} catch (error) {
console.error('\n✗ Migration system failed:', error.message);
console.error(error.stack);
throw error;
} finally {
this.close();
}
}
}
function runMigrations(dbPath) {
const runner = new MigrationRunner(dbPath);
return runner.runMigrations();
}
module.exports = { MigrationRunner, runMigrations };
if (require.main === module) {
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
runMigrations(dbPath);
}

View File

@@ -1,62 +1,72 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const { runMigrations } = require('./db/migrations');
const { initDatabase } = require('./db/init');
const { logStartupBanner } = require('./utils/startupLog');
const app = express();
const app = express();
const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
// Ensure required directories exist
const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads');
const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
// 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 });
[DATA_DIR, UPLOAD_PATH, STATIC_PATH].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Run migrations BEFORE initializing the DB connection used by routes
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
console.log('Running database migrations...');
try {
runMigrations(DB_PATH);
} catch (err) {
console.error('Migration failed — aborting startup:', err.message);
process.exit(1);
}
// Initialize database
initDatabase(DB_PATH);
// Init DB (path is managed internally by db/init.js)
console.log('Initializing database...');
initDatabase();
const dbStatus = '✓ Connected';
console.log('✓ Database ready!\n');
// Middleware
app.use(helmet({
contentSecurityPolicy: false, // Allow inline scripts for React
}));
// ── Middleware ─────────────────────────────────────────────────────────
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Static file serving for uploads
// ── Static file serving ──────────────────────────────────────────────
app.use('/uploads', express.static(UPLOAD_PATH));
app.use('/static', express.static(STATIC_PATH));
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'));
// ── 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/genetics', require('./routes/genetics'));
app.use('/api/pedigree', require('./routes/pedigree'));
app.use('/api/breeding', require('./routes/breeding'));
app.use('/api/settings', require('./routes/settings'));
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve React frontend in production
// ── Production SPA fallback ────────────────────────────────────────────────
if (process.env.NODE_ENV === 'production') {
const clientBuildPath = path.join(__dirname, '../client/dist');
app.use(express.static(clientBuildPath));
app.get('*', (req, res) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
const clientBuild = path.join(__dirname, '../client/dist');
app.use(express.static(clientBuild));
app.get(/^(?!\/(?:api|static|uploads)\/).*$/, (_req, res) => {
res.sendFile(path.join(clientBuild, 'index.html'));
});
}
// Error handling middleware
app.use((err, req, res, next) => {
// ── Global error handler ──────────────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal server error',
@@ -64,16 +74,16 @@ app.use((err, req, res, next) => {
});
});
// 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(`Access: http://localhost:${PORT}`);
console.log(`================================\n`);
logStartupBanner({
appName: 'BREEDR',
port: PORT,
environment: process.env.NODE_ENV || 'development',
dataDir: DATA_DIR,
uploadPath: UPLOAD_PATH,
staticPath: STATIC_PATH,
dbStatus: dbStatus
});
});
module.exports = app;

View File

@@ -11,35 +11,130 @@ router.get('/heat-cycles/dog/:dogId', (req, res) => {
WHERE dog_id = ?
ORDER BY start_date DESC
`).all(req.params.dogId);
res.json(cycles);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET all active heat cycles
// GET all active heat cycles (with dog info)
router.get('/heat-cycles/active', (req, res) => {
try {
const db = getDatabase();
const cycles = db.prepare(`
SELECT hc.*, d.name as dog_name, d.registration_number
SELECT hc.*, d.name as dog_name, d.registration_number, d.breed, d.birth_date
FROM heat_cycles hc
JOIN dogs d ON hc.dog_id = d.id
WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days')
ORDER BY hc.start_date DESC
`).all();
res.json(cycles);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET all heat cycles (all dogs, for calendar population)
router.get('/heat-cycles', (req, res) => {
try {
const db = getDatabase();
const { year, month } = req.query;
let query = `
SELECT hc.*, d.name as dog_name, d.registration_number, d.breed
FROM heat_cycles hc
JOIN dogs d ON hc.dog_id = d.id
`;
const params = [];
if (year && month) {
query += ` WHERE strftime('%Y', hc.start_date) = ? AND strftime('%m', hc.start_date) = ?`;
params.push(year, month.toString().padStart(2, '0'));
}
query += ' ORDER BY hc.start_date DESC';
const cycles = db.prepare(query).all(...params);
res.json(cycles);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET breeding date suggestions for a heat cycle
// Returns optimal breeding window based on start_date (days 9-15 of cycle)
router.get('/heat-cycles/:id/suggestions', (req, res) => {
try {
const db = getDatabase();
const cycle = db.prepare(`
SELECT hc.*, d.name as dog_name
FROM heat_cycles hc
JOIN dogs d ON hc.dog_id = d.id
WHERE hc.id = ?
`).get(req.params.id);
if (!cycle) return res.status(404).json({ error: 'Heat cycle not found' });
const start = new Date(cycle.start_date);
const addDays = (d, n) => {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r.toISOString().split('T')[0];
};
// Standard canine heat cycle windows
res.json({
cycle_id: cycle.id,
dog_name: cycle.dog_name,
start_date: cycle.start_date,
windows: [
{
label: 'Proestrus',
description: 'Bleeding begins, not yet receptive',
start: addDays(start, 0),
end: addDays(start, 8),
color: 'pink',
type: 'proestrus'
},
{
label: 'Optimal Breeding Window',
description: 'Estrus — highest fertility, best time to breed',
start: addDays(start, 9),
end: addDays(start, 15),
color: 'green',
type: 'optimal'
},
{
label: 'Late Estrus',
description: 'Fertility declining but breeding still possible',
start: addDays(start, 16),
end: addDays(start, 21),
color: 'yellow',
type: 'late'
},
{
label: 'Diestrus',
description: 'Cycle ending, not receptive',
start: addDays(start, 22),
end: addDays(start, 28),
color: 'gray',
type: 'diestrus'
}
],
// If a breeding_date was logged, compute whelping estimate
whelping: cycle.breeding_date ? {
breeding_date: cycle.breeding_date,
earliest: addDays(new Date(cycle.breeding_date), 58),
expected: addDays(new Date(cycle.breeding_date), 63),
latest: addDays(new Date(cycle.breeding_date), 68)
} : null
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST create heat cycle
router.post('/heat-cycles', (req, res) => {
try {
const { dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body;
const { dog_id, start_date, end_date, breeding_date, breeding_successful, notes } = req.body;
if (!dog_id || !start_date) {
return res.status(400).json({ error: 'Dog ID and start date are required' });
@@ -54,12 +149,11 @@ router.post('/heat-cycles', (req, res) => {
}
const result = db.prepare(`
INSERT INTO heat_cycles (dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful || 0, notes);
INSERT INTO heat_cycles (dog_id, start_date, end_date, breeding_date, breeding_successful, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(dog_id, start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null);
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(cycle);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -69,16 +163,13 @@ router.post('/heat-cycles', (req, res) => {
// PUT update heat cycle
router.put('/heat-cycles/:id', (req, res) => {
try {
const { start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body;
const { start_date, end_date, breeding_date, breeding_successful, notes } = req.body;
const db = getDatabase();
db.prepare(`
UPDATE heat_cycles
SET start_date = ?, end_date = ?, progesterone_peak_date = ?,
breeding_date = ?, breeding_successful = ?, notes = ?
SET start_date = ?, end_date = ?, breeding_date = ?, breeding_successful = ?, notes = ?
WHERE id = ?
`).run(start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes, req.params.id);
`).run(start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null, req.params.id);
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(req.params.id);
res.json(cycle);
} catch (error) {
@@ -97,32 +188,20 @@ router.delete('/heat-cycles/:id', (req, res) => {
}
});
// GET calculate expected whelping date
// GET whelping calculator (standalone)
router.get('/whelping-calculator', (req, res) => {
try {
const { breeding_date } = req.query;
if (!breeding_date) {
return res.status(400).json({ error: 'Breeding date is required' });
}
const breedDate = new Date(breeding_date);
// Average gestation: 63 days, range 58-68 days
const expectedDate = new Date(breedDate);
expectedDate.setDate(expectedDate.getDate() + 63);
const earliestDate = new Date(breedDate);
earliestDate.setDate(earliestDate.getDate() + 58);
const latestDate = new Date(breedDate);
latestDate.setDate(latestDate.getDate() + 68);
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r.toISOString().split('T')[0]; };
res.json({
breeding_date: breeding_date,
expected_whelping_date: expectedDate.toISOString().split('T')[0],
earliest_date: earliestDate.toISOString().split('T')[0],
latest_date: latestDate.toISOString().split('T')[0],
breeding_date,
expected_whelping_date: addDays(breedDate, 63),
earliest_date: addDays(breedDate, 58),
latest_date: addDays(breedDate, 68),
gestation_days: 63
});
} catch (error) {

View File

@@ -1,11 +1,10 @@
const express = require('express');
const router = express.Router();
const router = express.Router();
const { getDatabase } = require('../db/init');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
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,190 +29,334 @@ const upload = multer({
}
});
// GET all dogs
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
// ── 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, is_external, created_at, updated_at
`;
// ── 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 (paginated)
// Default: kennel dogs only (is_external = 0)
// ?include_external=1 : all active dogs (kennel + external)
// ?external_only=1 : external dogs only
// ?page=1&limit=50 : pagination
// ?search=term : filter by name or registration_number
// ?sex=male|female : filter by sex
// Response: { data, total, page, limit, stats: { total, males, females } }
// ─────────────────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
try {
const db = getDatabase();
const dogs = db.prepare('SELECT * FROM dogs WHERE is_active = 1 ORDER BY name').all();
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
const search = (req.query.search || '').trim();
const sex = req.query.sex === 'male' || req.query.sex === 'female' ? req.query.sex : '';
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = (page - 1) * limit;
// Parse photo_urls JSON
dogs.forEach(dog => {
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
});
let baseWhere;
if (externalOnly) {
baseWhere = 'is_active = 1 AND is_external = 1';
} else if (includeExternal) {
baseWhere = 'is_active = 1';
} else {
baseWhere = 'is_active = 1 AND is_external = 0';
}
res.json(dogs);
const filters = [];
const params = [];
if (search) {
filters.push('(name LIKE ? OR registration_number LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (sex) {
filters.push('sex = ?');
params.push(sex);
}
const whereClause = 'WHERE ' + [baseWhere, ...filters].join(' AND ');
const total = db.prepare(`SELECT COUNT(*) as count FROM dogs ${whereClause}`).get(...params).count;
const statsWhere = externalOnly
? 'WHERE is_active = 1 AND is_external = 1'
: includeExternal
? 'WHERE is_active = 1'
: 'WHERE is_active = 1 AND is_external = 0';
const stats = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN sex = 'male' THEN 1 ELSE 0 END) as males,
SUM(CASE WHEN sex = 'female' THEN 1 ELSE 0 END) as females
FROM dogs ${statsWhere}
`).get();
const dogs = db.prepare(`
SELECT ${DOG_COLS}
FROM dogs
${whereClause}
ORDER BY name
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({ data: attachParents(db, dogs), total, page, limit, stats });
} catch (error) {
console.error('Error fetching dogs:', error);
res.status(500).json({ error: error.message });
}
});
// GET single dog by ID
// ── 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(`
SELECT ${DOG_COLS}
FROM dogs
WHERE is_active = 1
ORDER BY name
`).all();
res.json(attachParents(db, dogs));
} catch (error) {
console.error('Error fetching all dogs:', error);
res.status(500).json({ error: error.message });
}
});
// ── 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();
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(req.params.id);
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' });
}
if (!dog) return res.status(404).json({ error: 'Dog not found' });
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
// Get parents
const parents = db.prepare(`
SELECT p.parent_type, d.*
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 = ?
`).all(req.params.id);
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
// Get offspring
dog.offspring = db.prepare(`
SELECT d.* FROM dogs d
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
`).all(req.params.id);
res.json(dog);
} catch (error) {
console.error('Error fetching dog:', error);
res.status(500).json({ error: error.message });
}
});
// 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 } = req.body;
const { name, registration_number, breed, sex, birth_date, color,
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' });
}
const db = getDatabase();
const result = db.prepare(`
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, photo_urls)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, registration_number, breed, sex, birth_date, color, microchip, notes, '[]');
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
microchip, notes, litter_id, photo_urls, is_champion, is_external)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
'[]',
is_champion ? 1 : 0,
is_external ? 1 : 0
);
const dogId = result.lastInsertRowid;
// Add parent relationships
if (sire_id) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, "sire")').run(dogId, sire_id);
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');
}
if (dam_id) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, "dam")').run(dogId, dam_id);
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
}
const dog = db.prepare('SELECT * 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: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`);
res.status(201).json(dog);
} catch (error) {
console.error('Error creating dog:', error);
res.status(500).json({ error: error.message });
}
});
// 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 } = req.body;
const { name, registration_number, breed, sex, birth_date, color,
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 = ?
birth_date = ?, color = ?, microchip = ?, notes = ?,
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
WHERE id = ?
`).run(name, registration_number, breed, sex, birth_date, color, microchip, notes, req.params.id);
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
is_champion ? 1 : 0,
is_external ? 1 : 0,
req.params.id
);
// Update parent relationships
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
if (sire_id) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, "sire")').run(req.params.id, sire_id);
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
}
if (dam_id) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, "dam")').run(req.params.id, dam_id);
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
}
const dog = db.prepare('SELECT * 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: ${dog.name} (ID: ${req.params.id})`);
res.json(dog);
} catch (error) {
console.error('Error updating dog:', error);
res.status(500).json({ error: error.message });
}
});
// DELETE dog (soft delete)
// ── DELETE dog (hard delete with cascade) ───────────────────────────────
router.delete('/:id', (req, res) => {
try {
const db = getDatabase();
db.prepare('UPDATE dogs SET is_active = 0 WHERE id = ?').run(req.params.id);
res.json({ message: 'Dog deleted successfully' });
const existing = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Dog not found' });
const id = req.params.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` });
} catch (error) {
console.error('Error deleting dog:', error);
res.status(500).json({ error: error.message });
}
});
// 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);
res.status(500).json({ error: error.message });
}
});
// 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' });
if (!dog) {
return res.status(404).json({ error: 'Dog not found' });
}
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
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);
res.status(500).json({ error: error.message });
}
});

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

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

View File

@@ -2,6 +2,46 @@ const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// OFA tests that count toward GRCA eligibility
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
const GRCA_CORE = {
hip: ['hip_ofa', 'hip_pennhip'],
elbow: ['elbow_ofa'],
heart: ['heart_ofa', 'heart_echo'],
eye: ['eye_caer'],
};
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa', 'dna_panel'];
// Helper: compute clearance summary for a dog
function getClearanceSummary(db, dogId) {
const records = db.prepare(`
SELECT test_type, ofa_result, ofa_number, expires_at, test_date
FROM health_records
WHERE dog_id = ? AND test_type IS NOT NULL
ORDER BY test_date DESC
`).all(dogId);
const today = new Date();
const in90 = new Date(); in90.setDate(today.getDate() + 90);
const summary = {};
for (const [group, types] of Object.entries(GRCA_CORE)) {
const match = records.find(r => types.includes(r.test_type));
if (!match) {
summary[group] = { status: 'missing', record: null };
} else {
let status = 'pass';
if (match.expires_at) {
const exp = new Date(match.expires_at);
if (exp < today) status = 'expired';
else if (exp <= in90) status = 'expiring_soon';
}
summary[group] = { status, record: match };
}
}
return summary;
}
// GET all health records for a dog
router.get('/dog/:dogId', (req, res) => {
try {
@@ -11,46 +51,91 @@ router.get('/dog/:dogId', (req, res) => {
WHERE dog_id = ?
ORDER BY test_date DESC
`).all(req.params.dogId);
res.json(records);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET single health record
router.get('/:id', (req, res) => {
// GET clearance summary (Hip / Elbow / Heart / Eyes) for a dog
router.get('/dog/:dogId/clearance-summary', (req, res) => {
try {
const db = getDatabase();
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
const dog = db.prepare('SELECT id, birth_date, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
if (!record) {
return res.status(404).json({ error: 'Health record not found' });
const summary = getClearanceSummary(db, dog.id);
// Age check: must be >= 24 months for hip/elbow
let ageEligible = false;
if (dog.birth_date) {
const months = (new Date() - new Date(dog.birth_date)) / (1000 * 60 * 60 * 24 * 30.44);
ageEligible = months >= 24;
}
res.json(record);
const allPass = Object.values(summary).every(s => ['pass', 'expiring_soon'].includes(s.status));
const grca_eligible = allPass && ageEligible;
res.json({ summary, grca_eligible, age_eligible: ageEligible, chic_number: dog.chic_number });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET CHIC eligibility check
router.get('/dog/:dogId/chic-eligible', (req, res) => {
try {
const db = getDatabase();
const dog = db.prepare('SELECT id, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
const summary = getClearanceSummary(db, dog.id);
const missing = Object.entries(summary)
.filter(([, v]) => v.status === 'missing')
.map(([k]) => k);
res.json({
chic_eligible: missing.length === 0,
chic_number: dog.chic_number || null,
missing_tests: missing,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST create health record
router.post('/', (req, res) => {
try {
const { dog_id, record_type, test_name, test_date, result, document_url, notes } = req.body;
const {
dog_id, record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes
} = req.body;
if (!dog_id || !record_type || !test_date) {
return res.status(400).json({ error: 'Dog ID, record type, and test date are required' });
return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' });
}
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
return res.status(400).json({ error: 'Invalid test_type' });
}
const db = getDatabase();
const dbResult = db.prepare(`
INSERT INTO health_records (dog_id, record_type, test_name, test_date, result, document_url, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(dog_id, record_type, test_name, test_date, result, document_url, notes);
INSERT INTO health_records
(dog_id, record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
dog_id, record_type, test_type || null, test_name || null, test_date,
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
document_url || null, result || null, vet_name || null, next_due || null, notes || null
);
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid);
res.status(201).json(record);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -60,14 +145,30 @@ router.post('/', (req, res) => {
// PUT update health record
router.put('/:id', (req, res) => {
try {
const { record_type, test_name, test_date, result, document_url, notes } = req.body;
const {
record_type, test_type, test_name, test_date,
ofa_result, ofa_number, performed_by, expires_at,
document_url, result, vet_name, next_due, notes
} = req.body;
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
return res.status(400).json({ error: 'Invalid test_type' });
}
const db = getDatabase();
db.prepare(`
UPDATE health_records
SET record_type = ?, test_name = ?, test_date = ?, result = ?, document_url = ?, notes = ?
SET record_type = ?, test_type = ?, test_name = ?, test_date = ?,
ofa_result = ?, ofa_number = ?, performed_by = ?, expires_at = ?,
document_url = ?, result = ?, vet_name = ?, next_due = ?, notes = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(record_type, test_name, test_date, result, document_url, notes, req.params.id);
`).run(
record_type, test_type || null, test_name || null, test_date,
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
document_url || null, result || null, vet_name || null, next_due || null, notes || null,
req.params.id
);
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
res.json(record);
@@ -87,4 +188,70 @@ router.delete('/:id', (req, res) => {
}
});
// GET cancer history for a dog
router.get('/dog/:dogId/cancer-history', (req, res) => {
try {
const db = getDatabase();
const records = db.prepare(`
SELECT * FROM cancer_history
WHERE dog_id = ?
ORDER BY age_at_diagnosis ASC, created_at DESC
`).all(req.params.dogId);
res.json(records);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST create cancer history record
router.post('/cancer-history', (req, res) => {
try {
const {
dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes
} = req.body;
if (!dog_id || !cancer_type) {
return res.status(400).json({ error: 'dog_id and cancer_type are required' });
}
const db = getDatabase();
// Update dog's age_at_death and cause_of_death if provided
if (age_at_death || cause_of_death) {
db.prepare(`
UPDATE dogs SET
age_at_death = COALESCE(?, age_at_death),
cause_of_death = COALESCE(?, cause_of_death)
WHERE id = ?
`).run(age_at_death || null, cause_of_death || null, dog_id);
}
const dbResult = db.prepare(`
INSERT INTO cancer_history
(dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
dog_id, cancer_type, age_at_diagnosis || null,
age_at_death || null, cause_of_death || null, notes || null
);
const record = db.prepare('SELECT * FROM cancer_history WHERE id = ?').get(dbResult.lastInsertRowid);
res.status(201).json(record);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET single health record (wildcard should go last to prevent overlap)
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
if (!record) return res.status(404).json({ error: 'Health record not found' });
res.json(record);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -2,10 +2,18 @@ const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// GET all litters
// GET all litters (paginated)
// ?page=1&limit=50
// Response: { data, total, page, limit }
router.get('/', (req, res) => {
try {
const db = getDatabase();
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = (page - 1) * limit;
const total = db.prepare('SELECT COUNT(*) as count FROM litters').get().count;
const litters = db.prepare(`
SELECT l.*,
s.name as sire_name, s.registration_number as sire_reg,
@@ -14,36 +22,43 @@ router.get('/', (req, res) => {
JOIN dogs s ON l.sire_id = s.id
JOIN dogs d ON l.dam_id = d.id
ORDER BY l.breeding_date DESC
`).all();
LIMIT ? OFFSET ?
`).all(limit, offset);
// Get puppies for each litter
litters.forEach(litter => {
litter.puppies = db.prepare(`
SELECT d.* FROM dogs d
JOIN parents ps ON d.id = ps.dog_id
JOIN parents pd ON d.id = pd.dog_id
WHERE ps.parent_id = ? AND pd.parent_id = ?
`).all(litter.sire_id, litter.dam_id);
if (litters.length > 0) {
const litterIds = litters.map(l => l.id);
const placeholders = litterIds.map(() => '?').join(',');
const allPuppies = db.prepare(`
SELECT * FROM dogs WHERE litter_id IN (${placeholders}) AND is_active = 1
`).all(...litterIds);
litter.puppies.forEach(puppy => {
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
const puppiesByLitter = {};
allPuppies.forEach(p => {
p.photo_urls = p.photo_urls ? JSON.parse(p.photo_urls) : [];
if (!puppiesByLitter[p.litter_id]) puppiesByLitter[p.litter_id] = [];
puppiesByLitter[p.litter_id].push(p);
});
});
res.json(litters);
litters.forEach(l => {
l.puppies = puppiesByLitter[l.id] || [];
l.actual_puppy_count = l.puppies.length;
});
}
res.json({ data: litters, total, page, limit });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET single litter
// GET single litter with puppies
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
const litter = db.prepare(`
SELECT l.*,
s.*, s.name as sire_name,
d.*, d.name as dam_name
s.name as sire_name, s.registration_number as sire_reg, s.breed as sire_breed,
d.name as dam_name, d.registration_number as dam_reg, d.breed as dam_breed
FROM litters l
JOIN dogs s ON l.sire_id = s.id
JOIN dogs d ON l.dam_id = d.id
@@ -55,16 +70,15 @@ router.get('/:id', (req, res) => {
}
litter.puppies = db.prepare(`
SELECT d.* FROM dogs d
JOIN parents ps ON d.id = ps.dog_id
JOIN parents pd ON d.id = pd.dog_id
WHERE ps.parent_id = ? AND pd.parent_id = ?
`).all(litter.sire_id, litter.dam_id);
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
`).all(litter.id);
litter.puppies.forEach(puppy => {
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
});
litter.actual_puppy_count = litter.puppies.length;
res.json(litter);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -74,7 +88,7 @@ router.get('/:id', (req, res) => {
// POST create new litter
router.post('/', (req, res) => {
try {
const { sire_id, dam_id, breeding_date, whelping_date, notes } = req.body;
const { sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes } = req.body;
if (!sire_id || !dam_id || !breeding_date) {
return res.status(400).json({ error: 'Sire, dam, and breeding date are required' });
@@ -82,7 +96,6 @@ router.post('/', (req, res) => {
const db = getDatabase();
// Verify sire is male and dam is female
const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id);
const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id);
@@ -94,12 +107,11 @@ router.post('/', (req, res) => {
}
const result = db.prepare(`
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, notes)
VALUES (?, ?, ?, ?, ?)
`).run(sire_id, dam_id, breeding_date, whelping_date, notes);
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(sire_id, dam_id, breeding_date, whelping_date || null, puppy_count || 0, notes || null);
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(litter);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -110,13 +122,12 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
try {
const { breeding_date, whelping_date, puppy_count, notes } = req.body;
const db = getDatabase();
db.prepare(`
UPDATE litters
SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ?
WHERE id = ?
`).run(breeding_date, whelping_date, puppy_count, notes, req.params.id);
`).run(breeding_date, whelping_date || null, puppy_count || 0, notes || null, req.params.id);
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id);
res.json(litter);
@@ -125,10 +136,112 @@ router.put('/:id', (req, res) => {
}
});
// POST link puppy to litter
router.post('/:id/puppies/:puppyId', (req, res) => {
try {
const { id: litterId, puppyId } = req.params;
const db = getDatabase();
const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId);
if (!litter) return res.status(404).json({ error: 'Litter not found' });
const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId);
if (!puppy) return res.status(404).json({ error: 'Puppy not found' });
db.prepare('UPDATE dogs SET litter_id = ? WHERE id = ?').run(litterId, puppyId);
const existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId);
const hasSire = existingParents.some(p => p.parent_type === 'sire');
const hasDam = existingParents.some(p => p.parent_type === 'dam');
if (!hasSire) {
db.prepare('INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, \'sire\')').run(puppyId, litter.sire_id);
}
if (!hasDam) {
db.prepare('INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, \'dam\')').run(puppyId, litter.dam_id);
}
res.json({ message: 'Puppy linked to litter successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE remove puppy from litter
router.delete('/:id/puppies/:puppyId', (req, res) => {
try {
const { puppyId } = req.params;
const db = getDatabase();
db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId);
res.json({ message: 'Puppy removed from litter' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ─── Puppy Weight / Health Log ───────────────────────────────────────────────
// GET weight/health logs for a puppy
router.get('/:litterId/puppies/:puppyId/logs', (req, res) => {
try {
const db = getDatabase();
// Use health_records table with note field to store weight logs
const logs = db.prepare(`
SELECT * FROM health_records
WHERE dog_id = ? AND record_type = 'weight_log'
ORDER BY record_date ASC
`).all(req.params.puppyId);
res.json(logs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST add weight/health log entry for a puppy
router.post('/:litterId/puppies/:puppyId/logs', (req, res) => {
try {
const { puppyId } = req.params;
const { record_date, weight_oz, weight_lbs, notes, record_type } = req.body;
if (!record_date) return res.status(400).json({ error: 'record_date is required' });
const db = getDatabase();
// Store weight as notes JSON in health_records
const description = JSON.stringify({
weight_oz: weight_oz || null,
weight_lbs: weight_lbs || null,
notes: notes || ''
});
const result = db.prepare(`
INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name)
VALUES (?, ?, ?, ?, ?)
`).run(puppyId, record_type || 'weight_log', record_date, description, null);
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(log);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE weight/health log entry
router.delete('/:litterId/puppies/:puppyId/logs/:logId', (req, res) => {
try {
const db = getDatabase();
db.prepare('DELETE FROM health_records WHERE id = ? AND dog_id = ?').run(req.params.logId, req.params.puppyId);
res.json({ message: 'Log entry deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE litter
router.delete('/:id', (req, res) => {
try {
const db = getDatabase();
db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id);
db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id);
res.json({ message: 'Litter deleted successfully' });
} catch (error) {

View File

@@ -2,73 +2,286 @@ const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// Helper function to calculate inbreeding coefficient
function calculateCOI(sireId, damId, generations = 5) {
const db = getDatabase();
const MAX_CACHE_SIZE = 1000;
const ancestorCache = new Map();
const coiCache = new Map();
// Get all ancestors for both parents
function getAncestors(dogId, currentGen = 0, maxGen = generations) {
if (currentGen >= maxGen) return [];
const parents = db.prepare(`
SELECT p.parent_type, p.parent_id, d.name
FROM parents p
JOIN dogs d ON p.parent_id = d.id
WHERE p.dog_id = ?
`).all(dogId);
const ancestors = parents.map(p => ({
id: p.parent_id,
name: p.name,
type: p.parent_type,
generation: currentGen + 1
}));
parents.forEach(p => {
ancestors.push(...getAncestors(p.parent_id, currentGen + 1, maxGen));
});
return ancestors;
function getFromCache(cache, key, computeFn) {
if (cache.has(key)) {
const val = cache.get(key);
cache.delete(key);
cache.set(key, val);
return val;
}
const sireAncestors = getAncestors(sireId);
const damAncestors = getAncestors(damId);
// Find common ancestors
const commonAncestors = [];
sireAncestors.forEach(sireAnc => {
damAncestors.forEach(damAnc => {
if (sireAnc.id === damAnc.id) {
commonAncestors.push({
id: sireAnc.id,
name: sireAnc.name,
sireGen: sireAnc.generation,
damGen: damAnc.generation
});
}
});
});
// Calculate COI using path coefficient method
let coi = 0;
const processed = new Set();
commonAncestors.forEach(anc => {
const key = `${anc.id}-${anc.sireGen}-${anc.damGen}`;
if (!processed.has(key)) {
processed.add(key);
const pathLength = anc.sireGen + anc.damGen;
coi += Math.pow(0.5, pathLength);
}
});
return {
coefficient: Math.round(coi * 10000) / 100, // Percentage with 2 decimals
commonAncestors: [...new Map(commonAncestors.map(a => [a.id, a])).values()]
};
const val = computeFn();
if (cache.size >= MAX_CACHE_SIZE) {
cache.delete(cache.keys().next().value);
}
cache.set(key, val);
return val;
}
// GET pedigree tree for a dog
/**
* getAncestorMap(db, dogId, maxGen)
* Returns Map<id, [{ id, name, generation }, ...]>
* INCLUDES dogId itself at generation 0 so direct parent-offspring
* pairings are correctly detected by calculateCOI.
*/
function getAncestorMap(db, dogId, maxGen = 6) {
const cacheKey = `${dogId}-${maxGen}`;
return getFromCache(ancestorCache, cacheKey, () => {
const map = new Map();
function recurse(id, gen) {
if (gen > maxGen) return;
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
if (!dog) return;
if (!map.has(id)) map.set(id, []);
map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
if (map.get(id).length === 1) {
const parents = db.prepare(`
SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
`).all(id);
parents.forEach(p => recurse(p.parent_id, gen + 1));
}
}
recurse(parseInt(dogId), 0);
return map;
});
}
/**
* isDirectRelation(db, sireId, damId)
* Returns { related, relationship } if one dog is a direct ancestor
* of the other within 3 generations.
*/
function isDirectRelation(db, sireId, damId) {
const sid = parseInt(sireId);
const did = parseInt(damId);
const sireMap = getAncestorMap(db, sid, 3);
const damMap = getAncestorMap(db, did, 3);
if (damMap.has(sid)) {
const gen = damMap.get(sid)[0].generation;
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
return { related: true, relationship: `Sire is the ${label} of the selected dam` };
}
if (sireMap.has(did)) {
const gen = sireMap.get(did)[0].generation;
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
return { related: true, relationship: `Dam is the ${label} of the selected sire` };
}
return { related: false, relationship: null };
}
/**
* calculateCOI(db, sireId, damId)
* Wright Path Coefficient method.
* Dogs included at gen 0 in their own maps so parent x offspring
* yields ~25% COI.
*
* Fix: do NOT exclude sid/did from commonIds globally.
* - Exclude `did` from sireMap keys (the dam itself can't be a
* common ancestor of the sire's side for THIS pairing's offspring)
* - Exclude `sid` from damMap keys (same logic for sire)
* This preserves the case where the sire IS a common ancestor in the
* dam's ancestry (parent x offspring) while still avoiding reflexive
* self-loops.
*/
function calculateCOI(db, sireId, damId) {
const cacheKey = `${sireId}-${damId}`;
return getFromCache(coiCache, cacheKey, () => {
const sid = parseInt(sireId);
const did = parseInt(damId);
const sireMap = getAncestorMap(db, sid);
const damMap = getAncestorMap(db, did);
// Common ancestors: in BOTH maps, but:
// - not the dam itself appearing in sireMap (would be a loop)
// - not the sire itself appearing in damMap already handled below
// We collect all IDs present in both, excluding only the direct
// subjects (did from sireMap side, sid excluded already since we
// iterate sireMap keys — but sid IS in sireMap at gen 0, and if
// damMap also has sid, that is the parent×offspring case we WANT).
const commonIds = [...sireMap.keys()].filter(
id => damMap.has(id) && id !== did
);
let coi = 0;
const processedPaths = new Set();
const commonAncestorList = [];
commonIds.forEach(ancId => {
const sireOccs = sireMap.get(ancId);
const damOccs = damMap.get(ancId);
sireOccs.forEach(so => {
damOccs.forEach(do_ => {
const key = `${ancId}-${so.generation}-${do_.generation}`;
if (!processedPaths.has(key)) {
processedPaths.add(key);
coi += Math.pow(0.5, so.generation + do_.generation + 1);
}
});
});
const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
commonAncestorList.push({
id: ancId,
name: sireOccs[0].name,
sireGen: closestSire.generation,
damGen: closestDam.generation
});
});
return {
coefficient: coi,
commonAncestors: commonAncestorList
};
});
}
// =====================================================================
// IMPORTANT: Specific named routes MUST be registered BEFORE
// the /:id wildcard, or Express will match 'relations' and
// 'trial-pairing' as dog IDs and return 404/wrong data.
// =====================================================================
const handleTrialPairing = (req, res) => {
try {
const { sire_id, dam_id } = req.body;
if (!sire_id || !dam_id) {
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
}
const db = getDatabase();
const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id);
const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id);
if (!sire || !dam) {
return res.status(404).json({ error: 'Invalid sire or dam \u2014 check sex values in database' });
}
const relation = isDirectRelation(db, sire_id, dam_id);
const result = calculateCOI(db, sire_id, dam_id);
res.json({
sire: { id: sire.id, name: sire.name },
dam: { id: dam.id, name: dam.name },
coi: result.coefficient,
commonAncestors: result.commonAncestors,
directRelation: relation.related ? relation.relationship : null,
recommendation: result.coefficient < 0.05 ? 'Low risk'
: result.coefficient < 0.10 ? 'Moderate risk'
: 'High risk'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
};
// POST /api/pedigree/trial-pairing
router.post('/trial-pairing', handleTrialPairing);
// POST /api/pedigree/coi
router.post('/coi', handleTrialPairing);
// GET /api/pedigree/:id/coi
router.get('/:id/coi', (req, res) => {
try {
const db = getDatabase();
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(req.params.id);
const sire = parents.find(p => p.parent_type === 'sire');
const dam = parents.find(p => p.parent_type === 'dam');
if (!sire || !dam) {
return res.json({ coi: 0, commonAncestors: [], message: 'Incomplete parent data' });
}
const result = calculateCOI(db, sire.parent_id, dam.parent_id);
res.json({
coi: result.coefficient,
commonAncestors: result.commonAncestors
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/pedigree/relations/:sireId/:damId
router.get('/relations/:sireId/:damId', (req, res) => {
try {
const db = getDatabase();
res.json(isDirectRelation(db, req.params.sireId, req.params.damId));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/pedigree/:id/cancer-lineage
router.get('/:id/cancer-lineage', (req, res) => {
try {
const db = getDatabase();
// Get ancestor map up to 5 generations
const ancestorMap = getAncestorMap(db, req.params.id, 5);
// Collect all unique ancestor IDs
const ancestorIds = Array.from(ancestorMap.keys());
if (ancestorIds.length === 0) {
return res.json({ lineage_cases: [], stats: { total_ancestors: 0, ancestors_with_cancer: 0 } });
}
// Query cancer history for all ancestors
const placeholders = ancestorIds.map(() => '?').join(',');
const cancerRecords = db.prepare(`
SELECT c.*, d.name, d.sex
FROM cancer_history c
JOIN dogs d ON c.dog_id = d.id
WHERE c.dog_id IN (${placeholders})
`).all(...ancestorIds);
// Structure the response
const cases = cancerRecords.map(record => {
// Find the closest generation this ancestor appears in
const occurrences = ancestorMap.get(record.dog_id);
const closestGen = occurrences.reduce((min, occ) => occ.generation < min ? occ.generation : min, 999);
return {
...record,
generation_distance: closestGen
};
});
// Sort by generation distance (closer relatives first)
cases.sort((a, b) => a.generation_distance - b.generation_distance);
// Count unique dogs with cancer (excluding generation 0 if we only want stats on ancestors)
const ancestorCases = cases.filter(c => c.generation_distance > 0);
const uniqueAncestorsWithCancer = new Set(ancestorCases.map(c => c.dog_id)).size;
// Number of ancestors is total unique IDs minus 1 for the dog itself
const numAncestors = ancestorIds.length > 0 && ancestorMap.get(parseInt(req.params.id)) ? ancestorIds.length - 1 : ancestorIds.length;
res.json({
lineage_cases: cases,
stats: {
total_ancestors: numAncestors,
ancestors_with_cancer: uniqueAncestorsWithCancer
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// =====================================================================
// Wildcard routes last
// =====================================================================
// GET /api/pedigree/:id
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
@@ -76,42 +289,29 @@ router.get('/:id', (req, res) => {
function buildTree(dogId, currentGen = 0) {
if (currentGen >= generations) return null;
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
if (!dog) return null;
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const parents = db.prepare(`
SELECT p.parent_type, p.parent_id
FROM parents p
WHERE p.dog_id = ?
`).all(dogId);
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId);
const sire = parents.find(p => p.parent_type === 'sire');
const dam = parents.find(p => p.parent_type === 'dam');
const dam = parents.find(p => p.parent_type === 'dam');
return {
...dog,
generation: currentGen,
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
};
}
const tree = buildTree(req.params.id);
if (!tree) {
return res.status(404).json({ error: 'Dog not found' });
}
if (!tree) return res.status(404).json({ error: 'Dog not found' });
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET reverse pedigree (descendants)
// GET /api/pedigree/:id/descendants
router.get('/:id/descendants', (req, res) => {
try {
const db = getDatabase();
@@ -119,67 +319,27 @@ router.get('/:id/descendants', (req, res) => {
function buildDescendantTree(dogId, currentGen = 0) {
if (currentGen >= generations) return null;
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
if (!dog) return null;
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const offspring = db.prepare(`
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
FROM dogs d
JOIN parents p ON d.id = p.dog_id
FROM dogs d JOIN parents p ON d.id = p.dog_id
WHERE p.parent_id = ? AND d.is_active = 1
`).all(dogId);
return {
...dog,
generation: currentGen,
offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1))
offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1))
};
}
const tree = buildDescendantTree(req.params.id);
if (!tree) {
return res.status(404).json({ error: 'Dog not found' });
}
if (!tree) return res.status(404).json({ error: 'Dog not found' });
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST calculate COI for a trial pairing
router.post('/trial-pairing', (req, res) => {
try {
const { sire_id, dam_id } = req.body;
if (!sire_id || !dam_id) {
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
}
const db = getDatabase();
const sire = db.prepare('SELECT * FROM dogs WHERE id = ? AND sex = "male"').get(sire_id);
const dam = db.prepare('SELECT * FROM dogs WHERE id = ? AND sex = "female"').get(dam_id);
if (!sire || !dam) {
return res.status(404).json({ error: 'Invalid sire or dam' });
}
const result = calculateCOI(sire_id, dam_id);
res.json({
sire: { id: sire.id, name: sire.name },
dam: { id: dam.id, name: dam.name },
coi: result.coefficient,
commonAncestors: result.commonAncestors,
recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

64
server/routes/settings.js Normal file
View File

@@ -0,0 +1,64 @@
const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
// Allowed columns — whitelist prevents arbitrary SQL column injection
const ALLOWED_KEYS = [
'kennel_name',
'kennel_tagline',
'kennel_address',
'kennel_phone',
'kennel_email',
'kennel_website',
'kennel_akc_id',
'kennel_breed',
'owner_name',
];
// GET /api/settings
router.get('/', (req, res) => {
try {
const db = getDatabase();
// Always returns exactly one row (seeded in init.js)
const row = db.prepare(`SELECT ${ALLOWED_KEYS.join(', ')} FROM settings LIMIT 1`).get();
res.json(row || {});
} catch (error) {
console.error('Error fetching settings:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/settings
router.put('/', (req, res) => {
try {
const db = getDatabase();
const updates = req.body || {};
// Build SET clause only for allowed keys that were sent
const fields = Object.keys(updates).filter(k => ALLOWED_KEYS.includes(k));
if (fields.length === 0) {
return res.status(400).json({ error: 'No valid settings fields provided' });
}
const setClause = fields.map(f => `${f} = ?`).join(', ');
const values = fields.map(f => updates[f] == null ? null : String(updates[f]));
// Ensure a row exists, then update it
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
if (!existing) {
db.prepare(`INSERT INTO settings (kennel_name) VALUES ('BREEDR')`).run();
}
db.prepare(`UPDATE settings SET ${setClause}, updated_at = datetime('now') WHERE id = (SELECT id FROM settings LIMIT 1)`)
.run(...values);
const row = db.prepare(`SELECT ${ALLOWED_KEYS.join(', ')} FROM settings LIMIT 1`).get();
res.json(row || {});
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

26
server/test_app.js Normal file
View File

@@ -0,0 +1,26 @@
const app = require('./index');
const http = require('http');
// Start temporary server
const server = http.createServer(app);
server.listen(3030, async () => {
console.log('Server started on 3030');
try {
const res = await fetch('http://localhost:3030/api/pedigree/relations/1/2');
const text = await res.text();
console.log('GET /api/pedigree/relations/1/2 RESPONSE:', res.status, text.substring(0, 150));
const postRes = await fetch('http://localhost:3030/api/pedigree/trial-pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sire_id: 1, dam_id: 2 })
});
const postText = await postRes.text();
console.log('POST /api/pedigree/trial-pairing RESPONSE:', postRes.status, postText.substring(0, 150));
} catch (err) {
console.error('Fetch error:', err);
} finally {
server.close();
process.exit(0);
}
});

8
server/test_express.js Normal file
View File

@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
router.post(['/a', '/b'], (req, res) => {
res.send('ok');
});
console.log('Started successfully');

167
server/utils/README.md Normal file
View File

@@ -0,0 +1,167 @@
# Server Utilities
## Startup Log (`startupLog.js`)
Comprehensive server startup logging utility that displays system information, configuration, and health checks on application boot.
### Features
- **ASCII Banner** - Eye-catching branded header with BREEDR logo
- **Application Info** - Version, environment, timestamp, Node.js version
- **Server Configuration** - Port, access URL, database status
- **Directory Status** - Checks existence and write permissions for data/uploads/static directories
- **System Resources** - Hostname, platform, architecture, CPU, memory
- **Process Info** - PID, heap usage, uptime
### Usage
```javascript
const { logStartupBanner } = require('./utils/startupLog');
app.listen(PORT, '0.0.0.0', () => {
logStartupBanner({
appName: 'BREEDR',
port: PORT,
environment: process.env.NODE_ENV || 'development',
dataDir: DATA_DIR,
uploadPath: UPLOAD_PATH,
staticPath: STATIC_PATH,
dbStatus: '✓ Connected'
});
});
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `appName` | string | `'BREEDR'` | Application name |
| `port` | number | `3000` | Server port |
| `environment` | string | `'development'` | Environment (development/production) |
| `dataDir` | string | `'./data'` | Data directory path |
| `uploadPath` | string | `'./uploads'` | Uploads directory path |
| `staticPath` | string | `'./static'` | Static assets directory path |
| `dbStatus` | string | `'unknown'` | Database connection status |
### Exported Functions
#### `logStartupBanner(config)`
Displays the complete startup banner with all system information.
**Parameters:**
- `config` (object) - Configuration options (see table above)
**Returns:** void
#### `getSystemInfo()`
Returns system information object.
**Returns:**
```javascript
{
hostname: string,
platform: string,
arch: string,
nodeVersion: string,
cpuCores: number,
totalMemory: string, // in GB
freeMemory: string, // in GB
uptime: string // in seconds
}
```
#### `getProcessInfo()`
Returns current process information.
**Returns:**
```javascript
{
pid: number,
heapUsed: string, // in MB
heapTotal: string, // in MB
external: string // in MB
}
```
#### `checkDirectories(dirs)`
Checks directory existence and write permissions.
**Parameters:**
- `dirs` (array) - Array of `{ name, path }` objects
**Returns:**
```javascript
{
[name]: {
exists: boolean,
path: string,
writable: boolean
}
}
```
#### `getAppVersion()`
Reads version from package.json.
**Returns:** string - Version number or 'unknown'
### Example Output
```
╔══════════════════════════════════════════════════════════╗
║ ║
║ ██████╗ ██████╗ ███████╗███████╗██████╗ ██████╗ ║
║ Dog Breeding Genealogy Management System ║
╚══════════════════════════════════════════════════════════╝
┌─────────────────────────────────────────────────────────┐
│ 📦 APPLICATION INFO │
├─────────────────────────────────────────────────────────┤
│ Version : 0.6.0 │
│ Environment : production │
│ Node.js : v18.19.0 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 🌐 SERVER CONFIGURATION │
├─────────────────────────────────────────────────────────┤
│ Port : 3000 │
│ Access URL : http://localhost:3000 │
│ Database : ✓ Connected │
└─────────────────────────────────────────────────────────┘
🚀 Server is ready and listening for connections
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### Benefits
1. **Instant System Visibility** - See all critical system info at startup
2. **Troubleshooting** - Quickly identify configuration or resource issues
3. **Professional Logging** - Clean, organized output for production environments
4. **Directory Health** - Immediate feedback on filesystem permissions
5. **Resource Monitoring** - Memory and process info at a glance
### Integration Checklist
- [x] Create `server/utils/startupLog.js`
- [x] Update `server/index.js` to import and call `logStartupBanner()`
- [x] Replace simple console.log startup with comprehensive banner
- [x] Test in development environment
- [ ] Test in production Docker container
- [ ] Verify all directory checks work correctly
- [ ] Update main README.md if needed
### Future Enhancements
- [ ] Add color support using chalk or similar library
- [ ] Log to file option for production environments
- [ ] Add API endpoint status checks
- [ ] Display loaded routes count
- [ ] Show database migration status
- [ ] Add startup time measurement

177
server/utils/startupLog.js Normal file
View File

@@ -0,0 +1,177 @@
const os = require('os');
const path = require('path');
const fs = require('fs');
/**
* Startup Log Utility
* Displays comprehensive system information and branding on server start
*/
function getSystemInfo() {
return {
hostname: os.hostname(),
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version,
cpuCores: os.cpus().length,
totalMemory: (os.totalmem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
freeMemory: (os.freemem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
uptime: process.uptime().toFixed(2) + 's'
};
}
function getProcessInfo() {
const memUsage = process.memoryUsage();
return {
pid: process.pid,
heapUsed: (memUsage.heapUsed / 1024 / 1024).toFixed(2) + ' MB',
heapTotal: (memUsage.heapTotal / 1024 / 1024).toFixed(2) + ' MB',
external: (memUsage.external / 1024 / 1024).toFixed(2) + ' MB'
};
}
function checkDirectories(dirs) {
const status = {};
dirs.forEach(({ name, path: dirPath }) => {
status[name] = {
exists: fs.existsSync(dirPath),
path: dirPath,
writable: false
};
// Check write permissions if directory exists
if (status[name].exists) {
try {
fs.accessSync(dirPath, fs.constants.W_OK);
status[name].writable = true;
} catch (err) {
status[name].writable = false;
}
}
});
return status;
}
function getAppVersion() {
try {
const packagePath = path.join(__dirname, '../../package.json');
if (fs.existsSync(packagePath)) {
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
return pkg.version || 'unknown';
}
} catch (err) {
// Silently fail
}
return 'unknown';
}
function logStartupBanner(config = {}) {
const {
appName = 'BREEDR',
port = 3000,
environment = 'development',
dataDir = './data',
uploadPath = './uploads',
staticPath = './static',
dbStatus = 'unknown'
} = config;
const version = getAppVersion();
const sysInfo = getSystemInfo();
const procInfo = getProcessInfo();
const timestamp = new Date().toISOString();
const directories = [
{ name: 'Data', path: dataDir },
{ name: 'Uploads', path: uploadPath },
{ name: 'Static', path: staticPath }
];
const dirStatus = checkDirectories(directories);
// ASCII Banner
console.log('\n');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ ║');
console.log('║ ██████╗ ██████╗ ███████╗███████╗██████╗ ██████╗ ║');
console.log('║ ██╔══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗ ║');
console.log('║ ██████╔╝██████╔╝█████╗ █████╗ ██║ ██║██████╔╝ ║');
console.log('║ ██╔══██╗██╔══██╗██╔══╝ ██╔══╝ ██║ ██║██╔══██╗ ║');
console.log('║ ██████╔╝██║ ██║███████╗███████╗██████╔╝██║ ██║ ║');
console.log('║ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ║');
console.log('║ ║');
console.log('║ Dog Breeding Genealogy Management System ║');
console.log('║ ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
// Application Info
console.log('┌─────────────────────────────────────────────────────────┐');
console.log('│ 📦 APPLICATION INFO │');
console.log('├─────────────────────────────────────────────────────────┤');
console.log(`│ Version : ${version.padEnd(40)}`);
console.log(`│ Environment : ${environment.padEnd(40)}`);
console.log(`│ Started : ${timestamp.padEnd(40)}`);
console.log(`│ Node.js : ${sysInfo.nodeVersion.padEnd(40)}`);
console.log('└─────────────────────────────────────────────────────────┘');
console.log('');
// Server Configuration
console.log('┌─────────────────────────────────────────────────────────┐');
console.log('│ 🌐 SERVER CONFIGURATION │');
console.log('├─────────────────────────────────────────────────────────┤');
console.log(`│ Port : ${String(port).padEnd(40)}`);
console.log(`│ Access URL : http://localhost:${port}${' '.repeat(27)}`);
console.log(`│ Database : ${dbStatus.padEnd(40)}`);
console.log('└─────────────────────────────────────────────────────────┘');
console.log('');
// Directory Status
console.log('┌─────────────────────────────────────────────────────────┐');
console.log('│ 📁 DIRECTORY STATUS │');
console.log('├─────────────────────────────────────────────────────────┤');
Object.entries(dirStatus).forEach(([name, status]) => {
const statusIcon = status.exists ? (status.writable ? '✓' : '⚠') : '✗';
const statusText = status.exists ? (status.writable ? 'OK' : 'READ-ONLY') : 'MISSING';
console.log(`${statusIcon} ${name.padEnd(10)} : ${statusText.padEnd(10)} ${status.path.substring(0, 25).padEnd(25)}`);
});
console.log('└─────────────────────────────────────────────────────────┘');
console.log('');
// System Resources
console.log('┌─────────────────────────────────────────────────────────┐');
console.log('│ 💻 SYSTEM RESOURCES │');
console.log('├─────────────────────────────────────────────────────────┤');
console.log(`│ Hostname : ${sysInfo.hostname.padEnd(40)}`);
console.log(`│ Platform : ${sysInfo.platform.padEnd(40)}`);
console.log(`│ Architecture : ${sysInfo.arch.padEnd(40)}`);
console.log(`│ CPU Cores : ${String(sysInfo.cpuCores).padEnd(40)}`);
console.log(`│ Total Memory : ${sysInfo.totalMemory.padEnd(40)}`);
console.log(`│ Free Memory : ${sysInfo.freeMemory.padEnd(40)}`);
console.log('└─────────────────────────────────────────────────────────┘');
console.log('');
// Process Info
console.log('┌─────────────────────────────────────────────────────────┐');
console.log('│ ⚙️ PROCESS INFO │');
console.log('├─────────────────────────────────────────────────────────┤');
console.log(`│ PID : ${String(procInfo.pid).padEnd(40)}`);
console.log(`│ Heap Used : ${procInfo.heapUsed.padEnd(40)}`);
console.log(`│ Heap Total : ${procInfo.heapTotal.padEnd(40)}`);
console.log(`│ External : ${procInfo.external.padEnd(40)}`);
console.log(`│ Uptime : ${sysInfo.uptime.padEnd(40)}`);
console.log('└─────────────────────────────────────────────────────────┘');
console.log('');
// Ready message
console.log('🚀 Server is ready and listening for connections');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('');
}
module.exports = {
logStartupBanner,
getSystemInfo,
getProcessInfo,
checkDirectories,
getAppVersion
};

BIN
static/br-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB