Compare commits
23 Commits
af9398ec0f
...
main2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b941c9a9a | ||
|
|
055364f467 | ||
|
|
b8eadd9efa | ||
|
|
ff1eb455dc | ||
|
|
c22ebbe45c | ||
|
|
e5f7b2b053 | ||
|
|
c00b6191e7 | ||
|
|
0f9d3cf187 | ||
|
|
2daccf7d8c | ||
| 5c6068364b | |||
| 768e25183d | |||
| 78069f2880 | |||
| 2cfeaf667e | |||
| 5eaa6e566c | |||
| 80b497e902 | |||
| 8cb4c773fd | |||
| 22e85f0d7e | |||
| aa3b1b2404 | |||
| 3275524ad0 | |||
| 9738b24db6 | |||
| 0c84b83e75 | |||
| 01a5db10c0 | |||
| df7d94ba9d |
@@ -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`).
|
||||
@@ -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.
|
||||
87
.zenflow/tasks/init-9c68/plan.md
Normal file
87
.zenflow/tasks/init-9c68/plan.md
Normal 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.
|
||||
44
.zenflow/tasks/init-9c68/requirements.md
Normal file
44
.zenflow/tasks/init-9c68/requirements.md
Normal 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.
|
||||
89
.zenflow/tasks/init-9c68/spec.md
Normal file
89
.zenflow/tasks/init-9c68/spec.md
Normal 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.
|
||||
44
.zenflow/tasks/new-task-6e6e/plan.md
Normal file
44
.zenflow/tasks/new-task-6e6e/plan.md
Normal 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.
|
||||
32
.zenflow/tasks/new-task-7382/investigation.md
Normal file
32
.zenflow/tasks/new-task-7382/investigation.md
Normal 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.
|
||||
44
.zenflow/tasks/new-task-7382/plan.md
Normal file
44
.zenflow/tasks/new-task-7382/plan.md
Normal 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.
|
||||
39
.zenflow/tasks/new-task-cdb6/plan.md
Normal file
39
.zenflow/tasks/new-task-cdb6/plan.md
Normal 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 2–3 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 3–5 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
201
API.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# BREEDR API Documentation (v0.6.1)
|
||||
|
||||
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": [ ... ]
|
||||
}
|
||||
```
|
||||
118
DEVELOPMENT.md
Normal file
118
DEVELOPMENT.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# DEVELOPMENT.md
|
||||
|
||||
This document provides technical details and guidelines for developing and maintaining the BREEDR Genealogy Management System.
|
||||
|
||||
## Tech Stack Overview
|
||||
|
||||
- **Monorepo Structure**:
|
||||
- `server/`: Express.js backend.
|
||||
- `client/`: React/Vite frontend.
|
||||
- `data/`: SQLite database storage.
|
||||
- `uploads/`: Uploaded images and documents.
|
||||
- `static/`: Static assets for the application.
|
||||
|
||||
- **Backend**: Node.js, Express, better-sqlite3, multer, bcrypt, jsonwebtoken.
|
||||
- **Frontend**: React 18, Vite, React Router 6, Axios, Lucide React, D3 (for pedigree trees).
|
||||
- **Database**: SQLite (managed with better-sqlite3).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18+ recommended)
|
||||
- npm
|
||||
|
||||
### Installation
|
||||
1. Clone the repository.
|
||||
2. Install root dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Install client dependencies:
|
||||
```bash
|
||||
cd client && npm install
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
Run the following from the project root:
|
||||
- **Run both client and server**: `npm run dev`
|
||||
- **Run server only**: `npm run server`
|
||||
- **Run client only**: `npm run client`
|
||||
- **Initialize Database**: `npm run db:init`
|
||||
- **Build for production**: `npm run build`
|
||||
|
||||
## Database Architecture
|
||||
|
||||
### Data Storage
|
||||
The database is a single SQLite file located at `data/breedr.db`. This directory is automatically created on startup if it doesn't exist.
|
||||
|
||||
### Initialization & Schema
|
||||
- **Initialization**: `server/db/init.js` defines the initial schema and creates tables if they don't exist.
|
||||
- **Migrations**: `server/db/migrations.js` handles schema updates. Migrations run automatically on server startup.
|
||||
|
||||
### "Parents Table" Approach
|
||||
Instead of storing parent IDs directly in the `dogs` table (which was the old approach), relationships are managed in a dedicated `parents` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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)
|
||||
);
|
||||
```
|
||||
|
||||
**Key Benefits**:
|
||||
- Avoids complex `ALTER TABLE` operations when changing pedigree logic.
|
||||
- Cleanly separates dog attributes from lineage relationships.
|
||||
- Supports indexing for fast recursive pedigree lookups.
|
||||
|
||||
### Key Tables
|
||||
- **`dogs`**: Core dog data (name, breed, sex, microchip, etc.).
|
||||
- **`parents`**: Lineage relationships (Sire/Dam).
|
||||
- **`litters`**: Groups of dogs from a single breeding.
|
||||
- **`breeding_records`**: Planned or completed breeding events.
|
||||
- **`health_records`**: OFA results, vaccinations, and other health tests.
|
||||
- **`genetic_tests`**: DNA panel results.
|
||||
- **`settings`**: Kennel-wide configurations.
|
||||
|
||||
## Backend Development
|
||||
|
||||
### API Routes
|
||||
Routes are modularized in `server/routes/`:
|
||||
- `/api/dogs`: Dog management.
|
||||
- `/api/litters`: Litter management.
|
||||
- `/api/health`: Health record management.
|
||||
- `/api/genetics`: Genetic testing management.
|
||||
- `/api/pedigree`: Pedigree tree generation.
|
||||
- `/api/breeding`: Breeding records.
|
||||
- `/api/settings`: Application settings.
|
||||
|
||||
### File Uploads
|
||||
Images and documents are stored in `uploads/`. The `multer` middleware handles file processing. File paths are stored in the database as relative URLs (e.g., `/uploads/image.jpg`).
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### State Management
|
||||
- **Settings**: Managed globally via `SettingsProvider` in `client/src/hooks/useSettings.jsx`.
|
||||
- **Component State**: Local `useState` and `useEffect` are preferred for feature-specific data.
|
||||
|
||||
### Styling
|
||||
- CSS Variables are used for theming.
|
||||
- The UI uses a modern, clean design with Lucide icons.
|
||||
|
||||
### Pedigree Trees
|
||||
The pedigree tree visualization is powered by `react-d3-tree` and D3.js. Logic for building the tree structure is located in `server/routes/pedigree.js` and visualized in the `PedigreeTree` component.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment mode | `development` |
|
||||
| `DATA_DIR` | Path to DB storage | `../data` |
|
||||
| `UPLOAD_PATH` | Path to uploads | `../uploads` |
|
||||
| `STATIC_PATH` | Path to static assets | `../static` |
|
||||
| `DB_PATH` | Full path to .db file | `../data/breedr.db` |
|
||||
105
FRONTEND_GUIDE.md
Normal file
105
FRONTEND_GUIDE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Frontend Guide
|
||||
|
||||
This document provides an overview of the frontend architecture, technologies, and patterns used in the BREEDR application.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [React](https://reactjs.org/) (bootstrapped with [Vite](https://vitejs.dev/))
|
||||
- **Routing**: [react-router-dom](https://reactrouter.com/)
|
||||
- **Icons**: [lucide-react](https://lucide.dev/)
|
||||
- **Data Fetching**: [axios](https://axios-http.com/)
|
||||
- **Visualizations**: [react-d3-tree](https://github.com/bkrem/react-d3-tree) (for pedigree rendering)
|
||||
- **Styling**: Standard CSS with CSS Variables (Custom properties)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
client/src/
|
||||
├── components/ # Reusable UI components
|
||||
├── hooks/ # Custom hooks and context providers
|
||||
├── pages/ # Page-level components (routes)
|
||||
├── utils/ # Helper functions and utilities
|
||||
├── App.jsx # Root component with routing
|
||||
├── App.css # Layout-specific styles
|
||||
├── index.css # Global styles and CSS variables
|
||||
└── main.jsx # Application entry point
|
||||
```
|
||||
|
||||
## Routing and Layout
|
||||
|
||||
The application uses `react-router-dom` for navigation. The primary layout and routes are defined in `client/src/App.jsx`.
|
||||
|
||||
- **Navbar**: Contains links to Dashboard, Dogs, Litters, Breeding, Pairing, and Settings.
|
||||
- **Main Content**: Renders the matched route element within a `.main-content` container.
|
||||
|
||||
### Key Routes
|
||||
- `/`: Dashboard
|
||||
- `/dogs`: Dog List
|
||||
- `/dogs/:id`: Dog Detail
|
||||
- `/pedigree/:id`: Pedigree View
|
||||
- `/litters`: Litter List
|
||||
- `/breeding`: Breeding Calendar
|
||||
- `/pairing`: Pairing Simulator
|
||||
- `/settings`: Settings Page
|
||||
|
||||
## State Management
|
||||
|
||||
### Settings Context (`useSettings`)
|
||||
Global application settings (like kennel name) are managed via a React Context.
|
||||
|
||||
- **Provider**: `SettingsProvider` in `client/src/hooks/useSettings.jsx`.
|
||||
- **Usage**:
|
||||
```javascript
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
const { settings, saveSettings, loading } = useSettings();
|
||||
```
|
||||
|
||||
### Local State
|
||||
Most page-specific data is managed using standard `useState` and `useEffect` hooks, fetching data via `axios`.
|
||||
|
||||
## Styling Conventions
|
||||
|
||||
The application follows a dark-theme aesthetic using CSS variables for consistency.
|
||||
|
||||
### CSS Variables (`client/src/index.css`)
|
||||
Key variables include:
|
||||
- `--primary`: Main brand color (warm amber/copper).
|
||||
- `--bg-primary`: Primary background.
|
||||
- `--text-primary`: Primary text color.
|
||||
- `--border`: Standard border color.
|
||||
- `--radius`: Default border radius.
|
||||
|
||||
### Reusable UI Classes
|
||||
- `.btn`, `.btn-primary`, `.btn-secondary`: Standard button styles.
|
||||
- `.card`: Container for grouped content.
|
||||
- `.grid`, `.grid-2`, `.grid-3`: Responsive grid layouts.
|
||||
- `.modal-overlay`, `.modal-content`: Standard modal structure.
|
||||
|
||||
## Key Components
|
||||
|
||||
### PedigreeTree (`client/src/components/PedigreeTree.jsx`)
|
||||
Uses `react-d3-tree` to render a horizontal, step-based pedigree.
|
||||
- **Props**: `dogId`, `pedigreeData`, `coi`.
|
||||
- **Features**: Custom node rendering (differentiating by sex and champion status), zoom/pan controls, and COI display.
|
||||
|
||||
### DogForm (`client/src/components/DogForm.jsx`)
|
||||
Handles both creation and editing of dog records.
|
||||
- **Logic**: Manages internal/external dog states, parent selection (manual or linked to litter), and champion status.
|
||||
- **Validation**: Basic required field validation; errors are displayed at the top of the form.
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
Standard `axios` requests are used:
|
||||
```javascript
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/endpoint');
|
||||
setData(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Error message');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Icons
|
||||
Use `lucide-react` for all icons to ensure consistency across the UI.
|
||||
44
README.md
44
README.md
@@ -28,10 +28,15 @@ A reactive, interactive dog breeding genealogy mapping system for professional k
|
||||
- **✅ Genetic Traits** - Inherited trait mapping
|
||||
- **✅ Settings Table** - Single-row kennel configuration with all contact/identity fields
|
||||
|
||||
### Recently Added (March 9, 2026 — v0.6.0)
|
||||
### Recently Added (March 10, 2026 — v0.6.1)
|
||||
- **✅ COI Direct-Relation Fix** — `calculateCOI` now correctly computes inbreeding coefficient for parent×offspring pairings. Previously returned `0.00%` due to blanket exclusion of `sid` from `commonIds`; sire now correctly appears as a common ancestor in the dam's ancestry map when they are parent×offspring
|
||||
- **✅ pedigree.js Route Fix** — `commonIds` filter changed from `id !== sid && id !== did` → `id !== did` only; preserves parent×offspring COI path while still preventing reflexive dam self-loop
|
||||
- **Expected COI for parent×offspring pairing:** ~25.00% (Wright's path coefficient method)
|
||||
|
||||
### Previously Added (March 9, 2026 — v0.6.0)
|
||||
- **✅ Champion Flag** — `is_champion INTEGER DEFAULT 0` on `dogs` table; safe `ALTER TABLE` migration guard for existing DBs
|
||||
- **✅ Champion Toggle in DogForm** — amber-gold highlighted checkbox row with `Award` icon; marks dog as titled champion
|
||||
- **✅ Champion ✪ in Parent Dropdowns** — sire/dam selects append `✪` to champion names for at-a-glance visibility
|
||||
- **✅ Champion ⭐ in Parent Dropdowns** — sire/dam selects append `⭐` to champion names for at-a-glance visibility
|
||||
- **✅ Champion Bloodline Badge** — offspring of champion parents display a badge on dog cards and detail pages
|
||||
- **✅ Kennel Settings API** — `GET/PUT /api/settings` with single-row column schema and ALLOWED_KEYS whitelist
|
||||
- **✅ Settings Table Migration** — all kennel fields added with safe `ALTER TABLE` guards on existing DBs; default seed row auto-created
|
||||
@@ -147,18 +152,20 @@ For a **fresh install**, the database will automatically initialize with the cor
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server (frontend + backend)
|
||||
# Run development server (frontend + backend, nodemon auto-reload)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
> **Note:** `npm run dev` uses nodemon for auto-reload on the server. `npm start` (production) does **not** watch for changes — restart is required after pulling updates.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
breedr/
|
||||
├── client/ # React frontend
|
||||
├── client/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── BreedingCalendar.jsx # Heat cycle calendar + whelping identifiers
|
||||
@@ -175,19 +182,19 @@ breedr/
|
||||
│ │ │ └── useSettings.jsx # SettingsProvider + useSettings context
|
||||
│ │ └── App.jsx
|
||||
│ └── package.json
|
||||
├── server/ # Node.js backend
|
||||
├── server/ # Node.js backend
|
||||
│ ├── routes/
|
||||
│ │ ├── dogs.js # is_champion in all queries
|
||||
│ │ ├── settings.js # GET/PUT kennel settings (single-row schema)
|
||||
│ │ ├── breeding.js # Heat cycles, whelping, suggestions
|
||||
│ │ ├── pedigree.js # COI, trial pairing
|
||||
│ │ ├── dogs.js # is_champion in all queries
|
||||
│ │ ├── settings.js # GET/PUT kennel settings (single-row schema)
|
||||
│ │ ├── breeding.js # Heat cycles, whelping, suggestions
|
||||
│ │ ├── pedigree.js # COI, trial pairing (v0.6.1 direct-relation fix)
|
||||
│ │ ├── litters.js
|
||||
│ │ └── health.js
|
||||
│ ├── db/
|
||||
│ │ └── init.js # Schema + ALTER TABLE migration guards
|
||||
│ │ └── init.js # Schema + ALTER TABLE migration guards
|
||||
│ └── index.js
|
||||
├── static/ # Branding assets (br-logo.png, etc.)
|
||||
├── docs/ # Documentation
|
||||
├── static/ # Branding assets (br-logo.png, etc.)
|
||||
├── docs/ # Documentation
|
||||
├── ROADMAP.md
|
||||
├── DATABASE.md
|
||||
├── Dockerfile
|
||||
@@ -210,6 +217,7 @@ breedr/
|
||||
### Pedigree & Genetics
|
||||
- `GET /api/pedigree/:id` - Generate pedigree tree
|
||||
- `POST /api/pedigree/trial-pairing` - COI + common ancestors + risk recommendation
|
||||
- `GET /api/pedigree/relations/:sireId/:damId` - Direct relation detection (parent/grandparent check)
|
||||
|
||||
### Breeding & Heat Cycles
|
||||
- `GET /api/breeding/heat-cycles` - All heat cycles
|
||||
@@ -230,6 +238,9 @@ breedr/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### COI shows 0.00% for parent×offspring pairings
|
||||
Ensure you are on v0.6.1+ (merge PR #47). The fix removes a blanket `id !== sid` exclusion in `calculateCOI` that was silently zeroing the inbreeding coefficient when the sire is a direct ancestor of the dam. After merging, restart the server.
|
||||
|
||||
### Server crashes with `SyntaxError: Unexpected end of input` on `settings.js`
|
||||
The settings route file may have been corrupted (double-encoded base64). Pull the latest code and rebuild.
|
||||
|
||||
@@ -272,6 +283,7 @@ A breeding date must be logged on the cycle for whelp window cells to appear. Us
|
||||
- [x] **Projected Whelping Calendar Identifier** (whelp window cells, due label, active card range, live modal preview, whelping banner)
|
||||
- [x] **Champion Bloodline Tracking** (is_champion flag, DogForm toggle, offspring badge)
|
||||
- [x] **Kennel Settings** (GET/PUT /api/settings, SettingsProvider, kennel name in navbar)
|
||||
- [x] **COI Direct-Relation Fix** (parent×offspring now correctly yields ~25% COI — v0.6.1)
|
||||
|
||||
### 🔜 In Progress / Up Next
|
||||
- [ ] Health Records System
|
||||
@@ -287,10 +299,16 @@ A breeding date must be logged on the cycle for whelp window cells to appear. Us
|
||||
|
||||
## Recent Updates
|
||||
|
||||
### March 10, 2026 - COI Direct-Relation Bug Fix (v0.6.1)
|
||||
- **Fixed:** `calculateCOI` in `server/routes/pedigree.js` — removed `id !== sid` from `commonIds` filter
|
||||
- **Root cause:** `getAncestorMap` includes each dog at `gen 0`; the sire (`sid`) correctly appears in the dam's ancestor map at `gen 1` for parent×offspring pairings, but `id !== sid` was filtering it out and returning `0.00%`
|
||||
- **Result:** Parent×offspring pairings now correctly return ~25.00% COI; all other pairings unaffected
|
||||
- **PR:** [#47](https://git.alwisp.com/jason/breedr/pulls/47)
|
||||
|
||||
### March 9, 2026 - Champion Bloodline, Settings, Build Fixes (v0.6.0)
|
||||
- **Added:** `is_champion` column to `dogs` table with safe `ALTER TABLE` migration guard
|
||||
- **Added:** Champion toggle checkbox in DogForm with amber-gold highlight and `Award` icon
|
||||
- **Added:** `✪` suffix on champion sire/dam in parent dropdowns
|
||||
- **Added:** `⭐` suffix on champion sire/dam in parent dropdowns
|
||||
- **Added:** Champion Bloodline badge on offspring cards/detail pages
|
||||
- **Added:** `GET/PUT /api/settings` route — single-row column schema with `ALLOWED_KEYS` whitelist
|
||||
- **Added:** Full kennel settings columns in `settings` table with migration guards
|
||||
|
||||
2675
client/package-lock.json
generated
Normal file
2675
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings } from 'lucide-react'
|
||||
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'
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
|
||||
@@ -42,6 +43,7 @@ function AppInner() {
|
||||
<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" />
|
||||
@@ -55,6 +57,7 @@ function AppInner() {
|
||||
<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 />} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Award } 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: '',
|
||||
@@ -16,6 +16,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
dam_id: null,
|
||||
litter_id: null,
|
||||
is_champion: false,
|
||||
is_external: isExternal ? 1 : 0,
|
||||
})
|
||||
const [dogs, setDogs] = useState([])
|
||||
const [litters, setLitters] = useState([])
|
||||
@@ -24,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
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()
|
||||
fetchLitters()
|
||||
if (!effectiveExternal) {
|
||||
fetchDogs()
|
||||
fetchLitters()
|
||||
}
|
||||
if (dog) {
|
||||
setFormData({
|
||||
name: dog.name || '',
|
||||
@@ -41,6 +47,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
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)
|
||||
}
|
||||
@@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
const submitData = {
|
||||
...formData,
|
||||
is_champion: formData.is_champion ? 1 : 0,
|
||||
sire_id: formData.sire_id || null,
|
||||
dam_id: formData.dam_id || null,
|
||||
litter_id: useManualParents ? null : (formData.litter_id || null),
|
||||
is_external: effectiveExternal ? 1 : 0,
|
||||
sire_id: effectiveExternal ? null : (formData.sire_id || null),
|
||||
dam_id: effectiveExternal ? null : (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,
|
||||
@@ -133,10 +141,31 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
<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>
|
||||
<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. Litter and parent fields are not applicable.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
@@ -221,69 +250,71 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
</div>
|
||||
</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>
|
||||
{/* Parent Section — hidden for external dogs */}
|
||||
{!effectiveExternal && (
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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 ? (
|
||||
<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
|
||||
{!useManualParents && littersAvailable ? (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||
<label className="label">Notes</label>
|
||||
@@ -294,7 +325,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
<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...' : dog ? 'Update Dog' : 'Add Dog'}
|
||||
{loading ? 'Saving...' : dog ? 'Update Dog' : effectiveExternal ? 'Add External Dog' : 'Add Dog'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -186,8 +186,8 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
{coi !== null && coi !== undefined && (
|
||||
<div className="coi-display">
|
||||
<span className="coi-label">COI</span>
|
||||
<span className={`coi-value ${coi > 10 ? 'high' : coi > 5 ? 'medium' : 'low'}`}>
|
||||
{coi.toFixed(2)}%
|
||||
<span className={`coi-value ${coi > 0.10 ? 'high' : coi > 0.05 ? 'medium' : 'low'}`}>
|
||||
{(coi * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
389
client/src/pages/ExternalDogs.jsx
Normal file
389
client/src/pages/ExternalDogs.jsx
Normal 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
|
||||
@@ -13,7 +13,8 @@ export default function PairingSimulator() {
|
||||
const [relationChecking, setRelationChecking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/dogs')
|
||||
// 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 || []))
|
||||
@@ -54,9 +55,6 @@ export default function PairingSimulator() {
|
||||
checkRelation(sireId, val)
|
||||
}
|
||||
|
||||
const males = dogs.filter(d => d.sex === 'male')
|
||||
const females = dogs.filter(d => d.sex === 'female')
|
||||
|
||||
async function handleSimulate(e) {
|
||||
e.preventDefault()
|
||||
if (!sireId || !damId) return
|
||||
@@ -67,13 +65,11 @@ export default function PairingSimulator() {
|
||||
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) })
|
||||
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Failed to calculate')
|
||||
}
|
||||
setResult(await res.json())
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Simulation failed')
|
||||
setResult(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -81,204 +77,164 @@ export default function PairingSimulator() {
|
||||
}
|
||||
}
|
||||
|
||||
function RiskBadge({ coi, recommendation }) {
|
||||
const isLow = coi < 5
|
||||
const isMed = coi >= 5 && coi < 10
|
||||
const isHigh = coi >= 10
|
||||
return (
|
||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
||||
{isLow && <CheckCircle size={20} />}
|
||||
{isMed && <AlertTriangle size={20} />}
|
||||
{isHigh && <XCircle size={20} />}
|
||||
<span>{recommendation}</span>
|
||||
</div>
|
||||
)
|
||||
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' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(139,92,246,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)' }}>
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Trial Pairing Simulator</h1>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>
|
||||
Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{/* Selector Card */}
|
||||
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<form onSubmit={handleSimulate}>
|
||||
<div className="form-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label className="label">Sire (Male) ♂</label>
|
||||
<select
|
||||
value={sireId}
|
||||
onChange={handleSireChange}
|
||||
required
|
||||
disabled={dogsLoading}
|
||||
>
|
||||
<option value="">— Select Sire —</option>
|
||||
{males.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!dogsLoading && males.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
|
||||
<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" style={{ margin: 0 }}>
|
||||
<label className="label">Dam (Female) ♀</label>
|
||||
<select
|
||||
value={damId}
|
||||
onChange={handleDamChange}
|
||||
required
|
||||
disabled={dogsLoading}
|
||||
>
|
||||
<option value="">— Select Dam —</option>
|
||||
{females.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!dogsLoading && females.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
|
||||
<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>
|
||||
|
||||
{/* Direct-relation warning banner */}
|
||||
{relationChecking && (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship…</p>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
Checking relationship...
|
||||
</div>
|
||||
)}
|
||||
{!relationChecking && relationWarning && (
|
||||
|
||||
{relationWarning && !relationChecking && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
||||
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
|
||||
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={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, color: '#eab308', fontSize: '0.875rem' }}>Direct Relation Detected</p>
|
||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||
{relationWarning}. COI will reflect the high inbreeding coefficient for this pairing.
|
||||
</p>
|
||||
</div>
|
||||
<ShieldAlert size={16} />
|
||||
<strong>Related:</strong> {relationWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!sireId || !damId || loading || relationChecking}
|
||||
style={{ minWidth: '160px' }}
|
||||
disabled={loading || dogsLoading || !sireId || !damId}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
||||
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</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>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div style={{ maxWidth: '720px' }}>
|
||||
{/* Direct-relation alert in results */}
|
||||
{result.directRelation && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
|
||||
}}>
|
||||
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, color: 'var(--danger)', fontSize: '0.875rem' }}>Direct Relation — High Inbreeding Risk</p>
|
||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{result.directRelation}</p>
|
||||
<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 — <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>
|
||||
)}
|
||||
|
||||
{/* COI Summary */}
|
||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
|
||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
|
||||
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
|
||||
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
|
||||
<p style={{
|
||||
fontSize: '2rem', fontWeight: 700, lineHeight: 1,
|
||||
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
||||
}}>
|
||||
{result.coi.toFixed(2)}%
|
||||
</p>
|
||||
</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 style={{ marginTop: '1.25rem' }}>
|
||||
<RiskBadge coi={result.coi} recommendation={result.recommendation} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||
<strong>COI Guide:</strong> <5% Low risk · 5–10% Moderate risk · >10% High risk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common Ancestors */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<GitMerge size={18} style={{ color: 'var(--accent)' }} />
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Common Ancestors</h3>
|
||||
<span className="badge badge-primary" style={{ marginLeft: 'auto' }}>
|
||||
{result.commonAncestors.length} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.commonAncestors.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 0 }}>
|
||||
No common ancestors found within 6 generations. This pairing has excellent genetic diversity.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Ancestor</th>
|
||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire Gen</th>
|
||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam Gen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.commonAncestors.map((anc, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '0.625rem 0.75rem', fontWeight: 500 }}>{anc.name}</td>
|
||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
||||
<span className="badge badge-primary">Gen {anc.sireGen}</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
||||
<span className="badge" style={{ background: 'rgba(244,114,182,0.15)', color: '#f472b6' }}>Gen {anc.damGen}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -144,16 +144,16 @@ export const formatCOI = (coi) => {
|
||||
}
|
||||
}
|
||||
|
||||
const value = coi.toFixed(2)
|
||||
const value = (coi * 100).toFixed(2)
|
||||
|
||||
if (coi <= 5) {
|
||||
if (coi <= 0.05) {
|
||||
return {
|
||||
value: `${value}%`,
|
||||
level: 'low',
|
||||
color: '#10b981',
|
||||
description: 'Low inbreeding - Excellent genetic diversity'
|
||||
}
|
||||
} else if (coi <= 10) {
|
||||
} else if (coi <= 0.10) {
|
||||
return {
|
||||
value: `${value}%`,
|
||||
level: 'medium',
|
||||
|
||||
@@ -13,33 +13,35 @@ function initDatabase() {
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// ── Dogs ────────────────────────────────────────────────────────────
|
||||
// ── Dogs ────────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dogs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
registration_number TEXT,
|
||||
breed TEXT NOT NULL,
|
||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||
birth_date TEXT,
|
||||
color TEXT,
|
||||
microchip TEXT,
|
||||
litter_id INTEGER,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_champion INTEGER DEFAULT 0,
|
||||
chic_number TEXT,
|
||||
age_at_death TEXT,
|
||||
cause_of_death TEXT,
|
||||
photo_urls TEXT DEFAULT '[]',
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
registration_number TEXT,
|
||||
breed TEXT NOT NULL,
|
||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||
birth_date TEXT,
|
||||
color TEXT,
|
||||
microchip TEXT,
|
||||
litter_id INTEGER,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_champion INTEGER DEFAULT 0,
|
||||
is_external INTEGER DEFAULT 0,
|
||||
chic_number TEXT,
|
||||
age_at_death TEXT,
|
||||
cause_of_death TEXT,
|
||||
photo_urls TEXT DEFAULT '[]',
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// migrate: add columns if missing (safe on existing DBs)
|
||||
const dogMigrations = [
|
||||
['is_champion', 'INTEGER DEFAULT 0'],
|
||||
['is_external', 'INTEGER DEFAULT 0'],
|
||||
['chic_number', 'TEXT'],
|
||||
['age_at_death', 'TEXT'],
|
||||
['cause_of_death', 'TEXT'],
|
||||
@@ -48,7 +50,7 @@ function initDatabase() {
|
||||
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||
}
|
||||
|
||||
// ── Parents ─────────────────────────────────────────────────────────
|
||||
// ── Parents ──────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS parents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -60,7 +62,7 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Breeding Records ─────────────────────────────────────────────────
|
||||
// ── Breeding Records ─────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS breeding_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -77,34 +79,28 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Litters ──────────────────────────────────────────────────────────
|
||||
// ── Litters ──────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS litters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
breeding_id INTEGER,
|
||||
sire_id INTEGER NOT NULL,
|
||||
dam_id INTEGER NOT NULL,
|
||||
whelp_date TEXT,
|
||||
total_count INTEGER DEFAULT 0,
|
||||
male_count INTEGER DEFAULT 0,
|
||||
female_count INTEGER DEFAULT 0,
|
||||
stillborn_count INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
breeding_id INTEGER,
|
||||
sire_id INTEGER NOT NULL,
|
||||
dam_id INTEGER NOT NULL,
|
||||
whelp_date TEXT,
|
||||
total_count INTEGER DEFAULT 0,
|
||||
male_count INTEGER DEFAULT 0,
|
||||
female_count INTEGER DEFAULT 0,
|
||||
stillborn_count INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
|
||||
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Health Records (OFA-extended) ────────────────────────────────────
|
||||
// test_type values: hip_ofa | hip_pennhip | elbow_ofa | heart_ofa |
|
||||
// heart_echo | eye_caer | thyroid_ofa | dna_panel | vaccination |
|
||||
// other
|
||||
// ofa_result values: excellent | good | fair | borderline | mild |
|
||||
// moderate | severe | normal | abnormal | pass | fail | carrier |
|
||||
// clear | affected | n/a
|
||||
// ── Health Records (OFA-extended) ─────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS health_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -130,7 +126,7 @@ function initDatabase() {
|
||||
|
||||
// migrate: add OFA-specific columns if missing (covers existing DBs)
|
||||
const healthMigrations = [
|
||||
['test_type', 'TEXT'],
|
||||
['test_type', 'TEXT'],
|
||||
['ofa_result', 'TEXT'],
|
||||
['ofa_number', 'TEXT'],
|
||||
['performed_by', 'TEXT'],
|
||||
@@ -144,10 +140,7 @@ function initDatabase() {
|
||||
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||
}
|
||||
|
||||
// ── Genetic Tests (DNA Panel) ─────────────────────────────────────────
|
||||
// result values: clear | carrier | affected | not_tested
|
||||
// marker examples: PRA1, PRA2, prcd-PRA, GR-PRA1, GR-PRA2, ICH1,
|
||||
// ICH2, NCL, DM, MD
|
||||
// ── Genetic Tests (DNA Panel) ──────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS genetic_tests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -164,23 +157,23 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Cancer History ───────────────────────────────────────────────────
|
||||
// ── Cancer History ────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cancer_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
cancer_type TEXT,
|
||||
age_at_diagnosis TEXT,
|
||||
age_at_death TEXT,
|
||||
cause_of_death TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
cancer_type TEXT,
|
||||
age_at_diagnosis TEXT,
|
||||
age_at_death TEXT,
|
||||
cause_of_death TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -31,15 +31,67 @@ const upload = multer({
|
||||
|
||||
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||
|
||||
// ── Shared SELECT columns ────────────────────────────────────────────
|
||||
// ── Shared SELECT columns ────────────────────────────────────────────────
|
||||
const DOG_COLS = `
|
||||
id, name, registration_number, breed, sex, birth_date,
|
||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
||||
is_champion, created_at, updated_at
|
||||
is_champion, is_external, created_at, updated_at
|
||||
`;
|
||||
|
||||
// ── GET all dogs ─────────────────────────────────────────────────────
|
||||
// ── Helper: attach parents to a list of dogs ─────────────────────────────
|
||||
function attachParents(db, dogs) {
|
||||
const parentStmt = db.prepare(`
|
||||
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||
FROM parents p
|
||||
JOIN dogs d ON p.parent_id = d.id
|
||||
WHERE p.dog_id = ?
|
||||
`);
|
||||
dogs.forEach(dog => {
|
||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||
const parents = parentStmt.all(dog.id);
|
||||
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||
});
|
||||
return dogs;
|
||||
}
|
||||
|
||||
// ── GET dogs
|
||||
// Default: kennel dogs only (is_external = 0)
|
||||
// ?include_external=1 : all active dogs (kennel + external)
|
||||
// ?external_only=1 : external dogs only
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
||||
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
||||
|
||||
let whereClause;
|
||||
if (externalOnly) {
|
||||
whereClause = 'WHERE is_active = 1 AND is_external = 1';
|
||||
} else if (includeExternal) {
|
||||
whereClause = 'WHERE is_active = 1';
|
||||
} else {
|
||||
whereClause = 'WHERE is_active = 1 AND is_external = 0';
|
||||
}
|
||||
|
||||
const dogs = db.prepare(`
|
||||
SELECT ${DOG_COLS}
|
||||
FROM dogs
|
||||
${whereClause}
|
||||
ORDER BY name
|
||||
`).all();
|
||||
|
||||
res.json(attachParents(db, dogs));
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ──────────
|
||||
// Kept for backwards-compat; equivalent to GET /?include_external=1
|
||||
router.get('/all', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const dogs = db.prepare(`
|
||||
@@ -48,29 +100,32 @@ router.get('/', (req, res) => {
|
||||
WHERE is_active = 1
|
||||
ORDER BY name
|
||||
`).all();
|
||||
|
||||
const parentStmt = db.prepare(`
|
||||
SELECT p.parent_type, d.id, d.name, d.is_champion
|
||||
FROM parents p
|
||||
JOIN dogs d ON p.parent_id = d.id
|
||||
WHERE p.dog_id = ?
|
||||
`);
|
||||
|
||||
dogs.forEach(dog => {
|
||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||
const parents = parentStmt.all(dog.id);
|
||||
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||
});
|
||||
|
||||
res.json(dogs);
|
||||
res.json(attachParents(db, dogs));
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error);
|
||||
console.error('Error fetching all dogs:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET single dog (with parents + offspring) ────────────────────────
|
||||
// ── GET external dogs only (is_external = 1) ──────────────────────────────
|
||||
// Kept for backwards-compat; equivalent to GET /?external_only=1
|
||||
router.get('/external', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const dogs = db.prepare(`
|
||||
SELECT ${DOG_COLS}
|
||||
FROM dogs
|
||||
WHERE is_active = 1 AND is_external = 1
|
||||
ORDER BY name
|
||||
`).all();
|
||||
res.json(attachParents(db, dogs));
|
||||
} catch (error) {
|
||||
console.error('Error fetching external dogs:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET single dog (with parents + offspring) ──────────────────────────
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
@@ -81,7 +136,7 @@ router.get('/:id', (req, res) => {
|
||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||
|
||||
const parents = db.prepare(`
|
||||
SELECT p.parent_type, d.id, d.name, d.is_champion
|
||||
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||
FROM parents p
|
||||
JOIN dogs d ON p.parent_id = d.id
|
||||
WHERE p.dog_id = ?
|
||||
@@ -91,7 +146,7 @@ router.get('/:id', (req, res) => {
|
||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||
|
||||
dog.offspring = db.prepare(`
|
||||
SELECT d.id, d.name, d.sex, d.is_champion
|
||||
SELECT d.id, d.name, d.sex, d.is_champion, d.is_external
|
||||
FROM dogs d
|
||||
JOIN parents p ON d.id = p.dog_id
|
||||
WHERE p.parent_id = ? AND d.is_active = 1
|
||||
@@ -104,13 +159,11 @@ router.get('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST create dog ──────────────────────────────────────────────────
|
||||
// ── POST create dog ─────────────────────────────────────────────────────
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { name, registration_number, breed, sex, birth_date, color,
|
||||
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
||||
|
||||
console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion });
|
||||
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||
|
||||
if (!name || !breed || !sex) {
|
||||
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
||||
@@ -119,8 +172,8 @@ router.post('/', (req, res) => {
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||
microchip, notes, litter_id, photo_urls, is_champion)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
microchip, notes, litter_id, photo_urls, is_champion, is_external)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name,
|
||||
emptyToNull(registration_number),
|
||||
@@ -131,11 +184,11 @@ router.post('/', (req, res) => {
|
||||
emptyToNull(notes),
|
||||
emptyToNull(litter_id),
|
||||
'[]',
|
||||
is_champion ? 1 : 0
|
||||
is_champion ? 1 : 0,
|
||||
is_external ? 1 : 0
|
||||
);
|
||||
|
||||
const dogId = result.lastInsertRowid;
|
||||
console.log(`✔ Dog inserted with ID: ${dogId}`);
|
||||
|
||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
||||
@@ -147,7 +200,7 @@ router.post('/', (req, res) => {
|
||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||
dog.photo_urls = [];
|
||||
|
||||
console.log(`✔ Dog created: ${dog.name} (ID: ${dogId})`);
|
||||
console.log(`✔ Dog created: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`);
|
||||
res.status(201).json(dog);
|
||||
} catch (error) {
|
||||
console.error('Error creating dog:', error);
|
||||
@@ -155,20 +208,18 @@ router.post('/', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── PUT update dog ───────────────────────────────────────────────────
|
||||
// ── PUT update dog ───────────────────────────────────────────────────────
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const { name, registration_number, breed, sex, birth_date, color,
|
||||
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
||||
|
||||
console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion });
|
||||
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||
|
||||
const db = getDatabase();
|
||||
db.prepare(`
|
||||
UPDATE dogs
|
||||
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
||||
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
||||
litter_id = ?, is_champion = ?, updated_at = datetime('now')
|
||||
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name,
|
||||
@@ -180,6 +231,7 @@ router.put('/:id', (req, res) => {
|
||||
emptyToNull(notes),
|
||||
emptyToNull(litter_id),
|
||||
is_champion ? 1 : 0,
|
||||
is_external ? 1 : 0,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
@@ -202,10 +254,7 @@ router.put('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE dog (hard delete with cascade) ────────────────────────────
|
||||
// Removes: parent relationships (both directions), health records,
|
||||
// heat cycles, and the dog record itself.
|
||||
// Photo files on disk are NOT removed here — run a gc job if needed.
|
||||
// ── DELETE dog (hard delete with cascade) ───────────────────────────────
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
@@ -213,13 +262,11 @@ router.delete('/:id', (req, res) => {
|
||||
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
||||
|
||||
const id = req.params.id;
|
||||
|
||||
// Cascade cleanup
|
||||
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); // remove as parent
|
||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); // remove own parents
|
||||
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
||||
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
||||
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
|
||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
|
||||
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
||||
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
||||
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
||||
|
||||
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
|
||||
res.json({ success: true, message: `${existing.name} has been deleted` });
|
||||
@@ -229,7 +276,7 @@ router.delete('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST upload photo ────────────────────────────────────────────────
|
||||
// ── POST upload photo ────────────────────────────────────────────────────
|
||||
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
@@ -249,7 +296,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE photo ─────────────────────────────────────────────────────
|
||||
// ── DELETE photo ──────────────────────────────────────────────────────
|
||||
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
|
||||
@@ -113,7 +113,7 @@ function calculateCOI(db, sireId, damId) {
|
||||
});
|
||||
|
||||
return {
|
||||
coefficient: Math.round(coi * 10000) / 100,
|
||||
coefficient: coi,
|
||||
commonAncestors: commonAncestorList
|
||||
};
|
||||
}
|
||||
@@ -124,8 +124,8 @@ function calculateCOI(db, sireId, damId) {
|
||||
// 'trial-pairing' as dog IDs and return 404/wrong data.
|
||||
// =====================================================================
|
||||
|
||||
// POST /api/pedigree/trial-pairing
|
||||
router.post('/trial-pairing', (req, res) => {
|
||||
// POST /api/pedigree/trial-pairing (alias for /coi)
|
||||
router.post(['/trial-pairing', '/coi'], (req, res) => {
|
||||
try {
|
||||
const { sire_id, dam_id } = req.body;
|
||||
if (!sire_id || !dam_id) {
|
||||
@@ -149,8 +149,8 @@ router.post('/trial-pairing', (req, res) => {
|
||||
coi: result.coefficient,
|
||||
commonAncestors: result.commonAncestors,
|
||||
directRelation: relation.related ? relation.relationship : null,
|
||||
recommendation: result.coefficient < 5 ? 'Low risk'
|
||||
: result.coefficient < 10 ? 'Moderate risk'
|
||||
recommendation: result.coefficient < 0.05 ? 'Low risk'
|
||||
: result.coefficient < 0.10 ? 'Moderate risk'
|
||||
: 'High risk'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -158,6 +158,28 @@ router.post('/trial-pairing', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user