Compare commits
48 Commits
af9398ec0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 761387388d | |||
| 4394286d0b | |||
|
|
b8633863b0 | ||
|
|
fa7a336588 | ||
| c3696ba015 | |||
| c483096c63 | |||
| e4e3b44fcf | |||
| 78e15d08af | |||
| 454665b9cb | |||
| d8557fcfca | |||
| 5f68ca0e8b | |||
| 42bab14ac3 | |||
| 5ca594fdc7 | |||
| 13185a5281 | |||
| 17b008a674 | |||
|
|
9b3210a81e | ||
| 81357e87ae | |||
|
|
8abd5e2db6 | ||
| a63617d9c0 | |||
|
|
7195aaecfc | ||
| 34bf29d8bf | |||
|
|
4f3074b1f4 | ||
| 3c7ba1775f | |||
|
|
0a0a5d232c | ||
|
|
58b53c981e | ||
|
|
7b941c9a9a | ||
|
|
055364f467 | ||
|
|
b8eadd9efa | ||
|
|
ff1eb455dc | ||
|
|
c22ebbe45c | ||
|
|
e5f7b2b053 | ||
|
|
c00b6191e7 | ||
|
|
0f9d3cf187 | ||
|
|
2daccf7d8c | ||
| 5c6068364b | |||
| 768e25183d | |||
| 78069f2880 | |||
| 2cfeaf667e | |||
| 5eaa6e566c | |||
| 80b497e902 | |||
| 8cb4c773fd | |||
| 22e85f0d7e | |||
| aa3b1b2404 | |||
| 3275524ad0 | |||
| 9738b24db6 | |||
| 0c84b83e75 | |||
| 01a5db10c0 | |||
| df7d94ba9d |
25
.gitea/workflows/docker-build.yml
Normal file
25
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
|
||||
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags & labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.alwisp.com/jason/breedr
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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.8.0)
|
||||
|
||||
Base URL: `/api`
|
||||
|
||||
All endpoints return JSON responses. Errors follow the format `{ "error": "message" }`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dogs (`/api/dogs`)
|
||||
|
||||
Manage individual dogs in the kennel.
|
||||
|
||||
### `GET /`
|
||||
Get active kennel dogs.
|
||||
- **Query Params**:
|
||||
- `include_external=1`: Include external dogs (studs/others)
|
||||
- `external_only=1`: Only show external dogs
|
||||
- **Response**: Array of [Dog](#dog-object) objects with `sire` and `dam` attached.
|
||||
|
||||
### `GET /:id`
|
||||
Get single dog details.
|
||||
- **Response**: [Dog](#dog-object) object including `sire`, `dam`, and `offspring` array.
|
||||
|
||||
### `POST /`
|
||||
Create a new dog.
|
||||
- **Body**: See [Dog](#dog-object) fields. `name`, `breed`, `sex` are required.
|
||||
- **Response**: The created Dog object.
|
||||
|
||||
### `PUT /:id`
|
||||
Update an existing dog.
|
||||
- **Body**: Dog fields to update.
|
||||
- **Response**: The updated Dog object.
|
||||
|
||||
### `DELETE /:id`
|
||||
Permanently delete a dog and its related records (health, heat, parents).
|
||||
- **Response**: `{ "success": true, "message": "..." }`
|
||||
|
||||
### `POST /:id/photos`
|
||||
Upload a photo for a dog.
|
||||
- **Form-Data**: `photo` (file)
|
||||
- **Response**: `{ "url": "...", "photos": [...] }`
|
||||
|
||||
### `DELETE /:id/photos/:photoIndex`
|
||||
Delete a specific photo from a dog's photo array.
|
||||
- **Response**: `{ "photos": [...] }`
|
||||
|
||||
---
|
||||
|
||||
## 2. Litters (`/api/litters`)
|
||||
|
||||
Manage breeding litters and puppy logs.
|
||||
|
||||
### `GET /`
|
||||
Get all litters.
|
||||
- **Response**: Array of [Litter](#litter-object) objects with sire/dam names and puppies.
|
||||
|
||||
### `GET /:id`
|
||||
Get single litter details.
|
||||
- **Response**: [Litter](#litter-object) object.
|
||||
|
||||
### `POST /`
|
||||
Create a new litter.
|
||||
- **Body**: `sire_id`, `dam_id`, `breeding_date` (required), `whelping_date`, `puppy_count`, `notes`.
|
||||
- **Response**: The created Litter object.
|
||||
|
||||
### `PUT /:id`
|
||||
Update litter details.
|
||||
|
||||
### `POST /:id/puppies/:puppyId`
|
||||
Link a dog to a litter as a puppy.
|
||||
- **Side Effect**: Automatically sets the litter's sire and dam as the puppy's parents.
|
||||
|
||||
### `DELETE /:id/puppies/:puppyId`
|
||||
Remove a puppy from a litter (sets `litter_id` to NULL).
|
||||
|
||||
### `GET /:litterId/puppies/:puppyId/logs`
|
||||
Get weight and health logs for a puppy.
|
||||
|
||||
### `POST /:litterId/puppies/:puppyId/logs`
|
||||
Add a weight/health log entry.
|
||||
- **Body**: `record_date` (required), `weight_oz`, `weight_lbs`, `notes`, `record_type`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Health (`/api/health`)
|
||||
|
||||
Manage OFA clearances and veterinary records.
|
||||
|
||||
### `GET /dog/:dogId`
|
||||
Get all health records for a dog.
|
||||
|
||||
### `GET /dog/:dogId/clearance-summary`
|
||||
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
|
||||
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
|
||||
|
||||
### `GET /dog/:dogId/chic-eligible`
|
||||
Check if a dog has all required CHIC tests.
|
||||
|
||||
### `POST /`
|
||||
Create health record.
|
||||
- **Body**: `dog_id`, `record_type`, `test_date` (required), `test_type`, `test_name`, `ofa_result`, `ofa_number`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Genetics (`/api/genetics`)
|
||||
|
||||
Manage DNA panel results and breeding risks.
|
||||
|
||||
### `GET /dog/:dogId`
|
||||
Get the full genetic panel for a dog. Returns `tests` (actual records) and `panel` (full list including `not_tested` placeholders).
|
||||
|
||||
### `GET /pairing-risk?sireId=X&damId=Y`
|
||||
Check genetic compatibility between two dogs.
|
||||
- **Response**: `{ risks: [...], safe_to_pair: boolean }`
|
||||
|
||||
### `POST /`
|
||||
Add a genetic test result.
|
||||
- **Body**: `dog_id`, `marker`, `result` (clear|carrier|affected|not_tested).
|
||||
|
||||
---
|
||||
|
||||
## 5. Breeding (`/api/breeding`)
|
||||
|
||||
Track heat cycles and whelping projections.
|
||||
|
||||
### `GET /heat-cycles/active`
|
||||
Get currently active heat cycles.
|
||||
|
||||
### `GET /heat-cycles/:id/suggestions`
|
||||
Get optimal breeding window (days 9-15) and whelping projections.
|
||||
|
||||
### `POST /heat-cycles`
|
||||
Log a new heat cycle. Dog must be female.
|
||||
|
||||
### `GET /whelping-calculator?breeding_date=YYYY-MM-DD`
|
||||
Standalone tool to calculate expected whelping window.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pedigree (`/api/pedigree`)
|
||||
|
||||
Advanced ancestry and COI calculations.
|
||||
|
||||
### `GET /:id?generations=N`
|
||||
Get an interactive pedigree tree (ancestors). Default 5 generations.
|
||||
|
||||
### `GET /:id/descendants?generations=N`
|
||||
Get a descendant tree. Default 3 generations.
|
||||
|
||||
### `POST /trial-pairing`
|
||||
Calculate Coefficient of Inbreeding (COI) for a hypothetical mating.
|
||||
- **Body**: `sire_id`, `dam_id`.
|
||||
- **Response**: `{ coi, commonAncestors, directRelation, recommendation }`
|
||||
|
||||
---
|
||||
|
||||
## 7. Settings (`/api/settings`)
|
||||
|
||||
### `GET /`
|
||||
Get kennel metadata (name, address, etc.).
|
||||
|
||||
### `PUT /`
|
||||
Update kennel settings.
|
||||
|
||||
---
|
||||
|
||||
## Data Objects
|
||||
|
||||
### Dog Object
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "BREEDR Champion",
|
||||
"registration_number": "AKC123456",
|
||||
"breed": "Golden Retriever",
|
||||
"sex": "male",
|
||||
"birth_date": "2020-01-01",
|
||||
"color": "Golden",
|
||||
"microchip": "900123456789",
|
||||
"is_active": 1,
|
||||
"is_champion": 1,
|
||||
"is_external": 0,
|
||||
"photo_urls": ["/uploads/img.jpg"],
|
||||
"notes": "Excellent temperament",
|
||||
"sire": { "id": 10, "name": "Sire Name" },
|
||||
"dam": { "id": 11, "name": "Dam Name" }
|
||||
}
|
||||
```
|
||||
|
||||
### Litter Object
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"sire_id": 1,
|
||||
"dam_id": 2,
|
||||
"breeding_date": "2023-01-01",
|
||||
"whelp_date": "2023-03-05",
|
||||
"total_count": 8,
|
||||
"puppies": [ ... ]
|
||||
}
|
||||
```
|
||||
115
CLEANUP_NOTES.md
115
CLEANUP_NOTES.md
@@ -1,115 +0,0 @@
|
||||
# Documentation Cleanup Notes
|
||||
|
||||
## Files to Delete (Outdated)
|
||||
|
||||
These documentation files are now outdated and should be deleted manually:
|
||||
|
||||
### DATABASE_MIGRATIONS.md
|
||||
- **Reason:** We no longer use migrations - clean init only
|
||||
- **Replacement:** DATABASE.md has current schema documentation
|
||||
- **Action:** Delete this file
|
||||
|
||||
### DEPLOY_NOW.md
|
||||
- **Reason:** Deployment info is outdated, superseded by README
|
||||
- **Replacement:** README.md has up-to-date deployment instructions
|
||||
- **Action:** Review and delete if redundant
|
||||
|
||||
### FEATURE_IMPLEMENTATION.md
|
||||
- **Reason:** Old implementation notes, likely stale
|
||||
- **Replacement:** ROADMAP.md has current feature status
|
||||
- **Action:** Review content, delete if redundant
|
||||
|
||||
### FRONTEND_FIX_REQUIRED.md
|
||||
- **Reason:** Specific bug fix notes, likely resolved
|
||||
- **Replacement:** Issues are tracked in ROADMAP
|
||||
- **Action:** Check if fixed, then delete
|
||||
|
||||
### IMPLEMENTATION_PLAN.md
|
||||
- **Reason:** Planning document, likely outdated
|
||||
- **Replacement:** ROADMAP.md is the living document
|
||||
- **Action:** Review and delete if redundant
|
||||
|
||||
### SPRINT1_PEDIGREE_COMPLETE.md
|
||||
- **Reason:** Sprint-specific notes, now historical
|
||||
- **Replacement:** ROADMAP.md shows current progress
|
||||
- **Action:** Archive or delete
|
||||
|
||||
### migrate-now.sh
|
||||
- **Reason:** Shell script for old migration system
|
||||
- **Replacement:** Not needed - init.js handles everything
|
||||
- **Action:** Delete this file
|
||||
|
||||
## Files to Keep (Current)
|
||||
|
||||
### DATABASE.md ✓
|
||||
- Complete schema documentation
|
||||
- Explains clean design (no migrations)
|
||||
- Reference for developers
|
||||
|
||||
### README.md ✓
|
||||
- Main project documentation
|
||||
- Installation and setup
|
||||
- Current features
|
||||
- Recently updated
|
||||
|
||||
### ROADMAP.md ✓
|
||||
- Development progress tracking
|
||||
- Feature planning
|
||||
- Version history
|
||||
- Recently updated
|
||||
|
||||
### INSTALL.md ✓
|
||||
- Detailed installation instructions
|
||||
- May need review for accuracy
|
||||
|
||||
### QUICKSTART.md ✓
|
||||
- Quick setup guide
|
||||
- May need review for accuracy
|
||||
|
||||
## Manual Cleanup Required
|
||||
|
||||
Gitea API doesn't support file deletion via MCP in some cases. To clean up:
|
||||
|
||||
```bash
|
||||
# Pull the branch
|
||||
git checkout docs/clean-schema-and-roadmap-update
|
||||
|
||||
# Delete outdated files
|
||||
git rm DATABASE_MIGRATIONS.md
|
||||
git rm DEPLOY_NOW.md
|
||||
git rm FEATURE_IMPLEMENTATION.md
|
||||
git rm FRONTEND_FIX_REQUIRED.md
|
||||
git rm IMPLEMENTATION_PLAN.md
|
||||
git rm SPRINT1_PEDIGREE_COMPLETE.md
|
||||
git rm migrate-now.sh
|
||||
|
||||
# Commit and push
|
||||
git commit -m "Clean: Remove outdated documentation files"
|
||||
git push origin docs/clean-schema-and-roadmap-update
|
||||
```
|
||||
|
||||
## Post-Cleanup Review
|
||||
|
||||
After cleanup, review these files for accuracy:
|
||||
|
||||
1. **INSTALL.md** - Verify installation steps are current
|
||||
2. **QUICKSTART.md** - Ensure quick start is up-to-date
|
||||
3. **docs/ folder** - Review any documentation in docs/ directory
|
||||
|
||||
## Summary
|
||||
|
||||
**Keep:**
|
||||
- DATABASE.md (new, comprehensive)
|
||||
- README.md (updated)
|
||||
- ROADMAP.md (updated)
|
||||
- INSTALL.md (needs review)
|
||||
- QUICKSTART.md (needs review)
|
||||
|
||||
**Delete:**
|
||||
- DATABASE_MIGRATIONS.md
|
||||
- DEPLOY_NOW.md
|
||||
- FEATURE_IMPLEMENTATION.md
|
||||
- FRONTEND_FIX_REQUIRED.md
|
||||
- IMPLEMENTATION_PLAN.md
|
||||
- SPRINT1_PEDIGREE_COMPLETE.md
|
||||
- migrate-now.sh
|
||||
222
DATABASE.md
222
DATABASE.md
@@ -1,222 +0,0 @@
|
||||
# BREEDR Database Schema
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the clean database schema for BREEDR. **NO migrations** - fresh installs create the correct schema automatically.
|
||||
|
||||
## Schema Design
|
||||
|
||||
### Core Principle: Parents Table Approach
|
||||
|
||||
The `dogs` table **does NOT have sire/dam columns**. Parent relationships are stored in the separate `parents` table. This design:
|
||||
- Keeps the schema clean and normalized
|
||||
- Allows flexible parent relationships
|
||||
- Supports future extensions (multiple sires, surrogates, etc.)
|
||||
|
||||
## Tables
|
||||
|
||||
### dogs
|
||||
|
||||
Core registry for all dogs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE dogs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
registration_number TEXT UNIQUE,
|
||||
breed TEXT NOT NULL,
|
||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||
birth_date DATE,
|
||||
color TEXT,
|
||||
microchip TEXT,
|
||||
photo_urls TEXT, -- JSON array
|
||||
notes TEXT,
|
||||
litter_id INTEGER, -- Links to litters table
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
**Important:** NO `sire_id` or `dam_id` columns!
|
||||
|
||||
### parents
|
||||
|
||||
Stores sire/dam relationships.
|
||||
|
||||
```sql
|
||||
CREATE TABLE parents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL, -- The puppy
|
||||
parent_id INTEGER NOT NULL, -- The parent
|
||||
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) -- One sire, one dam per dog
|
||||
);
|
||||
```
|
||||
|
||||
### litters
|
||||
|
||||
Breeding records and litter tracking.
|
||||
|
||||
```sql
|
||||
CREATE TABLE litters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sire_id INTEGER NOT NULL,
|
||||
dam_id INTEGER NOT NULL,
|
||||
breeding_date DATE NOT NULL,
|
||||
whelping_date DATE,
|
||||
puppy_count INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (sire_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dam_id) REFERENCES dogs(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### health_records
|
||||
|
||||
Health tests, vaccinations, exams, treatments.
|
||||
|
||||
```sql
|
||||
CREATE TABLE health_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
record_type TEXT NOT NULL CHECK(record_type IN ('test', 'vaccination', 'exam', 'treatment', 'certification')),
|
||||
test_name TEXT,
|
||||
test_date DATE NOT NULL,
|
||||
result TEXT,
|
||||
document_url TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### heat_cycles
|
||||
|
||||
Female heat cycle tracking for breeding timing.
|
||||
|
||||
```sql
|
||||
CREATE TABLE heat_cycles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
progesterone_peak_date DATE,
|
||||
breeding_date DATE,
|
||||
breeding_successful INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### traits
|
||||
|
||||
Genetic trait tracking and inheritance.
|
||||
|
||||
```sql
|
||||
CREATE TABLE traits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
trait_category TEXT NOT NULL,
|
||||
trait_name TEXT NOT NULL,
|
||||
trait_value TEXT NOT NULL,
|
||||
inherited_from INTEGER, -- Parent dog ID
|
||||
notes TEXT,
|
||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (inherited_from) REFERENCES dogs(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
### Creating a Dog with Parents
|
||||
|
||||
```javascript
|
||||
POST /api/dogs
|
||||
{
|
||||
"name": "Puppy Name",
|
||||
"breed": "Breed Name",
|
||||
"sex": "male",
|
||||
"sire_id": 5, // Parent male dog ID
|
||||
"dam_id": 8, // Parent female dog ID
|
||||
"litter_id": 2 // Optional: link to litter
|
||||
}
|
||||
```
|
||||
|
||||
The API route automatically:
|
||||
1. Inserts the dog into `dogs` table (without sire/dam columns)
|
||||
2. Creates entries in `parents` table linking to sire and dam
|
||||
|
||||
### Querying Parents
|
||||
|
||||
```sql
|
||||
-- Get a dog's parents
|
||||
SELECT p.parent_type, d.*
|
||||
FROM parents p
|
||||
JOIN dogs d ON p.parent_id = d.id
|
||||
WHERE p.dog_id = ?;
|
||||
|
||||
-- Get a dog's offspring
|
||||
SELECT d.*
|
||||
FROM dogs d
|
||||
JOIN parents p ON d.id = p.dog_id
|
||||
WHERE p.parent_id = ?;
|
||||
```
|
||||
|
||||
## Fresh Install
|
||||
|
||||
For a fresh install:
|
||||
|
||||
1. **Delete the old database** (if upgrading):
|
||||
```bash
|
||||
rm data/breedr.db
|
||||
```
|
||||
|
||||
2. **Start the server** - it will create the correct schema automatically:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Verify the schema**:
|
||||
```bash
|
||||
sqlite3 data/breedr.db ".schema dogs"
|
||||
```
|
||||
|
||||
You should see `litter_id` but **NO** `sire_id` or `dam_id` columns.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "no such column: weight" or "no such column: sire_id"
|
||||
|
||||
**Solution:** Your database has an old schema. Delete it and let the app recreate it:
|
||||
|
||||
```bash
|
||||
rm data/breedr.db
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Parent relationships not saving
|
||||
|
||||
Check server logs. You should see:
|
||||
```
|
||||
✓ Dog inserted with ID: 123
|
||||
Adding sire relationship: dog 123 -> sire 5
|
||||
✓ Sire relationship added
|
||||
Adding dam relationship: dog 123 -> dam 8
|
||||
✓ Dam relationship added
|
||||
```
|
||||
|
||||
If relationships aren't being created, check that `sire_id` and `dam_id` are being sent in the API request.
|
||||
|
||||
## Database Files
|
||||
|
||||
- `server/db/init.js` - Creates clean schema, no migrations
|
||||
- `server/routes/dogs.js` - Handles parent relationships via `parents` table
|
||||
- `server/index.js` - Initializes database on startup
|
||||
|
||||
**NO MIGRATIONS!** The init file is the source of truth.
|
||||
101
DEVELOPMENT.md
Normal file
101
DEVELOPMENT.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# DEVELOPMENT.md
|
||||
|
||||
This document provides technical details and guidelines for developing and maintaining the BREEDR Genealogy Management System.
|
||||
|
||||
## Tech Stack Overview
|
||||
|
||||
### Backend
|
||||
- **Node.js & Express**: Core API server.
|
||||
- **better-sqlite3**: High-performance SQLite driver.
|
||||
- **Multer**: Multi-part form data handling for photo uploads.
|
||||
- **Bcrypt & JWT**: (Planned) Authentication and security.
|
||||
|
||||
### Frontend
|
||||
- **React 18 & Vite**: Modern reactive UI with fast HMR.
|
||||
- **React Router 6**: Client-side navigation.
|
||||
- **Lucide React**: Consistent iconography.
|
||||
- **React-D3-Tree & D3.js**: Dynamic pedigree visualization.
|
||||
- **Axios**: Promised-based HTTP client for API communication.
|
||||
|
||||
---
|
||||
|
||||
## Database Architecture
|
||||
|
||||
### SQLite Implementation
|
||||
The database is a single file located at `data/breedr.db`. This directory is automatically created on startup.
|
||||
|
||||
### "Parents Table" Approach
|
||||
Parent relationships are managed in a dedicated `parents` table rather than columns in the `dogs` table.
|
||||
- ** dog_id**: The child dog.
|
||||
- ** parent_id**: The parent dog.
|
||||
- ** parent_type**: 'sire' or 'dam'.
|
||||
|
||||
**Benefits**: Supports recursive lookups, avoids `ALTER TABLE` complexity for lineage changes, and allows historical mapping of ancestors without full profiles.
|
||||
|
||||
### Safe Migrations
|
||||
BREEDR use a migration-free synchronization approach:
|
||||
1. `server/db/init.js` defines the latest table structures.
|
||||
2. Safe `ALTER TABLE` guards inject missing columns on startup.
|
||||
3. This ensures data persistence across updates without manual migration scripts.
|
||||
|
||||
### Key Tables
|
||||
- `dogs`: Registry for kennel and external dogs.
|
||||
- `parents`: Ancestry relationships.
|
||||
- `litters`: Produced breeding groups.
|
||||
- `health_records`: OFA clearances and vet records.
|
||||
- `genetic_tests`: DNA panel results.
|
||||
- `settings`: Kennel-wide configuration (single row).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Documentation
|
||||
|
||||
### Project Structure
|
||||
```text
|
||||
client/src/
|
||||
├── components/ # Reusable UI (PedigreeTree, DogForm, Cards)
|
||||
├── hooks/ # Custom hooks (useSettings)
|
||||
├── pages/ # Route-level components
|
||||
├── App.jsx # Routing & Layout
|
||||
└── index.css # Global styles & Design System
|
||||
```
|
||||
|
||||
### Design System & Styling
|
||||
The UI follows a modern dark-theme aesthetic using **CSS Variables** defined in `index.css`:
|
||||
- `--primary`: Brand color (Warm Amber/Blue).
|
||||
- `--bg-primary`: Deep Slate background.
|
||||
- Glassmorphism effects via `backdrop-filter`.
|
||||
- Responsive grid layouts (`.grid-2`, `.grid-3`).
|
||||
|
||||
### Key Components
|
||||
- **PedigreeTree**: horizontal, D3-powered tree with zoom/pan.
|
||||
- **DogForm**: Dual-mode (Kennel/External) dog entry with parent selection.
|
||||
|
||||
---
|
||||
|
||||
## API & Backend Development
|
||||
|
||||
### Route Modules (`server/routes/`)
|
||||
- `/api/dogs`: Dog registry and photo uploads.
|
||||
- `/api/litters`: Litter management and puppy linking.
|
||||
- `/api/pedigree`: Recursive ancestry/descendant tree generation.
|
||||
- `/api/breeding`: Heat cycle tracking and whelping projections.
|
||||
|
||||
### Environment Variables
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `DB_PATH` | Path to .db file | `../data/breedr.db` |
|
||||
| `UPLOAD_PATH`| Path to photo storage| `../uploads` |
|
||||
|
||||
---
|
||||
|
||||
## Technical History & Design Logs
|
||||
|
||||
For deeper technical dives into specific features, refer to the `docs/` directory:
|
||||
- [UI Redesign & Color System](docs/UI_REDESIGN.md)
|
||||
- [Compact Card Layout Design](docs/COMPACT_CARDS.md)
|
||||
- [Microchip Field Unique Constraint Fix](docs/MICROCHIP_FIX.md)
|
||||
|
||||
---
|
||||
*Last Updated: March 12, 2026*
|
||||
10
Dockerfile
10
Dockerfile
@@ -37,14 +37,14 @@ RUN npm install --omit=dev
|
||||
# Copy server code
|
||||
COPY server/ ./server/
|
||||
|
||||
# Copy static assets (branding, etc.) to ensure default logo is present
|
||||
COPY static/ ./static/
|
||||
|
||||
# Copy built frontend from previous stage
|
||||
COPY --from=frontend-builder /app/client/dist ./client/dist
|
||||
|
||||
# Create necessary directories (including static for branding assets)
|
||||
RUN mkdir -p /app/data /app/uploads /app/static
|
||||
|
||||
# Initialize database schema on build
|
||||
RUN node server/db/init.js || true
|
||||
# Create data and uploads directories
|
||||
RUN mkdir -p /app/data /app/uploads
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
|
||||
340
QUICKSTART.md
340
QUICKSTART.md
@@ -1,340 +0,0 @@
|
||||
# BREEDR Quick Start Guide
|
||||
## Litter Management & Pedigree Visualization
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Pull the Feature Branch
|
||||
```bash
|
||||
git checkout feature/litter-management-and-pedigree
|
||||
```
|
||||
|
||||
### 2. Run Database Migration
|
||||
```bash
|
||||
node server/db/migrate_litter_id.js
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Running litter_id migration...
|
||||
✓ Added litter_id column to dogs table
|
||||
✓ Created index on litter_id
|
||||
Migration completed successfully!
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 4. Start the Application
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3000` and the client on `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Litter Management
|
||||
|
||||
### Creating Your First Litter
|
||||
|
||||
1. **Navigate to Litters**
|
||||
- Click "Litters" in the navigation menu
|
||||
- Click "Add New Litter" button
|
||||
|
||||
2. **Fill in Litter Details**
|
||||
- **Sire (Father)**: Select from dropdown of male dogs
|
||||
- **Dam (Mother)**: Select from dropdown of female dogs
|
||||
- **Breeding Date**: Date of breeding (required)
|
||||
- **Whelping Date**: Expected/actual birth date (optional)
|
||||
- **Expected Puppy Count**: Estimated number of puppies
|
||||
- **Notes**: Any additional breeding information
|
||||
|
||||
3. **Save the Litter**
|
||||
- Click "Create Litter"
|
||||
- Litter appears in the list with format: "Sire x Dam - Date"
|
||||
|
||||
### Adding Puppies to a Litter
|
||||
|
||||
#### Method 1: Link to Existing Litter (Recommended)
|
||||
|
||||
1. **Click "Add New Dog"**
|
||||
2. **Enter Puppy Details**
|
||||
- Name (required)
|
||||
- Breed (required)
|
||||
- Sex (required)
|
||||
- Birth Date
|
||||
- Color
|
||||
- Microchip
|
||||
|
||||
3. **Select Parent Method**
|
||||
- Choose "Link to Litter" radio button
|
||||
- Select the litter from dropdown
|
||||
- Parents are automatically filled!
|
||||
|
||||
4. **Save**
|
||||
- Click "Add Dog"
|
||||
- Puppy is now linked to the litter
|
||||
- Parent relationships are automatically created
|
||||
|
||||
#### Method 2: Manual Parent Selection
|
||||
|
||||
1. **Click "Add New Dog"**
|
||||
2. **Enter Puppy Details**
|
||||
3. **Select Parent Method**
|
||||
- Choose "Manual Parent Selection" radio button
|
||||
- Select Sire from male dogs dropdown
|
||||
- Select Dam from female dogs dropdown
|
||||
|
||||
4. **Save**
|
||||
- Puppy is created with selected parents
|
||||
- No litter association
|
||||
|
||||
### Viewing Litter Details
|
||||
|
||||
1. **Click on a Litter** in the list
|
||||
2. **See Litter Information:**
|
||||
- Sire and Dam details
|
||||
- Breeding and whelping dates
|
||||
- List of all puppies in the litter
|
||||
- Actual puppy count vs expected
|
||||
|
||||
### Editing a Litter
|
||||
|
||||
1. Click "Edit" on the litter
|
||||
2. Update breeding/whelping dates
|
||||
3. Modify notes
|
||||
4. **Note:** Cannot change sire/dam after creation
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Interactive Pedigree Tree
|
||||
|
||||
### Viewing a Pedigree
|
||||
|
||||
1. **From Dog List:**
|
||||
- Click on any dog
|
||||
- Click "View Pedigree" button
|
||||
|
||||
2. **Pedigree Opens in Modal**
|
||||
- Shows dog's ancestry tree
|
||||
- 5 generations displayed
|
||||
- Color-coded by sex:
|
||||
- Blue nodes = Males ♂
|
||||
- Pink nodes = Females ♀
|
||||
|
||||
### Navigating the Tree
|
||||
|
||||
#### Zoom Controls
|
||||
- **Zoom In**: Click "+" button or mouse wheel up
|
||||
- **Zoom Out**: Click "-" button or mouse wheel down
|
||||
- **Reset View**: Click reset button to center tree
|
||||
|
||||
#### Panning
|
||||
- **Click and Drag**: Move the tree around
|
||||
- **Mouse Wheel**: Zoom in/out
|
||||
|
||||
#### Node Information
|
||||
Each node displays:
|
||||
- Dog name (large text)
|
||||
- Registration number
|
||||
- Birth year
|
||||
- Sex symbol (♂ or ♀)
|
||||
|
||||
### Reading the Tree
|
||||
|
||||
```
|
||||
Great-Great-Grandpa ♂
|
||||
Great-Grandpa ♂
|
||||
Great-Great-Grandma ♀
|
||||
Grandpa ♂
|
||||
Great-Great-Grandpa ♂
|
||||
Great-Grandma ♀
|
||||
Great-Great-Grandma ♀
|
||||
Sire ♂
|
||||
Great-Great-Grandpa ♂
|
||||
Great-Grandpa ♂
|
||||
Great-Great-Grandma ♀
|
||||
Grandma ♀
|
||||
Great-Great-Grandpa ♂
|
||||
Great-Grandma ♀
|
||||
Great-Great-Grandma ♀
|
||||
Dog Name
|
||||
Dam ♀
|
||||
[... similar structure for dam's side]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Workflow 1: Breeding a Litter
|
||||
|
||||
1. ✓ Select breeding pair (sire and dam)
|
||||
2. ✓ Create litter record with breeding date
|
||||
3. ✓ Track whelping date when puppies are born
|
||||
4. ✓ Add each puppy:
|
||||
- Link to the litter
|
||||
- Enter individual details
|
||||
- Assign registration numbers
|
||||
5. ✓ View pedigree of any puppy to see full ancestry
|
||||
|
||||
### Workflow 2: Recording Historical Dogs
|
||||
|
||||
1. ✓ Add foundation dogs (no parents)
|
||||
2. ✓ Add their offspring using manual parent selection
|
||||
3. ✓ Continue building the family tree
|
||||
4. ✓ View pedigrees to verify relationships
|
||||
|
||||
### Workflow 3: Planning a Breeding
|
||||
|
||||
1. ✓ View pedigrees of potential sire and dam
|
||||
2. ✓ Check for common ancestors
|
||||
3. ✓ Use trial pairing tool (coming soon)
|
||||
4. ✓ Create litter when breeding occurs
|
||||
|
||||
---
|
||||
|
||||
## Tips & Best Practices
|
||||
|
||||
### For Litter Management
|
||||
|
||||
✅ **Do:**
|
||||
- Create the litter record BEFORE adding puppies
|
||||
- Enter accurate breeding dates for record keeping
|
||||
- Use meaningful notes (progesterone timing, heat cycle info)
|
||||
- Link puppies to litters for automatic parent relationships
|
||||
|
||||
❌ **Don't:**
|
||||
- Don't change sire/dam after litter creation (create new litter instead)
|
||||
- Don't forget to update whelping date when puppies arrive
|
||||
- Avoid mixing litter-linked and manually-parented puppies
|
||||
|
||||
### For Pedigree Viewing
|
||||
|
||||
✅ **Do:**
|
||||
- Zoom out to see the full tree at once
|
||||
- Use drag to focus on specific branches
|
||||
- Click nodes to see additional details
|
||||
- Reset view if you get lost
|
||||
|
||||
❌ **Don't:**
|
||||
- Don't try to edit from pedigree view (use dog edit form)
|
||||
- Avoid excessive zooming (can make nodes too small)
|
||||
|
||||
### Data Entry Tips
|
||||
|
||||
1. **Registration Numbers**: Enter consistently (e.g., "AKC-12345")
|
||||
2. **Microchips**: Use full 15-digit number
|
||||
3. **Birth Dates**: Critical for age calculations and sorting
|
||||
4. **Breed Names**: Keep consistent spelling and capitalization
|
||||
5. **Colors**: Use standard color terminology for your breed
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No such column: sire" Error
|
||||
|
||||
**Problem:** Getting this error when adding a dog
|
||||
|
||||
**Solution:**
|
||||
1. Make sure you ran the migration:
|
||||
```bash
|
||||
node server/db/migrate_litter_id.js
|
||||
```
|
||||
2. Restart the server
|
||||
3. Try again
|
||||
|
||||
### Pedigree Tree Not Loading
|
||||
|
||||
**Problem:** Pedigree modal shows "Loading..." forever
|
||||
|
||||
**Possible Causes:**
|
||||
- Dog has no parents recorded
|
||||
- Network issue
|
||||
- Server not running
|
||||
|
||||
**Solution:**
|
||||
1. Check browser console for errors
|
||||
2. Verify server is running
|
||||
3. Ensure dog has at least one parent recorded
|
||||
|
||||
### Parents Not Auto-Populating
|
||||
|
||||
**Problem:** Selected a litter but parents didn't fill in
|
||||
|
||||
**Solution:**
|
||||
1. Refresh the page
|
||||
2. Make sure litter has valid sire and dam
|
||||
3. Try selecting the litter again
|
||||
|
||||
### Can't See All Generations
|
||||
|
||||
**Problem:** Pedigree tree only shows 2-3 generations
|
||||
|
||||
**This is normal if:**
|
||||
- Older generations don't have parents recorded
|
||||
- Foundation dogs have no ancestry
|
||||
- You need to add more historical data
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
*Coming in future release*
|
||||
|
||||
- `Ctrl/Cmd + N` - New Dog
|
||||
- `Ctrl/Cmd + L` - New Litter
|
||||
- `Ctrl/Cmd + P` - View Pedigree
|
||||
- `Esc` - Close Modal
|
||||
|
||||
---
|
||||
|
||||
## Next Features Coming Soon
|
||||
|
||||
🔜 **Trial Pairing Simulator**
|
||||
- Calculate COI before breeding
|
||||
- See common ancestors
|
||||
- Risk assessment
|
||||
|
||||
🔜 **Heat Cycle Tracking**
|
||||
- Track progesterone levels
|
||||
- Breeding date recommendations
|
||||
- Calendar view
|
||||
|
||||
🔜 **PDF Pedigree Export**
|
||||
- Print-ready pedigrees
|
||||
- Custom formatting
|
||||
- Multiple generations
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Documentation:** [FEATURE_IMPLEMENTATION.md](./FEATURE_IMPLEMENTATION.md)
|
||||
- **Roadmap:** [ROADMAP.md](./ROADMAP.md)
|
||||
- **Installation:** [INSTALL.md](./INSTALL.md)
|
||||
- **README:** [README.md](./README.md)
|
||||
|
||||
---
|
||||
|
||||
## Video Tutorials
|
||||
|
||||
*Coming soon - check back for video walkthroughs of these features!*
|
||||
|
||||
1. Creating Your First Litter
|
||||
2. Adding Puppies to a Litter
|
||||
3. Navigating Pedigree Trees
|
||||
4. Advanced Breeding Records
|
||||
|
||||
---
|
||||
|
||||
## Congratulations!
|
||||
|
||||
You're now ready to use BREEDR's litter management and pedigree visualization features. Start by creating a litter or viewing a pedigree tree!
|
||||
|
||||
**Happy Breeding! 🐶**
|
||||
375
README.md
375
README.md
@@ -2,347 +2,86 @@
|
||||
|
||||
A reactive, interactive dog breeding genealogy mapping system for professional kennel management.
|
||||
|
||||
## ✅ Current Features
|
||||
---
|
||||
|
||||
### Core Functionality
|
||||
- **✅ Dog Registry** - Complete CRUD operations with comprehensive profiles
|
||||
- **✅ Photo Management** - Multiple photos per dog with upload/delete capabilities
|
||||
- **✅ Parent Relationships** - Clean database design using `parents` table (no sire/dam columns in dogs table)
|
||||
- **✅ Litter Management** - Track breeding records, link puppies to litters
|
||||
- **✅ Interactive Pedigree Visualization** - Multi-generational family trees with zoom/pan
|
||||
- **✅ Modern UI** - Sleek, dark-themed interface with compact info cards
|
||||
- **✅ Search & Filter** - Find dogs by name, breed, sex, and more
|
||||
- **✅ Branded Navigation** - Custom logo (br-logo.png) with gold-to-rusty-red gradient title
|
||||
- **✅ Trial Pairing Simulator** - COI calculator with common ancestors table and risk badge
|
||||
- **✅ Heat Cycle Calendar** - Month grid calendar with cycle windows, breeding date suggestions, and **projected whelping identifiers**
|
||||
- **✅ Champion Bloodline Tracking** - Mark dogs as titled champions; offspring display a Champion Bloodline badge
|
||||
- **✅ Kennel Settings** - Configurable kennel name, tagline, address, AKC ID, breed, owner info
|
||||
- **✅ UI Theme** - CSS custom property theming with `--champion-gold` and dark-mode variables
|
||||
## 🌟 Recent Highlights (v0.8.0)
|
||||
- **✅ Reverse Pedigree** — Toggle between ancestors and descendants view for full lineage tracking.
|
||||
- **✅ External Dog Mapping** — Assign parents to external dogs, allowing for full genealogy of outside lines.
|
||||
- **✅ Universal Parent Selection** — Select any dog (kennel or external) as a sire/dam from any profile.
|
||||
|
||||
### Database Architecture
|
||||
- **✅ Clean Schema** - No migrations needed; fresh installs create correct structure; existing DBs auto-migrate via safe `ALTER TABLE` guards
|
||||
- **✅ Normalized Design** - `parents` table for relationships (sire/dam)
|
||||
- **✅ Litter Linking** - Dogs linked to litters via `litter_id`
|
||||
- **✅ Health Records** - Medical history and genetic testing
|
||||
- **✅ Heat Cycles** - Breeding cycle tracking
|
||||
- **✅ Genetic Traits** - Inherited trait mapping
|
||||
- **✅ Settings Table** - Single-row kennel configuration with all contact/identity fields
|
||||
---
|
||||
|
||||
### Recently 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 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
|
||||
- **✅ SettingsProvider / useSettings** — React context hook renamed `useSettings.jsx` (was `.js`; contained JSX causing Vite build failure)
|
||||
- **✅ `server/index.js` Fix** — `initDatabase()` called with no args to match updated `db/init.js`; removed duplicate `/api/health` route
|
||||
- **✅ `settings.js` Route Fix** — rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
|
||||
|
||||
### Previously Added (March 9, 2026 — v0.5.1)
|
||||
- **✅ Projected Whelp Window on Calendar** - Indigo/purple day cells (days 58–65 from breeding date) visible directly on the month grid
|
||||
- **✅ Expected Whelp Day Marker** - Indigo dot on the exact expected whelp day (day 63) alongside the green breeding dot
|
||||
- **✅ "[Name] due" Cell Label** - Baby 🍼 icon + dog name label inside the whelp day cell
|
||||
- **✅ Active Cycle Card — Whelp Range** - "Whelp est. [date]" row with earliest–latest range shown on each active cycle card
|
||||
- **✅ Jump-to-Whelp-Month Button** - One-click navigation to the whelp month when it differs from current view
|
||||
- **✅ Live Whelp Preview in Modal** - Instant client-side earliest/expected/latest preview as soon as a breeding date is entered (no save required)
|
||||
- **✅ Whelping Banner** - Full-width indigo banner listing dogs with projected whelps when no active heat cycles are visible
|
||||
- **✅ Legend Entry** - "Projected Whelp" added to calendar legend
|
||||
- **✅ Updated Page Subtitle** - Now reads: *"Track heat cycles, optimal breeding windows, and projected whelping dates"*
|
||||
|
||||
### Previously Added (March 9, 2026 — v0.5.0)
|
||||
- **✅ Heat Cycle Calendar** - Full month grid with color-coded cycle windows (Proestrus / Optimal / Late Estrus / Diestrus)
|
||||
- **✅ Start Cycle Modal** - Click any day or the header button to log a new heat cycle for a female
|
||||
- **✅ Breeding Date Suggestions** - Phase windows with date ranges loaded from `GET /api/breeding/heat-cycles/:id/suggestions`
|
||||
- **✅ Whelping Estimate** - Auto-calculates earliest/expected/latest whelping once a breeding date is logged
|
||||
- **✅ Trial Pairing Simulator** - `/pairing` route with sire/dam dropdowns, COI%, risk badge, and common ancestors table
|
||||
- **✅ Pairing Nav Link** - `FlaskConical` icon added to navbar
|
||||
- **✅ New API Endpoints** - `GET /api/breeding/heat-cycles`, `GET /api/breeding/heat-cycles/:id/suggestions`
|
||||
|
||||
### Previously Added (March 9, 2026 — v0.4.x)
|
||||
- **✅ Brand Logo** - Custom `br-logo.png` in navbar replacing generic icon
|
||||
- **✅ Gradient Title** - Gold-to-rusty-red gradient on "BREEDR" brand text
|
||||
- **✅ Static Asset Serving** - `/static` directory served by Express for branding assets
|
||||
- **✅ Dev Proxy** - Vite dev server proxies `/static` to Express backend
|
||||
- **✅ Route Fix** - `/static` and `/uploads` paths no longer fall through to React catch-all
|
||||
- **✅ Logo Sizing** - Fixed brand logo to 1:1 aspect ratio square
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: React 18 with modern component design
|
||||
- **Visualization**: React-D3-Tree for pedigree charts
|
||||
- **Backend**: Node.js/Express API
|
||||
- **Database**: SQLite (embedded, zero-config) with clean normalized schema + safe `ALTER TABLE` migration guards
|
||||
- **Container**: Single Docker image with multi-stage build
|
||||
- **Styling**: CSS custom properties with dark theme + `--champion-gold` + gradient branding
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker Deployment (Recommended)
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Docker Deployment (Recommended)
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.alwisp.com/jason/breedr.git
|
||||
cd breedr
|
||||
|
||||
# Build Docker image
|
||||
docker build -t breedr:latest .
|
||||
|
||||
# Run with docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access at: `http://localhost:3000`
|
||||
|
||||
### Upgrading an Existing Installation
|
||||
|
||||
The database now uses safe `ALTER TABLE` guards — **you do not need to delete your database to upgrade**. Just pull and rebuild:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
git pull origin master
|
||||
docker-compose up -d --build
|
||||
```
|
||||
Access at: `http://localhost:3000`
|
||||
|
||||
New columns (`is_champion`, all `settings` kennel fields) are added automatically on first boot. Your existing dog data is preserved.
|
||||
|
||||
### Fresh Install Database Setup
|
||||
|
||||
For a **fresh install**, the database will automatically initialize with the correct schema and seed a default settings row.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **No sire/dam columns in `dogs` table** - Parent relationships stored in `parents` table
|
||||
2. **Normalized structure** - Reduces redundancy, improves data integrity
|
||||
3. **Litter linking** - Dogs reference litters via `litter_id` foreign key
|
||||
4. **Safe migrations** - `ALTER TABLE ... ADD COLUMN` guards allow zero-downtime upgrades
|
||||
|
||||
### Core Tables
|
||||
|
||||
- **dogs** - Core dog registry; includes `is_champion`, `litter_id`, `photo_urls`
|
||||
- **parents** - Sire/dam relationships (dog_id, parent_id, parent_type)
|
||||
- **litters** - Breeding records with sire/dam references
|
||||
- **health_records** - Medical and genetic testing
|
||||
- **heat_cycles** - Breeding cycle tracking
|
||||
- **traits** - Genetic trait mapping
|
||||
- **settings** - Single-row kennel configuration (kennel_name, tagline, address, phone, email, website, akc_id, breed, owner_name)
|
||||
|
||||
**Full schema documentation:** [DATABASE.md](DATABASE.md)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `NODE_ENV` - production/development (default: production)
|
||||
- `PORT` - Server port (default: 3000)
|
||||
- `DATA_DIR` - Data directory for SQLite file (default: /app/data)
|
||||
- `UPLOAD_PATH` - Upload directory (default: /app/uploads)
|
||||
- `STATIC_PATH` - Static assets directory (default: /app/static)
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
### 2. Manual Development Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server (frontend + backend)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
> **Note:** The database initializes automatically on first boot. No manual migrations are required.
|
||||
|
||||
### Project Structure
|
||||
---
|
||||
|
||||
## 🐕 Managing Your Kennel
|
||||
|
||||
- **Adding Dogs**: Go to the **Dogs** page, click **Add New Dog**. You can mark dogs as **External** if they aren't in your kennel but are needed for pedigree mapping.
|
||||
- **Champion Tracking**: Toggle the **Champion** status to title dogs. Offspring will automatically display the "Champion Bloodline" badge.
|
||||
- **Photo Management**: Multiple high-quality photos per dog with a compact gallery view.
|
||||
- **Litter Tracking**: Link puppies to breeding records automatically to track weight and health from birth.
|
||||
|
||||
## 🧬 Breeding & Genetics
|
||||
|
||||
- **Interactive Pedigree**: 5-generation trees with zoom/pan. Toggle the **Reverse Pedigree** switch to see descendant lineage.
|
||||
- **Trial Pairing Simulator**: Calculate Wright's Inbreeding Coefficient (COI) instantly. Identifies common ancestors and providing risk badges (Low/Moderate/High).
|
||||
- **Heat Cycles**: Track female cycles on the calendar. Includes **projected whelping alerts** (indigo windows) and expected due dates.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
- **Frontend**: React 18, Vite, Lucide Icons
|
||||
- **Visualization**: React-D3-Tree, D3.js
|
||||
- **Backend**: Node.js, Express.js
|
||||
- **Database**: SQLite (Zero-config, safe `ALTER TABLE` migrations)
|
||||
- **Deployment**: Multi-stage Docker
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
```
|
||||
breedr/
|
||||
├── client/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── BreedingCalendar.jsx # Heat cycle calendar + whelping identifiers
|
||||
│ │ │ ├── PairingSimulator.jsx # Trial pairing + COI
|
||||
│ │ │ ├── SettingsPage.jsx # Kennel settings form
|
||||
│ │ │ ├── Dashboard.jsx
|
||||
│ │ │ ├── DogList.jsx
|
||||
│ │ │ ├── DogDetail.jsx
|
||||
│ │ │ ├── PedigreeView.jsx
|
||||
│ │ │ └── LitterList.jsx
|
||||
│ │ ├── components/
|
||||
│ │ │ └── DogForm.jsx # Champion toggle + parent selects
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useSettings.jsx # SettingsProvider + useSettings context
|
||||
│ │ └── App.jsx
|
||||
│ └── package.json
|
||||
├── 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
|
||||
│ │ ├── litters.js
|
||||
│ │ └── health.js
|
||||
│ ├── db/
|
||||
│ │ └── init.js # Schema + ALTER TABLE migration guards
|
||||
│ └── index.js
|
||||
├── static/ # Branding assets (br-logo.png, etc.)
|
||||
├── docs/ # Documentation
|
||||
├── ROADMAP.md
|
||||
├── DATABASE.md
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
├── client/ # React frontend (Pages: Pedigree, Pairing, Calendar, Settings)
|
||||
├── server/ # Node.js backend (Routes: Dogs, Pedigree, Breeding, Settings)
|
||||
├── static/ # Branded assets (logos, etc.)
|
||||
├── data/ # SQLite database storage (mapped in Docker)
|
||||
├── uploads/ # Dog photo storage (mapped in Docker)
|
||||
└── docs/ # Technical documentation and design history
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
---
|
||||
|
||||
### Dogs
|
||||
- `GET/POST /api/dogs` - Dog CRUD operations
|
||||
- `GET /api/dogs/:id` - Get dog with parents (incl. is_champion), offspring, and health summary
|
||||
- `PUT /api/dogs/:id` - Update dog (incl. is_champion)
|
||||
- `POST /api/dogs/:id/photos` - Upload photos
|
||||
## 🕒 Release Summary
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get kennel settings
|
||||
- `PUT /api/settings` - Update kennel settings (partial update supported)
|
||||
- **v0.8.0** (Mar 2026): Reverse Pedigree & External dog parentage.
|
||||
- **v0.7.0** (In Progress): Health & Genetics (OFA clearances, DNA panels).
|
||||
- **v0.6.1**: COI calculation fix for direct parent×offspring relations.
|
||||
- **v0.6.0**: Champion status tracking & Kennel settings API.
|
||||
|
||||
### Pedigree & Genetics
|
||||
- `GET /api/pedigree/:id` - Generate pedigree tree
|
||||
- `POST /api/pedigree/trial-pairing` - COI + common ancestors + risk recommendation
|
||||
---
|
||||
|
||||
### Breeding & Heat Cycles
|
||||
- `GET /api/breeding/heat-cycles` - All heat cycles
|
||||
- `GET /api/breeding/heat-cycles/active` - Active cycles with dog info
|
||||
- `GET /api/breeding/heat-cycles/dog/:dogId` - Cycles for a specific dog
|
||||
- `GET /api/breeding/heat-cycles/:id/suggestions` - Breeding windows + whelping estimate
|
||||
- `POST /api/breeding/heat-cycles` - Create new heat cycle
|
||||
- `PUT /api/breeding/heat-cycles/:id` - Update cycle (log breeding date, etc.)
|
||||
- `DELETE /api/breeding/heat-cycles/:id` - Delete cycle
|
||||
- `GET /api/breeding/whelping-calculator` - Standalone whelping date calculator
|
||||
## ❓ Troubleshooting
|
||||
- **COI shows 0.00%?**: Ensure both parents are mapped and have shared ancestors.
|
||||
- **Missing Columns?**: Restart the server; auto-init guards add columns automatically.
|
||||
- **Logo not appearing?**: Place `br-logo.png` in the `static/` directory.
|
||||
|
||||
### Litters
|
||||
- `GET/POST /api/litters` - Litter management
|
||||
---
|
||||
|
||||
### Assets
|
||||
- `GET /static/*` - Branding and static assets
|
||||
- `GET /uploads/*` - Dog photos
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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.
|
||||
|
||||
### "no such column: kennel_name" or "no such column: is_champion"
|
||||
Your database predates the `ALTER TABLE` migration guards. Pull the latest code and restart — columns are added automatically. No data loss.
|
||||
|
||||
### "no such column: weight" or "no such column: sire_id"
|
||||
Your database has a very old schema. Delete and recreate:
|
||||
```bash
|
||||
cp data/breedr.db data/breedr.db.backup
|
||||
rm data/breedr.db
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Logo not appearing in navbar
|
||||
Ensure `br-logo.png` is placed in the `static/` directory at the project root. The file is served at `/static/br-logo.png`.
|
||||
|
||||
### Heat cycles not showing on calendar
|
||||
Ensure dogs are registered with `sex: 'female'` before creating heat cycles. The API validates this and will return a 400 error for male dogs.
|
||||
|
||||
### Whelping window not appearing on calendar
|
||||
A breeding date must be logged on the cycle for whelp window cells to appear. Use the Cycle Detail modal → "Log Breeding Date" field.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Docker containerization
|
||||
- [x] SQLite database with clean schema + ALTER TABLE migration guards
|
||||
- [x] Dog management (CRUD) with champion flag
|
||||
- [x] Photo management
|
||||
- [x] Interactive pedigree visualization
|
||||
- [x] Litter management
|
||||
- [x] Parent-child relationships via parents table
|
||||
- [x] Modern UI redesign
|
||||
- [x] Search and filtering
|
||||
- [x] Custom brand logo + gradient title
|
||||
- [x] Static asset serving
|
||||
- [x] Trial Pairing Simulator (COI + common ancestors + risk badge)
|
||||
- [x] Heat Cycle Calendar (month grid + windows + breeding suggestions + whelping estimate)
|
||||
- [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)
|
||||
|
||||
### 🔜 In Progress / Up Next
|
||||
- [ ] Health Records System
|
||||
- [ ] Genetic trait tracking
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] PDF pedigree generation
|
||||
- [ ] Advanced search and filters
|
||||
- [ ] Export capabilities
|
||||
- [ ] Progesterone tracking (extended feature)
|
||||
|
||||
**Full roadmap:** [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
## Recent Updates
|
||||
|
||||
### 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:** 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
|
||||
- **Added:** `SettingsProvider` / `useSettings` React context for kennel name in navbar
|
||||
- **Fixed:** `useSettings.js` → `useSettings.jsx` (Vite build failure — JSX in `.js` file)
|
||||
- **Fixed:** `server/index.js` — `initDatabase()` called with no args; removed duplicate `/api/health` route
|
||||
- **Fixed:** `server/routes/settings.js` — rewrote from double-encoded base64 + old key/value schema
|
||||
- **Fixed:** `DB_PATH` arg removed from `initDatabase()` call; `DATA_DIR` env var now controls directory
|
||||
|
||||
### March 9, 2026 - Projected Whelping Calendar Identifier (v0.5.1)
|
||||
- **Added:** Indigo whelp window (days 58–65) on calendar grid cells when a breeding date is logged
|
||||
- **Added:** Indigo dot marker on exact expected whelp day (day 63)
|
||||
- **Added:** `Baby` icon + "[Name] due" label inside whelp day cells
|
||||
- **Added:** "Whelp est. [date]" row with earliest–latest range on active cycle cards
|
||||
- **Added:** Jump-to-whelp-month button on active cycle cards
|
||||
- **Added:** Live whelp preview in Cycle Detail modal (client-side, instant, no save required)
|
||||
- **Added:** Full-width whelping banner when projected whelps exist but no active heat cycles are visible
|
||||
- **Added:** "Projected Whelp" legend entry with Baby icon
|
||||
- **Updated:** Page subtitle to include projected whelping dates
|
||||
|
||||
### March 9, 2026 - Heat Cycle Calendar & Trial Pairing Simulator (v0.5.0)
|
||||
- **Added:** Full month grid heat cycle calendar with color-coded phase windows
|
||||
- **Added:** Start Heat Cycle modal (click any day or header button)
|
||||
- **Added:** Cycle Detail modal with breeding window breakdown and inline breeding date logging
|
||||
- **Added:** Whelping estimate (earliest/expected/latest) auto-calculated from breeding date
|
||||
- **Added:** Trial Pairing Simulator at `/pairing` with COI%, risk badge, common ancestors table
|
||||
- **Added:** `GET /api/breeding/heat-cycles` and `GET /api/breeding/heat-cycles/:id/suggestions` endpoints
|
||||
- **Moved:** Progesterone tracking to extended roadmap
|
||||
|
||||
### March 9, 2026 - Branding & Header Improvements (v0.4.1)
|
||||
- **Added:** Custom `br-logo.png` brand logo in navbar
|
||||
- **Added:** Gold-to-rusty-red gradient on "BREEDR" title text
|
||||
- **Added:** `/static` directory for branding assets served by Express
|
||||
- **Fixed:** Vite dev proxy for `/static` routes
|
||||
- **Fixed:** `/static` and `/uploads` paths no longer fall through to React router
|
||||
- **Fixed:** Brand logo sized as fixed 1:1 square for proper aspect ratio
|
||||
|
||||
## Documentation
|
||||
|
||||
- [DATABASE.md](DATABASE.md) - Complete schema documentation
|
||||
- [ROADMAP.md](ROADMAP.md) - Development roadmap and features
|
||||
- [INSTALL.md](INSTALL.md) - Detailed installation instructions
|
||||
- [QUICKSTART.md](QUICKSTART.md) - Quick setup guide
|
||||
|
||||
## License
|
||||
|
||||
Private use only - All rights reserved
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check documentation in `docs/` folder
|
||||
- Review DATABASE.md for schema questions
|
||||
- Check container logs: `docker logs breedr`
|
||||
- Contact the system administrator
|
||||
**Full Documentation**:
|
||||
[Installation Guide](INSTALL.md) | [Development & Architecture](DEVELOPMENT.md) | [API Reference](API.md) | [Roadmap](ROADMAP.md)
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
# BREEDR v0.4.0 Release Notes
|
||||
|
||||
**Release Date:** March 9, 2026
|
||||
**Branch:** `docs/clean-schema-and-roadmap-update`
|
||||
**Focus:** Clean Database Schema & Documentation Overhaul
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New
|
||||
|
||||
### Clean Database Architecture
|
||||
|
||||
We've completely overhauled the database design for simplicity and correctness:
|
||||
|
||||
- **✅ NO MORE MIGRATIONS** - Fresh init creates correct schema automatically
|
||||
- **✅ Removed weight/height columns** - Never implemented, now gone
|
||||
- **✅ Added litter_id column** - Proper linking of puppies to litters
|
||||
- **✅ Parents table approach** - NO sire/dam columns in dogs table
|
||||
- **✅ Normalized relationships** - Sire/dam stored in separate parents table
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The old schema had:
|
||||
- Migration scripts trying to fix schema issues
|
||||
- `sire_id` and `dam_id` columns causing "no such column" errors
|
||||
- Complex migration logic that could fail
|
||||
|
||||
The new schema:
|
||||
- ✅ Clean initialization - always correct
|
||||
- ✅ Normalized design - proper relationships
|
||||
- ✅ Simple maintenance - no migration tracking
|
||||
- ✅ Better logging - see exactly what's happening
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technical Changes
|
||||
|
||||
### Database
|
||||
|
||||
**Removed:**
|
||||
- `dogs.weight` column (never implemented)
|
||||
- `dogs.height` column (never implemented)
|
||||
- `dogs.sire_id` column (moved to parents table)
|
||||
- `dogs.dam_id` column (moved to parents table)
|
||||
- `server/db/migrations.js` (no more migrations)
|
||||
|
||||
**Added:**
|
||||
- `dogs.litter_id` column with foreign key to litters
|
||||
- `parents` table for sire/dam relationships
|
||||
- Clean `server/db/init.js` as single source of truth
|
||||
|
||||
### API Changes
|
||||
|
||||
**server/routes/dogs.js:**
|
||||
- Fixed parent handling - properly uses parents table
|
||||
- Added detailed logging for relationship creation
|
||||
- Removed schema detection logic
|
||||
- Cleaner error messages
|
||||
|
||||
**server/index.js:**
|
||||
- Removed migrations import and execution
|
||||
- Simplified startup - just calls initDatabase()
|
||||
- Better console output with status indicators
|
||||
|
||||
### Documentation
|
||||
|
||||
**New Files:**
|
||||
- `DATABASE.md` - Complete schema reference
|
||||
- `CLEANUP_NOTES.md` - Lists outdated files to remove
|
||||
- `RELEASE_NOTES_v0.4.0.md` - This file
|
||||
|
||||
**Updated Files:**
|
||||
- `README.md` - Current features and setup instructions
|
||||
- `ROADMAP.md` - Accurate progress tracking and version history
|
||||
|
||||
**Outdated Files (Manual Deletion Required):**
|
||||
- `DATABASE_MIGRATIONS.md`
|
||||
- `DEPLOY_NOW.md`
|
||||
- `FEATURE_IMPLEMENTATION.md`
|
||||
- `FRONTEND_FIX_REQUIRED.md`
|
||||
- `IMPLEMENTATION_PLAN.md`
|
||||
- `SPRINT1_PEDIGREE_COMPLETE.md`
|
||||
- `migrate-now.sh`
|
||||
|
||||
See `CLEANUP_NOTES.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Upgrade Instructions
|
||||
|
||||
### For Fresh Installs
|
||||
|
||||
No action needed! The database will initialize correctly:
|
||||
|
||||
```bash
|
||||
git clone https://git.alwisp.com/jason/breedr.git
|
||||
cd breedr
|
||||
git checkout docs/clean-schema-and-roadmap-update
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### For Existing Installations
|
||||
|
||||
**Important:** This update requires starting with a fresh database.
|
||||
|
||||
1. **Backup your data:**
|
||||
```bash
|
||||
cp data/breedr.db data/breedr.db.backup
|
||||
```
|
||||
|
||||
2. **Stop the application:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
3. **Delete old database:**
|
||||
```bash
|
||||
rm data/breedr.db
|
||||
```
|
||||
|
||||
4. **Pull latest code:**
|
||||
```bash
|
||||
git pull origin docs/clean-schema-and-roadmap-update
|
||||
```
|
||||
|
||||
5. **Rebuild and restart:**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
6. **Verify database:**
|
||||
```bash
|
||||
docker exec -it breedr sqlite3 /app/data/breedr.db ".schema dogs"
|
||||
```
|
||||
|
||||
You should see `litter_id` but **NO** `sire_id`, `dam_id`, `weight`, or `height` columns.
|
||||
|
||||
### Data Migration Notes
|
||||
|
||||
**Parent Relationships:**
|
||||
- Cannot be automatically migrated due to schema change
|
||||
- You'll need to re-enter sire/dam relationships for existing dogs
|
||||
- Use the dog edit form or litter linking feature
|
||||
|
||||
**All Other Data:**
|
||||
- Basic dog info (name, breed, sex, etc.) can be re-entered
|
||||
- Photos will need to be re-uploaded
|
||||
- Consider this a fresh start with a clean, correct schema
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- ✅ **Fixed:** "no such column: sire" errors
|
||||
- ✅ **Fixed:** "no such column: weight" errors
|
||||
- ✅ **Fixed:** "no such column: height" errors
|
||||
- ✅ **Fixed:** Parent relationships not saving properly
|
||||
- ✅ **Fixed:** Schema detection failures on startup
|
||||
- ✅ **Fixed:** Migration system complexity
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Updates
|
||||
|
||||
### DATABASE.md
|
||||
|
||||
Comprehensive database documentation including:
|
||||
- Schema design principles
|
||||
- All table structures with SQL
|
||||
- API usage examples
|
||||
- Query examples for relationships
|
||||
- Fresh install instructions
|
||||
- Troubleshooting guide
|
||||
|
||||
### README.md
|
||||
|
||||
Updated with:
|
||||
- Current feature list
|
||||
- Clean schema explanation
|
||||
- Fresh install vs upgrade instructions
|
||||
- Troubleshooting for common errors
|
||||
- Links to documentation
|
||||
|
||||
### ROADMAP.md
|
||||
|
||||
Updated with:
|
||||
- Phase 1-3 marked complete
|
||||
- v0.4.0 release notes
|
||||
- Current sprint focus recommendations
|
||||
- Version history
|
||||
|
||||
---
|
||||
|
||||
## 🧐 Developer Notes
|
||||
|
||||
### New Development Workflow
|
||||
|
||||
**For database changes:**
|
||||
1. Edit `server/db/init.js` only
|
||||
2. Test with fresh database: `rm data/breedr.db && npm run dev`
|
||||
3. Update `DATABASE.md` documentation
|
||||
4. No migrations needed!
|
||||
|
||||
**For API changes involving parents:**
|
||||
- Use `parents` table for sire/dam relationships
|
||||
- Check `server/routes/dogs.js` for examples
|
||||
- Log relationship creation for debugging
|
||||
|
||||
### Testing
|
||||
|
||||
Test these scenarios:
|
||||
1. Fresh install - database created correctly
|
||||
2. Add dog with sire/dam - parents table populated
|
||||
3. Add dog via litter - litter_id set, parents auto-linked
|
||||
4. View dog details - parents and offspring shown correctly
|
||||
5. Pedigree view - multi-generation tree displays
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Next
|
||||
|
||||
### Recommended Next Features
|
||||
|
||||
1. **Trial Pairing Simulator** (4-6 hours)
|
||||
- Uses existing COI calculator backend
|
||||
- High value for breeding decisions
|
||||
- Relatively quick to implement
|
||||
|
||||
2. **Health Records System** (6-8 hours)
|
||||
- Important for breeding decisions
|
||||
- Vaccination tracking
|
||||
- Document management
|
||||
|
||||
3. **Heat Cycle Management** (6-8 hours)
|
||||
- Natural extension of litter management
|
||||
- Calendar functionality
|
||||
- Breeding planning
|
||||
|
||||
See `ROADMAP.md` for full details.
|
||||
|
||||
---
|
||||
|
||||
## ℹ️ Support
|
||||
|
||||
**Documentation:**
|
||||
- [DATABASE.md](DATABASE.md) - Schema reference
|
||||
- [README.md](README.md) - Project overview
|
||||
- [ROADMAP.md](ROADMAP.md) - Development plan
|
||||
- [CLEANUP_NOTES.md](CLEANUP_NOTES.md) - File cleanup guide
|
||||
|
||||
**Common Issues:**
|
||||
- "no such column" errors → Delete database and restart
|
||||
- Parents not saving → Check server logs for relationship creation
|
||||
- Schema looks wrong → Verify with `.schema dogs` command
|
||||
|
||||
**Logs:**
|
||||
```bash
|
||||
docker logs breedr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Credits
|
||||
|
||||
Clean schema design and implementation by the BREEDR development team.
|
||||
|
||||
Special thanks for thorough testing and validation of the new database architecture.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog Summary
|
||||
|
||||
### Added
|
||||
- Clean database initialization system
|
||||
- `dogs.litter_id` column
|
||||
- `parents` table for relationships
|
||||
- DATABASE.md documentation
|
||||
- Detailed logging for debugging
|
||||
- CLEANUP_NOTES.md
|
||||
- RELEASE_NOTES_v0.4.0.md
|
||||
|
||||
### Changed
|
||||
- Database init is now single source of truth
|
||||
- Parent relationships use parents table
|
||||
- README.md updated
|
||||
- ROADMAP.md updated
|
||||
- Simplified server startup
|
||||
|
||||
### Removed
|
||||
- Migration system (`server/db/migrations.js`)
|
||||
- `dogs.weight` column
|
||||
- `dogs.height` column
|
||||
- `dogs.sire_id` column
|
||||
- `dogs.dam_id` column
|
||||
- Schema detection logic
|
||||
- Outdated documentation (marked for deletion)
|
||||
|
||||
### Fixed
|
||||
- "no such column" errors
|
||||
- Parent relationship saving
|
||||
- Schema consistency issues
|
||||
- Migration failures
|
||||
|
||||
---
|
||||
|
||||
**Full Diff:** [Compare branches on Gitea](https://git.alwisp.com/jason/breedr/compare/feature/enhanced-litters-and-pedigree...docs/clean-schema-and-roadmap-update)
|
||||
|
||||
**Next Release:** v0.5.0 - Trial Pairing Simulator (planned)
|
||||
553
ROADMAP.md
553
ROADMAP.md
@@ -1,402 +1,9 @@
|
||||
# BREEDR Development Roadmap
|
||||
# BREEDR Development Roadmap (v0.8.0)
|
||||
|
||||
## ✅ Phase 1: Foundation (COMPLETE)
|
||||
|
||||
### Infrastructure
|
||||
- [x] Docker multi-stage build configuration
|
||||
- [x] SQLite database with automatic initialization
|
||||
- [x] Express.js API server
|
||||
- [x] React 18 frontend with Vite
|
||||
- [x] Git repository structure
|
||||
|
||||
### Database Schema
|
||||
- [x] Dogs table with core fields (NO sire/dam columns)
|
||||
- [x] Parents relationship table for sire/dam tracking
|
||||
- [x] Litters breeding records
|
||||
- [x] Health records tracking
|
||||
- [x] Heat cycles management
|
||||
- [x] Traits genetic mapping
|
||||
- [x] Indexes and triggers
|
||||
- [x] **litter_id column** for linking puppies to litters
|
||||
- [x] **Clean schema design** - NO migrations, fresh init only
|
||||
- [x] **Safe ALTER TABLE migration guards** - new columns added automatically on upgrade
|
||||
|
||||
### API Endpoints
|
||||
- [x] `/api/dogs` - Full CRUD operations (incl. `is_champion`)
|
||||
- [x] `/api/pedigree` - Tree generation and COI calculator
|
||||
- [x] `/api/litters` - Breeding records
|
||||
- [x] `/api/health` - Health tracking
|
||||
- [x] `/api/breeding` - Heat cycles and whelping calculator
|
||||
- [x] `/api/settings` - Kennel configuration (GET/PUT)
|
||||
- [x] Photo upload with Multer
|
||||
- [x] **Parent relationship handling** via parents table
|
||||
- [x] `/static/*` - Branding and static asset serving
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Core Functionality (COMPLETE)
|
||||
|
||||
### Dog Management
|
||||
- [x] Add new dogs with full form
|
||||
- [x] Edit existing dogs
|
||||
- [x] View dog details
|
||||
- [x] List all dogs with search/filter
|
||||
- [x] Upload multiple photos per dog
|
||||
- [x] Delete photos
|
||||
- [x] Parent selection (sire/dam) via parents table
|
||||
- [x] **Proper error handling** for API failures
|
||||
|
||||
### User Interface
|
||||
- [x] Dashboard with statistics
|
||||
- [x] Dog list with grid view
|
||||
- [x] Dog detail pages
|
||||
- [x] Modal forms for add/edit
|
||||
- [x] Photo management UI
|
||||
- [x] Search and sex filtering
|
||||
- [x] Responsive navigation
|
||||
- [x] **Compact info cards** (80x80 avatars)
|
||||
- [x] **Modern dark theme** with glass morphism
|
||||
- [x] **Custom brand logo** (br-logo.png) in navbar
|
||||
- [x] **Gold-to-rusty-red gradient** on BREEDR brand title
|
||||
- [x] **Static asset serving** via Express `/static` route
|
||||
- [x] **Vite dev proxy** for `/static` routes
|
||||
- [x] **Route fix** - static/uploads don't fall through to React router
|
||||
- [x] **Logo aspect ratio** fixed to 1:1 square
|
||||
|
||||
### Features Implemented
|
||||
- [x] Photo upload and storage
|
||||
- [x] Parent-child relationships (via parents table)
|
||||
- [x] Basic information tracking
|
||||
- [x] Registration numbers
|
||||
- [x] Microchip tracking (optional)
|
||||
- [x] **Litter linking** with litter_id
|
||||
- [x] **Clean database schema** with no migrations
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 3: Breeding Tools (COMPLETE)
|
||||
|
||||
### Pedigree & Genetics
|
||||
- [x] **Interactive pedigree tree visualization**
|
||||
- [x] Integrate React-D3-Tree
|
||||
- [x] Show 3-5 generations
|
||||
- [x] Click to navigate
|
||||
- [x] Zoom and pan controls
|
||||
- [x] Beautiful color-coded nodes
|
||||
- [x] Male/Female distinction
|
||||
|
||||
- [x] **Litter Management** ✅
|
||||
- [x] Create litter records
|
||||
- [x] Link puppies to litter
|
||||
- [x] Track whelping details
|
||||
- [x] Auto-link parent relationships
|
||||
- [x] Database migration for litter_id
|
||||
- [x] Enhanced API endpoints
|
||||
- [x] Dual parent selection mode (litter/manual)
|
||||
- [x] UI fix for proper layout and error handling
|
||||
|
||||
- [x] **Trial Pairing Simulator** ✅ *(March 9, 2026)*
|
||||
- [x] Sire and dam selection dropdowns
|
||||
- [x] COI calculation display with color coding
|
||||
- [x] Common ancestors table (sire-gen / dam-gen columns)
|
||||
- [x] Risk badge: Low (<5%) / Moderate (5-10%) / High (>10%)
|
||||
- [x] `/pairing` route + navbar link
|
||||
- [x] `POST /api/pedigree/trial-pairing` backend
|
||||
|
||||
- [x] **Heat Cycle Calendar** ✅ *(March 9, 2026)*
|
||||
- [x] Full month grid calendar (Sun–Sat) with prev/next navigation
|
||||
- [x] Color-coded day cells by cycle phase
|
||||
- [x] Start Heat Cycle modal (female dropdown + date picker)
|
||||
- [x] Cycle Detail modal with phase breakdown
|
||||
- [x] Breeding date logging inline
|
||||
- [x] Whelping estimate (earliest/expected/latest)
|
||||
- [x] Active cycles list with phase badge + day counter
|
||||
- [x] `GET /api/breeding/heat-cycles` endpoint
|
||||
- [x] `GET /api/breeding/heat-cycles/:id/suggestions` endpoint
|
||||
|
||||
- [x] **Projected Whelping Calendar Identifier** ✅ *(March 9, 2026 − v0.5.1)*
|
||||
- [x] Gestation constants: earliest=58, expected=63, latest=65 days
|
||||
- [x] `getWwhelpDates(cycle)` client-side helper (no extra API call)
|
||||
- [x] Indigo whelp window cells (days 58–63) on calendar grid
|
||||
- [x] Indigo dot marker on expected whelp day (day 63)
|
||||
- [x] `Baby` icon + "[Name] due" label inside whelp day cells
|
||||
- [x] "Whelp est. [date]" row with range on active cycle cards
|
||||
- [x] Jump-to-whelp-month button on active cycle cards
|
||||
- [x] Live whelp preview in Cycle Detail modal (client-side, instant)
|
||||
- [x] Full-width whelping banner when projected whelps exist
|
||||
- [x] "Projected Whelp" legend entry with Baby icon
|
||||
- [x] Updated page subtitle to include whelping dates
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4a: Champion & Settings (COMPLETE − v0.6.0)
|
||||
|
||||
### Champion Bloodline Tracking
|
||||
- [x] `is_champion INTEGER DEFAULT 0` column on `dogs` table
|
||||
- [x] Safe `ALTER TABLE dogs ADD COLUMN is_champion` migration guard
|
||||
- [x] `is_champion` included in all `GET /api/dogs` + `GET /api/dogs/:id` responses
|
||||
- [x] `is_champion` persisted in `POST` and `PUT /api/dogs`
|
||||
- [x] `is_champion` included on sire/dam JOIN queries and offspring query
|
||||
- [x] Champion toggle checkbox in `DogForm` with amber-gold highlight + `Award` icon
|
||||
- [x] `✪` suffix on champion names in sire/dam parent dropdowns
|
||||
- [x] Champion Bloodline badge on offspring cards and dog detail pages
|
||||
|
||||
### Kennel Settings
|
||||
- [x] `settings` table: `kennel_name`, `kennel_tagline`, `kennel_address`, `kennel_phone`, `kennel_email`, `kennel_website`, `kennel_akc_id`, `kennel_breed`, `owner_name`
|
||||
- [x] Safe `ALTER TABLE settings ADD COLUMN` migration loop for all kennel fields
|
||||
- [x] Auto-seed default row (`kennel_name = 'BREEDR'`) if table is empty
|
||||
- [x] `GET /api/settings` − returns single-row as flat JSON object
|
||||
- [x] `PUT /api/settings` − partial update via `ALLOWED_KEYS` whitelist
|
||||
- [x] `SettingsProvider` / `useSettings` React context hook
|
||||
- [x] Kennel name displayed in navbar from settings
|
||||
- [x] `SettingsPage` component for editing kennel info
|
||||
|
||||
### Build & Runtime Fixes (v0.6.0)
|
||||
- [x] `useSettings.js` → `useSettings.jsx` − Vite build failed because JSX in `.js` file
|
||||
- [x] `server/index.js` − `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal)
|
||||
- [x] `server/index.js` − removed duplicate `app.get('/api/health')` inline route
|
||||
- [x] `server/index.js` − `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation
|
||||
- [x] `server/routes/settings.js` − rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
|
||||
|
||||
---
|
||||
|
||||
## 📋 Phase 4b: Health & Genetics (NEXT UP − v0.7.0)
|
||||
## 🚀 Current Status: v0.8.0 (Active Development)
|
||||
|
||||
### 🔜 Next Up — Phase 4b: Health & Genetics Build Order
|
||||
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
|
||||
> This phase builds a structured, breed-aware health tracking system aligned with those requirements.
|
||||
|
||||
### Tier 1 — OFA Health Clearances *(Priority 1)* 🩺
|
||||
|
||||
The four GRCA-required clearances that must be on record in the public OFA database before breeding.
|
||||
|
||||
**Database (schema additions to `health_records` table):**
|
||||
- [ ] Add `test_type` ENUM-style field: `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`, `dna_panel`
|
||||
- [ ] Add `result` field: `pass`, `fail`, `carrier`, `clear`, `excellent`, `good`, `fair`, `borderline`
|
||||
- [ ] Add `ofa_number` VARCHAR — official OFA certification number
|
||||
- [ ] Add `chic_number` VARCHAR — CHIC certification number (dog-level field on `dogs` table)
|
||||
- [ ] Add `performed_by` VARCHAR — vet or specialist name
|
||||
- [ ] Add `expires_at` DATE — for annually-renewed tests (eyes, heart)
|
||||
- [ ] Add `document_url` VARCHAR — path to uploaded PDF/image
|
||||
- [ ] Safe ALTER TABLE migration guards for all new columns
|
||||
|
||||
**API:**
|
||||
- [ ] `GET /api/health/:dogId` — list all health records for a dog
|
||||
- [ ] `POST /api/health` — create health record
|
||||
- [ ] `PUT /api/health/:id` — update health record
|
||||
- [ ] `DELETE /api/health/:id` — delete health record
|
||||
- [ ] `GET /api/health/:dogId/clearance-summary` — returns pass/fail/missing for all 4 OFA tiers
|
||||
- [ ] `GET /api/health/:dogId/chic-eligible` — returns boolean + missing tests
|
||||
|
||||
**UI Components:**
|
||||
- [ ] `HealthRecordForm` modal — test type dropdown, result, OFA#, date, performed-by, expiry, document upload
|
||||
- [ ] `HealthTimeline` component — chronological list of all health events per dog on DogDetail page
|
||||
- [ ] `ClearanceSummaryCard` — shows OFA Hip / Elbow / Heart / Eyes status in a 2x2 grid with color badges (green=pass, yellow=expiring, red=missing/fail)
|
||||
- [ ] `ChicStatusBadge` — amber badge on dog cards and DogDetail if CHIC number is on file
|
||||
- [ ] Expiry alert: yellow badge on dog card if any annual test expires within 90 days; red if expired
|
||||
- [ ] Document upload support (PDF/image) tied to individual health records
|
||||
|
||||
**Clearance Tiers Tracked:**
|
||||
| Test | OFA Minimum Age | Renewal | Notes |
|
||||
|---|---|---|---|
|
||||
| Hip Dysplasia | 24 months | Once (final) | OFA eval or PennHIP |
|
||||
| Elbow Dysplasia | 24 months | Once (final) | OFA eval |
|
||||
| Cardiac (Heart) | 12 months | Annual recommended | Echo preferred over auscultation |
|
||||
| Eyes (CAER) | 12 months | **Annual** | Board-certified ACVO ophthalmologist |
|
||||
| Thyroid (OFA) | 12 months | Annual recommended | Bonus/Tier 2 |
|
||||
|
||||
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
||||
**Estimated Time:** 8–10 hours
|
||||
|
||||
---
|
||||
|
||||
### Tier 2 — DNA Genetic Panel *(Priority 2)* 🧬
|
||||
|
||||
Embark or equivalent panel results per dog. Allows carrier × clear pairing without producing affected offspring.
|
||||
|
||||
**Database:**
|
||||
- [ ] `genetic_tests` table: `id`, `dog_id`, `test_provider` (embark/optigen/etc), `test_name`, `result` (clear/carrier/affected), `test_date`, `document_url`, `created_at`
|
||||
- [ ] Safe `CREATE TABLE IF NOT EXISTS` guard
|
||||
|
||||
**Golden Retriever Panel — Key Markers:**
|
||||
- [ ] PRA1 (Progressive Retinal Atrophy type 1)
|
||||
- [ ] PRA2 (Progressive Retinal Atrophy type 2)
|
||||
- [ ] prcd-PRA (Progressive Rod-Cone Degeneration)
|
||||
- [ ] ICH1 / ICH2 (Ichthyosis — very common in Goldens)
|
||||
- [ ] NCL (Neuronal Ceroid Lipofuscinosis — fatal neurological)
|
||||
- [ ] DM (Degenerative Myelopathy)
|
||||
- [ ] MD (Muscular Dystrophy)
|
||||
- [ ] GR-PRA1, GR-PRA2 (Golden-specific PRA variants)
|
||||
|
||||
**API:**
|
||||
- [ ] `GET /api/genetics/:dogId` — list all genetic test results
|
||||
- [ ] `POST /api/genetics` — add genetic result
|
||||
- [ ] `PUT /api/genetics/:id` — update
|
||||
- [ ] `DELETE /api/genetics/:id` — delete
|
||||
- [ ] `GET /api/genetics/pairing-risk?sireId=&damId=` — returns at-risk combinations for a trial pairing
|
||||
|
||||
**UI Components:**
|
||||
- [ ] `GeneticTestForm` modal — provider, marker, result (clear/carrier/affected), date, upload
|
||||
- [ ] `GeneticPanelCard` on DogDetail — color-coded grid of all markers (green=clear, yellow=carrier, red=affected, gray=not tested)
|
||||
- [ ] Pairing risk overlay on Trial Pairing Simulator — flag if sire+dam are both carriers for same marker
|
||||
- [ ] "Not Tested" indicator on dog cards when no DNA panel on file
|
||||
|
||||
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
||||
**Estimated Time:** 6–8 hours
|
||||
|
||||
---
|
||||
|
||||
### Tier 3 — Cancer Lineage & Longevity Tracking *(Priority 3)* 📊
|
||||
|
||||
Golden Retrievers have ~60% cancer mortality rate. Lineage-based cancer history is a major differentiator for responsible breeders.
|
||||
|
||||
**Database:**
|
||||
- [ ] `cancer_history` table: `id`, `dog_id`, `cancer_type`, `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`, `created_at`
|
||||
- [ ] Add `age_at_death` and `cause_of_death` optional fields to `dogs` table
|
||||
|
||||
**API:**
|
||||
- [ ] `GET /api/health/:dogId/cancer-history`
|
||||
- [ ] `POST /api/health/cancer-history`
|
||||
- [ ] `GET /api/pedigree/:dogId/cancer-lineage` — walks ancestors and returns cancer incidence summary
|
||||
|
||||
**UI:**
|
||||
- [ ] Longevity section on DogDetail — age at death, cause of death
|
||||
- [ ] Cancer lineage indicator on Trial Pairing Simulator — "X of 8 ancestors had cancer history"
|
||||
- [ ] Optional cancer history entry on DogForm
|
||||
|
||||
**Complexity:** Low-Medium | **Impact:** Medium | **User Value:** High (differentiator)
|
||||
**Estimated Time:** 4–5 hours
|
||||
|
||||
---
|
||||
|
||||
### Tier 4 — Breeding Eligibility Checker *(Priority 4)* ✅
|
||||
|
||||
Automatic litter eligibility gate based on health clearance status of sire and dam.
|
||||
|
||||
**Logic:**
|
||||
- [ ] Dog is "GRCA eligible" if: Hip OFA ✅ + Elbow OFA ✅ + Heart ✅ + Eyes (non-expired) ✅ + age ≥ 24 months
|
||||
- [ ] Dog is "CHIC eligible" if all four tests are in OFA public database (CHIC number on file)
|
||||
- [ ] Warning flags in Trial Pairing Simulator if sire or dam is missing required clearances
|
||||
- [ ] Block litter creation (with override) if either parent fails eligibility check
|
||||
|
||||
**UI:**
|
||||
- [ ] Eligibility badge on dog cards: `GRCA Eligible` (green) / `Incomplete` (yellow) / `Not Eligible` (red)
|
||||
- [ ] Eligibility breakdown tooltip on hover — shows which tests are missing
|
||||
- [ ] Pre-litter warning modal when creating a litter with non-eligible parents
|
||||
- [ ] CHIC number field + verification note on DogDetail
|
||||
|
||||
**Complexity:** Low | **Impact:** High | **User Value:** Excellent
|
||||
**Estimated Time:** 3–4 hours
|
||||
|
||||
---
|
||||
|
||||
## 📋 Phase 5: Advanced Features (PLANNED)
|
||||
|
||||
### Pedigree Tools
|
||||
- [ ] Reverse pedigree (descendants view)
|
||||
- [ ] PDF pedigree generation
|
||||
- [ ] Export to standard formats
|
||||
- [ ] Print-friendly layouts
|
||||
- [ ] Multi-generation COI analysis
|
||||
|
||||
### Breeding Planning
|
||||
- [ ] Heat cycle predictions (based on cycle history)
|
||||
- [ ] Expected whelping alerts / push notifications
|
||||
- [ ] Breeding history reports
|
||||
- [ ] iCal export for cycle events
|
||||
|
||||
### Search & Analytics
|
||||
- [ ] Advanced search filters
|
||||
- [ ] By breed, color, age
|
||||
- [ ] By health clearances
|
||||
- [ ] By registration status
|
||||
- [ ] Statistics dashboard
|
||||
- [ ] Breeding success rates
|
||||
- [ ] Average litter sizes
|
||||
- [ ] Popular pairings
|
||||
|
||||
---
|
||||
|
||||
## 📋 Phase 6: Polish & Optimization (PLANNED)
|
||||
|
||||
### User Experience
|
||||
- [ ] Loading states for all operations
|
||||
- [ ] Better error messages
|
||||
- [ ] Confirmation dialogs
|
||||
- [ ] Undo functionality
|
||||
- [ ] Keyboard shortcuts
|
||||
|
||||
### Performance
|
||||
- [ ] Image optimization
|
||||
- [ ] Lazy loading
|
||||
- [ ] API caching
|
||||
- [ ] Database query optimization
|
||||
|
||||
### Mobile
|
||||
- [ ] Touch-friendly interface
|
||||
- [ ] Mobile photo capture
|
||||
- [ ] Responsive tables
|
||||
- [ ] Offline mode
|
||||
|
||||
### Documentation
|
||||
- [x] DATABASE.md - Complete schema documentation
|
||||
- [x] User-facing documentation
|
||||
- [ ] API documentation
|
||||
- [ ] Video tutorials
|
||||
- [ ] FAQ section
|
||||
|
||||
---
|
||||
|
||||
## Future / Extended Features (BACKLOG)
|
||||
|
||||
### Progesterone Tracking *(Moved from Phase 3)*
|
||||
- [ ] Log progesterone level readings per heat cycle
|
||||
- [ ] Chart progesterone curve over cycle days
|
||||
- [ ] LH surge detection
|
||||
- [ ] Optimal breeding day prediction from levels
|
||||
|
||||
### Multi-User Support
|
||||
- [ ] User authentication
|
||||
- [ ] Role-based permissions
|
||||
- [ ] Activity logs
|
||||
- [ ] Shared access
|
||||
|
||||
### Integration
|
||||
- [ ] Import from other systems
|
||||
- [ ] Export to Excel/CSV
|
||||
- [ ] Integration with kennel clubs
|
||||
- [ ] Backup to cloud storage
|
||||
- [ ] OFA database lookup by registration number
|
||||
|
||||
### Advanced Genetics
|
||||
- [ ] DNA test result tracking (full Embark import)
|
||||
- [ ] Genetic diversity analysis
|
||||
- [ ] Breed-specific calculators
|
||||
- [ ] Health risk predictions
|
||||
|
||||
### Kennel Management
|
||||
- [ ] Breeding contracts
|
||||
- [ ] Buyer tracking
|
||||
- [ ] Financial records
|
||||
- [ ] Stud service management
|
||||
|
||||
---
|
||||
|
||||
## 🏃 Current Sprint: v0.7.0 (Phase 4b)
|
||||
|
||||
### ✅ Completed This Sprint (v0.6.0)
|
||||
- [x] `is_champion` flag − DB column, API, DogForm toggle, offspring badge, parent dropdown `✪`
|
||||
- [x] Kennel Settings − `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name
|
||||
- [x] `useSettings.jsx` rename (Vite build fix)
|
||||
- [x] `server/index.js` fix − `initDatabase()` no-arg, duplicate health route removed
|
||||
- [x] `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed
|
||||
|
||||
### ✅ Previously Completed (v0.5.1)
|
||||
- [x] Projected Whelping Calendar Identifier − indigo whelp window cells, due label, active card range, jump-to-month button
|
||||
- [x] Live whelp preview in Cycle Detail modal (client-side, no save required)
|
||||
- [x] Full-width whelping banner for months with projected whelps
|
||||
- [x] "Projected Whelp" legend entry + updated page subtitle
|
||||
|
||||
### 🔜 Next Up — Phase 4b Build Order
|
||||
|
||||
#### Step 1: DB Schema Extensions
|
||||
- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url)
|
||||
@@ -430,45 +37,15 @@ Automatic litter eligibility gate based on health clearance status of sire and d
|
||||
- [ ] Eligibility badge on dog cards
|
||||
- [ ] Pre-litter eligibility warning modal
|
||||
|
||||
#### Step 6: Cancer / Longevity (Stretch)
|
||||
- [ ] Cancer history form + lineage summary on Trial Pairing page
|
||||
- [ ] Age at death / cause of death on DogDetail
|
||||
|
||||
### Testing Needed
|
||||
- [x] Add/edit dog forms with litter selection
|
||||
- [x] Database schema initialization
|
||||
- [x] Pedigree tree rendering
|
||||
- [x] Zoom/pan controls
|
||||
- [x] UI layout fixes
|
||||
- [x] Error handling for API failures
|
||||
- [x] Parent relationship creation via parents table
|
||||
- [x] Brand logo display and sizing
|
||||
- [x] Gradient title rendering
|
||||
- [x] Static asset serving in prod and dev
|
||||
- [ ] Champion toggle − DogForm save/load round-trip
|
||||
- [ ] Champion badge − offspring card display
|
||||
- [ ] Kennel settings − save + navbar name update
|
||||
- [ ] Trial pairing simulator (end-to-end)
|
||||
- [ ] Heat cycle calendar (start cycle, detail modal, whelping)
|
||||
- [ ] Projected whelping calendar identifier (whelp cells, due label, banner)
|
||||
- [ ] Health records — OFA clearance CRUD
|
||||
- [ ] Genetic panel — DNA marker entry and display
|
||||
- [ ] Eligibility checker — badge and litter gate
|
||||
|
||||
### Known Issues
|
||||
- None currently
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
## 🕒 Version History & Recent Progress
|
||||
|
||||
1. Pick a feature from "Next Up" above
|
||||
2. Create a feature branch off `master`: `feat/feature-name`
|
||||
3. Implement with tests
|
||||
4. Update this roadmap and README.md
|
||||
5. Submit PR for review
|
||||
|
||||
## Version History
|
||||
- **v0.8.0** (March 12, 2026) - Reverse Pedigree & External Parentage (LATEST)
|
||||
- [x] **Reverse Pedigree** (descendants view) toggle on Pedigree page
|
||||
- [x] **External dog parentage** improvements (allowed assigning sire/dam to external dogs)
|
||||
- [x] **Universal parent selection** (sire/dam dropdowns now include all dogs)
|
||||
- [x] Updated documentation and roadmap
|
||||
|
||||
- **v0.7.0** (In Progress) - Phase 4b: Health & Genetics
|
||||
- OFA clearance tracking (Hip, Elbow, Heart, Eyes + CHIC number)
|
||||
@@ -476,58 +53,86 @@ Automatic litter eligibility gate based on health clearance status of sire and d
|
||||
- Cancer lineage & longevity tracking
|
||||
- Breeding eligibility checker (GRCA + CHIC gates)
|
||||
|
||||
- **v0.6.1** (March 10, 2026) - COI Direct-Relation Fix
|
||||
- Fixed `calculateCOI` to correctly compute coefficient for parent×offspring pairings (~25%)
|
||||
- Removed blanket sire exclusion in ancestor mapping logic
|
||||
|
||||
- **v0.6.0** (March 9, 2026) - Champion Bloodline, Settings, Build Fixes
|
||||
- `is_champion` flag on dogs table with ALTER TABLE migration guard
|
||||
- Champion toggle in DogForm; `✪` suffix in parent dropdowns; offspring badge
|
||||
- Kennel settings table + `GET/PUT /api/settings` + `SettingsProvider`
|
||||
- `useSettings.jsx` rename (Vite build fix)
|
||||
- `server/index.js` fix: `initDatabase()` no-arg, duplicate health route removed
|
||||
- `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed
|
||||
- `server/routes/settings.js` rewrite: double-encoded base64 fixed
|
||||
|
||||
- **v0.5.1** (March 9, 2026) - Projected Whelping Calendar Identifier
|
||||
- **v0.5.1** (March 9, 2026) - Projected Whelping Calendar
|
||||
- Indigo whelp window cells (days 58–65) on month grid
|
||||
- Indigo dot marker on exact expected whelp day (day 63)
|
||||
- `Baby` icon + "[Name] due" label in whelp day cells
|
||||
- "Whelp est." range row on active cycle cards
|
||||
- Jump-to-whelp-month button on cycle cards
|
||||
- Live whelp preview in Cycle Detail modal (client-side, instant)
|
||||
- Full-width whelping banner when projected whelps exist
|
||||
- "Projected Whelp" legend entry + updated page subtitle
|
||||
- Live whelp preview in Cycle Detail modal
|
||||
|
||||
- **v0.5.0** (March 9, 2026) - Breeding Tools Complete
|
||||
- **v0.5.0** (March 9, 2026) - Breeding Tools
|
||||
- Trial Pairing Simulator: COI calculator, risk badge, common ancestors
|
||||
- Heat Cycle Calendar: month grid, phase color coding, start-cycle modal
|
||||
- Cycle Detail: breeding windows, inline breeding date, whelping estimate
|
||||
- New API: `GET /heat-cycles`, `GET /heat-cycles/:id/suggestions`
|
||||
- Progesterone tracking moved to extended backlog
|
||||
- Heat Cycle Calendar: month grid, phase color coding, suggestions
|
||||
|
||||
- **v0.4.1** (March 9, 2026) - Branding & Header Improvements
|
||||
- Custom br-logo.png in navbar
|
||||
- Gold-to-rusty-red gradient title
|
||||
- Static asset serving via Express
|
||||
- Vite dev proxy for /static
|
||||
- Route fix for static/uploads paths
|
||||
- Logo 1:1 aspect ratio fix
|
||||
---
|
||||
|
||||
- **v0.4.0** (March 9, 2026) - Clean Database Schema
|
||||
- Complete database overhaul with clean normalized design
|
||||
- Removed migrations, fresh init only
|
||||
- Parents table for relationships
|
||||
- Comprehensive documentation
|
||||
|
||||
- **v0.3.1** - UI Fixes & Error Handling
|
||||
- Fixed blank screen issue on Add Dog modal
|
||||
- Improved parent selection layout
|
||||
- Added comprehensive error handling
|
||||
- Enhanced visual design with proper spacing
|
||||
|
||||
- **v0.3.0** - Litter Management & Interactive Pedigree
|
||||
- Added litter_id to dogs table
|
||||
- Implemented LitterForm component
|
||||
- Created PedigreeView with React-D3-Tree
|
||||
- Enhanced DogForm with dual parent selection
|
||||
- Fixed "no such column: sire" error
|
||||
- Added comprehensive documentation
|
||||
|
||||
- **v0.2.0** - Dog CRUD operations complete
|
||||
- **v0.1.0** - Initial foundation with API and database
|
||||
## 📋 Future Roadmap
|
||||
|
||||
### ✅ Phase 5: Advanced Features (IN PROGRESS)
|
||||
- [x] Reverse pedigree (descendants view)
|
||||
- [ ] PDF pedigree generation
|
||||
- [ ] Export to standard formats (CSV, JSON)
|
||||
- [ ] Print-friendly layouts
|
||||
- [ ] Multi-generation COI analysis
|
||||
|
||||
### 📅 Phase 6: Polish & Optimization
|
||||
- [ ] **User Experience**: Loading states, better error messages, undo functionality
|
||||
- [ ] **Performance**: Image optimization, lazy loading, API caching
|
||||
- [ ] **Mobile**: Touch-friendly interface, mobile photo capture
|
||||
- [ ] **Documentation**: API technical docs, video tutorials
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed Milestones
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [x] Docker multi-stage build & SQLite database
|
||||
- [x] Express.js API server & React 18 frontend
|
||||
- [x] Parents relationship table for sire/dam tracking
|
||||
|
||||
### Phase 2: Core Functionality
|
||||
- [x] Dog Management (Full CRUD, photo uploads)
|
||||
- [x] Modern dark theme with glass morphism
|
||||
- [x] Branded navigation with custom logo
|
||||
|
||||
### Phase 3: Breeding Tools
|
||||
- [x] Interactive pedigree tree visualization (React-D3-Tree)
|
||||
- [x] Litter Management & linking puppies
|
||||
- [x] Trial Pairing Simulator & Heat Cycle Calendar
|
||||
- [x] Projected Whelping identifiers
|
||||
|
||||
### Phase 4a: Champion & Settings
|
||||
- [x] Champion bloodline tracking and badges
|
||||
- [x] Universal Kennel Settings system
|
||||
|
||||
---
|
||||
|
||||
## 🏃 Testing & Quality Assurance
|
||||
- [x] Database schema initialization guards
|
||||
- [x] Pedigree tree rendering & zoom/pan
|
||||
- [x] Parent relationship creation logic
|
||||
- [x] Static asset serving (prod/dev)
|
||||
- [ ] Champion toggle load/save trip
|
||||
- [ ] Heat cycle calendar whelping logic
|
||||
- [ ] Health records OFA clearance CRUD (Upcoming)
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
1. Pick a feature from "Next Up" above
|
||||
2. Create a feature branch off `master`: `feat/feature-name`
|
||||
3. Implement with tests and update this roadmap
|
||||
4. Submit PR for review
|
||||
|
||||
---
|
||||
*Last Updated: March 12, 2026*
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
@@ -48,7 +55,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
|
||||
const fetchDogs = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
const res = await axios.get('/api/dogs/all')
|
||||
setDogs(res.data || [])
|
||||
} catch (e) {
|
||||
setDogs([])
|
||||
@@ -57,8 +64,8 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
|
||||
const fetchLitters = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/litters')
|
||||
const data = res.data || []
|
||||
const res = await axios.get('/api/litters', { params: { limit: 200 } })
|
||||
const data = res.data.data || []
|
||||
setLitters(data)
|
||||
setLittersAvailable(data.length > 0)
|
||||
if (data.length === 0) setUseManualParents(true)
|
||||
@@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
const submitData = {
|
||||
...formData,
|
||||
is_champion: formData.is_champion ? 1 : 0,
|
||||
is_external: effectiveExternal ? 1 : 0,
|
||||
sire_id: formData.sire_id || null,
|
||||
dam_id: formData.dam_id || null,
|
||||
litter_id: useManualParents ? null : (formData.litter_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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
@@ -230,7 +259,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
}}>
|
||||
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
||||
|
||||
{littersAvailable && (
|
||||
{!effectiveExternal && littersAvailable && (
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||
<input type="radio" name="parentMode" checked={!useManualParents}
|
||||
@@ -245,44 +274,44 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
</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 && !effectiveExternal ? (
|
||||
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
||||
<label className="label">Select Litter</label>
|
||||
<select name="litter_id" className="input"
|
||||
value={formData.litter_id || ''} onChange={handleChange}>
|
||||
<option value="">No Litter</option>
|
||||
{litters.map(l => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formData.litter_id && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
|
||||
✓ Parents will be automatically set from the selected litter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Sire (Father)</label>
|
||||
<select name="sire_id" className="input"
|
||||
value={formData.sire_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Dam (Mother)</label>
|
||||
<select name="dam_id" className="input"
|
||||
value={formData.dam_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Sire (Father)</label>
|
||||
<select name="sire_id" className="input"
|
||||
value={formData.sire_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Dam (Mother)</label>
|
||||
<select name="dam_id" className="input"
|
||||
value={formData.dam_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||
@@ -294,7 +323,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>
|
||||
|
||||
97
client/src/components/GeneticPanelCard.jsx
Normal file
97
client/src/components/GeneticPanelCard.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dna, Plus } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import GeneticTestForm from './GeneticTestForm'
|
||||
|
||||
const RESULT_STYLES = {
|
||||
clear: { bg: 'rgba(52,199,89,0.15)', color: 'var(--success)' },
|
||||
carrier: { bg: 'rgba(255,159,10,0.15)', color: 'var(--warning)' },
|
||||
affected: { bg: 'rgba(255,59,48,0.15)', color: 'var(--danger)' },
|
||||
not_tested: { bg: 'var(--bg-tertiary)', color: 'var(--text-muted)' }
|
||||
}
|
||||
|
||||
export default function GeneticPanelCard({ dogId }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState(null)
|
||||
|
||||
const fetchGenetics = () => {
|
||||
setLoading(true)
|
||||
axios.get(`/api/genetics/dog/${dogId}`)
|
||||
.then(res => setData(res.data))
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { fetchGenetics() }, [dogId])
|
||||
|
||||
const openAdd = () => { setEditingRecord(null); setShowForm(true) }
|
||||
const openEdit = (rec) => { setEditingRecord(rec); setShowForm(true) }
|
||||
const handleSaved = () => { setShowForm(false); fetchGenetics() }
|
||||
|
||||
if (error || (!loading && !data)) return null
|
||||
|
||||
const panel = data?.panel || []
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Dna size={18} /> DNA Genetics Panel
|
||||
</h2>
|
||||
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAdd}>
|
||||
<Plus size={14} /> Update Marker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Loading...</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.5rem'
|
||||
}}>
|
||||
{panel.map(item => {
|
||||
const style = RESULT_STYLES[item.result] || RESULT_STYLES.not_tested
|
||||
// Pass the whole test record if it exists so we can edit it
|
||||
const record = item.id ? item : { marker: item.marker, result: 'not_tested' }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.marker}
|
||||
onClick={() => openEdit(record)}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.color}44`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.1s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-2px)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}
|
||||
>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.2rem', fontWeight: 500 }}>
|
||||
{item.marker}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', color: style.color, fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
{item.result.replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<GeneticTestForm
|
||||
dogId={dogId}
|
||||
record={editingRecord}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSave={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
client/src/components/GeneticTestForm.jsx
Normal file
157
client/src/components/GeneticTestForm.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
const GR_MARKERS = [
|
||||
{ value: 'PRA1', label: 'PRA1' },
|
||||
{ value: 'PRA2', label: 'PRA2' },
|
||||
{ value: 'prcd-PRA', label: 'prcd-PRA' },
|
||||
{ value: 'GR-PRA1', label: 'GR-PRA1' },
|
||||
{ value: 'GR-PRA2', label: 'GR-PRA2' },
|
||||
{ value: 'ICH1', label: 'ICH1 (Ichthyosis 1)' },
|
||||
{ value: 'ICH2', label: 'ICH2 (Ichthyosis 2)' },
|
||||
{ value: 'NCL', label: 'Neuronal Ceroid Lipofuscinosis' },
|
||||
{ value: 'DM', label: 'Degenerative Myelopathy' },
|
||||
{ value: 'MD', label: 'Muscular Dystrophy' }
|
||||
]
|
||||
|
||||
const RESULTS = [
|
||||
{ value: 'clear', label: 'Clear / Normal' },
|
||||
{ value: 'carrier', label: 'Carrier (1 copy)' },
|
||||
{ value: 'affected', label: 'Affected / At Risk (2 copies)' },
|
||||
{ value: 'not_tested', label: 'Not Tested' }
|
||||
]
|
||||
|
||||
const EMPTY = {
|
||||
test_provider: 'Embark',
|
||||
marker: 'PRA1',
|
||||
result: 'clear',
|
||||
test_date: '',
|
||||
document_url: '',
|
||||
notes: ''
|
||||
}
|
||||
|
||||
export default function GeneticTestForm({ dogId, record, onClose, onSave }) {
|
||||
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// If not tested, don't save
|
||||
if (form.result === 'not_tested' && !record) {
|
||||
setError('Cannot save a "Not Tested" result. Please just delete the record if it exists.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (record && record.id) {
|
||||
if (form.result === 'not_tested') {
|
||||
// If changed to not_tested, just delete it
|
||||
await axios.delete(`/api/genetics/${record.id}`)
|
||||
} else {
|
||||
await axios.put(`/api/genetics/${record.id}`, form)
|
||||
}
|
||||
} else {
|
||||
await axios.post('/api/genetics', { ...form, dog_id: dogId })
|
||||
}
|
||||
onSave()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save genetic record')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const labelStyle = {
|
||||
fontSize: '0.8rem', color: 'var(--text-muted)',
|
||||
marginBottom: '0.25rem', display: 'block',
|
||||
}
|
||||
const inputStyle = {
|
||||
width: '100%', background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000, padding: '1rem',
|
||||
}}>
|
||||
<div className="card" style={{
|
||||
width: '100%', maxWidth: '500px', maxHeight: '90vh',
|
||||
overflowY: 'auto', position: 'relative',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Genetic Result</h2>
|
||||
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Marker *</label>
|
||||
<select style={inputStyle} value={form.marker} onChange={e => set('marker', e.target.value)} disabled={!!record}>
|
||||
{GR_MARKERS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Result *</label>
|
||||
<select style={inputStyle} value={form.result} onChange={e => set('result', e.target.value)}>
|
||||
{RESULTS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Provider</label>
|
||||
<input style={inputStyle} placeholder="Embark, PawPrint, etc." value={form.test_provider}
|
||||
onChange={e => set('test_provider', e.target.value)} />
|
||||
</div>
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Test Date</label>
|
||||
<input style={inputStyle} type="date" value={form.test_date}
|
||||
onChange={e => set('test_date', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Document URL</label>
|
||||
<input style={inputStyle} type="url" placeholder="Link to PDF or result page" value={form.document_url}
|
||||
onChange={e => set('document_url', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={fw}>
|
||||
<label style={labelStyle}>Notes</label>
|
||||
<textarea style={{ ...inputStyle, minHeight: '60px', resize: 'vertical' }}
|
||||
value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
|
||||
}}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Result'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
|
||||
|
||||
const fetchDogs = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
const res = await axios.get('/api/dogs/all')
|
||||
setDogs(res.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error)
|
||||
|
||||
@@ -46,15 +46,15 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
: (isMale ? 'rgba(59,130,246,0.3)' : 'rgba(236,72,153,0.3)')
|
||||
const ringColor = isRoot ? rootAccent : nodeColor
|
||||
|
||||
const r = isRoot ? 34 : 28
|
||||
const r = isRoot ? 46 : 38
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Glow halo */}
|
||||
{/* Glow halo — kept within the circle so it doesn't bleed onto text labels */}
|
||||
<circle
|
||||
r={r + 10}
|
||||
r={r - 4}
|
||||
fill={glowColor}
|
||||
style={{ filter: 'blur(6px)' }}
|
||||
style={{ filter: 'blur(4px)' }}
|
||||
/>
|
||||
|
||||
{/* Outer ring */}
|
||||
@@ -95,25 +95,25 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
|
||||
{/* Gender / crown icon */}
|
||||
<text
|
||||
fill={isRoot ? '#fff' : '#fff'}
|
||||
fontSize={isRoot ? 22 : 18}
|
||||
fontSize={isRoot ? 28 : 24}
|
||||
textAnchor="middle"
|
||||
dy="7"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
dy="8"
|
||||
stroke="none"
|
||||
style={{ fill: '#ffffff', pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{isRoot ? '👑' : (isMale ? '♂' : '♀')}
|
||||
</text>
|
||||
|
||||
{/* Name label */}
|
||||
<text
|
||||
fill="var(--text-primary, #f5f0e8)"
|
||||
fontSize={isRoot ? 15 : 13}
|
||||
fontSize={isRoot ? 22 : 18}
|
||||
fontWeight={isRoot ? '700' : '600'}
|
||||
fontFamily="Inter, sans-serif"
|
||||
textAnchor="middle"
|
||||
x="0"
|
||||
y={r + 18}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
y={r + 32}
|
||||
stroke="none"
|
||||
style={{ fill: isRoot ? '#ffffff' : '#f8fafc', pointerEvents: 'none' }}
|
||||
>
|
||||
{nodeDatum.name}
|
||||
</text>
|
||||
@@ -121,13 +121,13 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
{/* Breed label (subtle) */}
|
||||
{breed && (
|
||||
<text
|
||||
fill="var(--text-muted, #8c8472)"
|
||||
fontSize="10"
|
||||
fontSize="14"
|
||||
fontFamily="Inter, sans-serif"
|
||||
textAnchor="middle"
|
||||
x="0"
|
||||
y={r + 31}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
y={r + 52}
|
||||
stroke="none"
|
||||
style={{ fill: '#cbd5e1', pointerEvents: 'none' }}
|
||||
>
|
||||
{breed}
|
||||
</text>
|
||||
@@ -136,13 +136,13 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
{/* Registration number */}
|
||||
{nodeDatum.attributes?.registration && (
|
||||
<text
|
||||
fill="var(--text-muted, #8c8472)"
|
||||
fontSize="10"
|
||||
fontSize="14"
|
||||
fontFamily="Inter, sans-serif"
|
||||
textAnchor="middle"
|
||||
x="0"
|
||||
y={r + (breed ? 44 : 31)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
y={r + (breed ? 70 : 52)}
|
||||
stroke="none"
|
||||
style={{ fill: '#94a3b8', pointerEvents: 'none' }}
|
||||
>
|
||||
{nodeDatum.attributes.registration}
|
||||
</text>
|
||||
@@ -151,13 +151,13 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
{/* Birth year */}
|
||||
{nodeDatum.attributes?.birth_year && (
|
||||
<text
|
||||
fill="var(--text-muted, #8c8472)"
|
||||
fontSize="10"
|
||||
fontSize="14"
|
||||
fontFamily="Inter, sans-serif"
|
||||
textAnchor="middle"
|
||||
x="0"
|
||||
y={r + (breed ? 57 : (nodeDatum.attributes?.registration ? 44 : 31))}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
y={r + (breed ? 88 : (nodeDatum.attributes?.registration ? 70 : 52))}
|
||||
stroke="none"
|
||||
style={{ fill: '#94a3b8', pointerEvents: 'none' }}
|
||||
>
|
||||
({nodeDatum.attributes.birth_year})
|
||||
</text>
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -232,8 +232,8 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||
}}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
separation={{ siblings: 1.6, nonSiblings: 2.2 }}
|
||||
nodeSize={{ x: 220, y: 160 }}
|
||||
separation={{ siblings: 1.8, nonSiblings: 2.4 }}
|
||||
nodeSize={{ x: 280, y: 200 }}
|
||||
renderCustomNodeElement={renderCustomNode}
|
||||
enableLegacyTransitions
|
||||
transitionDuration={300}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
position: relative;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
background: white;
|
||||
background: var(--bg-primary, #1e1e24);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
.pedigree-container {
|
||||
flex: 1;
|
||||
background: linear-gradient(to bottom, #f8fafc 0%, #e2e8f0 100%);
|
||||
background: var(--bg-primary, #1e1e24);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -26,8 +27,8 @@
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: var(--bg-elevated, #2a2a35);
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #475569;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
@@ -57,10 +58,10 @@
|
||||
|
||||
.pedigree-info {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
background: var(--bg-elevated, #2a2a35);
|
||||
border-top: 1px solid var(--border, #333);
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted, #a1a1aa);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@
|
||||
}
|
||||
|
||||
.pedigree-info strong {
|
||||
color: #334155;
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
/* Override react-d3-tree styles */
|
||||
@@ -94,12 +95,12 @@
|
||||
|
||||
.rd3t-label__title {
|
||||
font-weight: 600;
|
||||
fill: #1e293b;
|
||||
fill: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.rd3t-label__attributes {
|
||||
font-size: 0.875rem;
|
||||
fill: #64748b;
|
||||
font-size: 1rem;
|
||||
fill: var(--text-muted, #a1a1aa);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
|
||||
@@ -17,19 +17,24 @@ function PedigreeView({ dogId, onClose }) {
|
||||
}, [dogId])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('.pedigree-container')
|
||||
if (!container) return
|
||||
|
||||
const updateDimensions = () => {
|
||||
const container = document.querySelector('.pedigree-container')
|
||||
if (container) {
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
setDimensions({ width, height })
|
||||
setTranslate({ x: width / 4, y: height / 2 })
|
||||
}
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
setDimensions({ width, height })
|
||||
setTranslate({ x: width / 4, y: height / 2 })
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateDimensions()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
const fetchPedigree = async () => {
|
||||
|
||||
@@ -21,21 +21,21 @@ function Dashboard() {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
|
||||
axios.get('/api/dogs'),
|
||||
axios.get('/api/litters'),
|
||||
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
|
||||
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
|
||||
axios.get('/api/breeding/heat-cycles/active')
|
||||
])
|
||||
|
||||
const dogs = dogsRes.data
|
||||
const { data: recentDogsList, stats: dogStats } = dogsRes.data
|
||||
setStats({
|
||||
totalDogs: dogs.length,
|
||||
males: dogs.filter(d => d.sex === 'male').length,
|
||||
females: dogs.filter(d => d.sex === 'female').length,
|
||||
totalLitters: littersRes.data.length,
|
||||
totalDogs: dogStats?.total ?? 0,
|
||||
males: dogStats?.males ?? 0,
|
||||
females: dogStats?.females ?? 0,
|
||||
totalLitters: littersRes.data.total,
|
||||
activeHeatCycles: heatCyclesRes.data.length
|
||||
})
|
||||
|
||||
setRecentDogs(dogs.slice(0, 8))
|
||||
setRecentDogs(recentDogsList)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
|
||||
@@ -6,6 +6,8 @@ import DogForm from '../components/DogForm'
|
||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
|
||||
import HealthRecordForm from '../components/HealthRecordForm'
|
||||
import GeneticPanelCard from '../components/GeneticPanelCard'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
|
||||
function DogDetail() {
|
||||
const { id } = useParams()
|
||||
@@ -262,6 +264,18 @@ function DogDetail() {
|
||||
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{dog.chic_number && (
|
||||
<div className="info-row">
|
||||
<span className="info-label"><ShieldCheck size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />CHIC Status</span>
|
||||
<span className="info-value">
|
||||
<span style={{
|
||||
fontSize: '0.75rem', fontWeight: 600, padding: '0.2rem 0.6rem',
|
||||
background: 'rgba(99,102,241,0.15)', color: '#818cf8',
|
||||
borderRadius: '999px', border: '1px solid rgba(99,102,241,0.3)'
|
||||
}}>CHIC #{dog.chic_number}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dog.microchip && (
|
||||
<div className="info-row">
|
||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||
@@ -317,6 +331,9 @@ function DogDetail() {
|
||||
{/* OFA Clearance Summary */}
|
||||
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
|
||||
|
||||
{/* DNA Genetics Panel */}
|
||||
<GeneticPanelCard dogId={id} />
|
||||
|
||||
{/* Health Records List */}
|
||||
{healthRecords.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
|
||||
@@ -1,57 +1,69 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import DogForm from '../components/DogForm'
|
||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
function DogList() {
|
||||
const [dogs, setDogs] = useState([])
|
||||
const [filteredDogs, setFilteredDogs] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sexFilter, setSexFilter] = useState('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const searchTimerRef = useRef(null)
|
||||
|
||||
useEffect(() => { fetchDogs() }, [])
|
||||
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
|
||||
|
||||
const fetchDogs = async () => {
|
||||
const fetchDogs = async (p, q, s) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
setDogs(res.data)
|
||||
setLoading(false)
|
||||
const params = { page: p, limit: LIMIT }
|
||||
if (q) params.search = q
|
||||
if (s !== 'all') params.sex = s
|
||||
const res = await axios.get('/api/dogs', { params })
|
||||
setDogs(res.data.data)
|
||||
setTotal(res.data.total)
|
||||
setPage(p)
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filterDogs = () => {
|
||||
let filtered = dogs
|
||||
if (search) {
|
||||
filtered = filtered.filter(dog =>
|
||||
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
}
|
||||
if (sexFilter !== 'all') {
|
||||
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||
}
|
||||
setFilteredDogs(filtered)
|
||||
const handleSearchChange = (value) => {
|
||||
setSearch(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
|
||||
}
|
||||
|
||||
const handleSave = () => { fetchDogs() }
|
||||
const handleSexChange = (value) => {
|
||||
setSexFilter(value)
|
||||
fetchDogs(1, search, value)
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearch('')
|
||||
setSexFilter('all')
|
||||
fetchDogs(1, '', 'all')
|
||||
}
|
||||
|
||||
const handleSave = () => { fetchDogs(page, search, sexFilter) }
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
fetchDogs(page, search, sexFilter)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete dog. Please try again.')
|
||||
@@ -60,6 +72,8 @@ function DogList() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / LIMIT)
|
||||
|
||||
const calculateAge = (birthDate) => {
|
||||
if (!birthDate) return null
|
||||
const today = new Date()
|
||||
@@ -85,7 +99,7 @@ function DogList() {
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'}
|
||||
{total} {total === 1 ? 'dog' : 'dogs'}
|
||||
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,11 +119,11 @@ function DogList() {
|
||||
className="input"
|
||||
placeholder="Search by name or registration..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
style={{ paddingLeft: '2.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '140px' }}>
|
||||
<select className="input" value={sexFilter} onChange={(e) => handleSexChange(e.target.value)} style={{ width: '140px' }}>
|
||||
<option value="all">All Dogs</option>
|
||||
<option value="male">Males ♂</option>
|
||||
<option value="female">Females ♀</option>
|
||||
@@ -117,7 +131,7 @@ function DogList() {
|
||||
{(search || sexFilter !== 'all') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||
onClick={handleClearFilters}
|
||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||
>
|
||||
Clear
|
||||
@@ -127,7 +141,7 @@ function DogList() {
|
||||
</div>
|
||||
|
||||
{/* Dogs List */}
|
||||
{filteredDogs.length === 0 ? (
|
||||
{dogs.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
||||
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||
@@ -147,7 +161,7 @@ function DogList() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filteredDogs.map(dog => (
|
||||
{dogs.map(dog => (
|
||||
<div
|
||||
key={dog.id}
|
||||
className="card"
|
||||
@@ -259,6 +273,19 @@ function DogList() {
|
||||
{dog.registration_number}
|
||||
</div>
|
||||
)}
|
||||
{dog.chic_number && (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: 'rgba(99,102,241,0.1)',
|
||||
border: '1px solid rgba(99,102,241,0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.75rem', fontWeight: 600,
|
||||
color: '#818cf8', marginLeft: '0.5rem'
|
||||
}}>
|
||||
CHIC #{dog.chic_number}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -300,6 +327,31 @@ function DogList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchDogs(page - 1, search, sexFilter)}
|
||||
disabled={page <= 1 || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchDogs(page + 1, search, sexFilter)}
|
||||
disabled={page >= totalPages || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Dog Modal */}
|
||||
{showAddModal && (
|
||||
<DogForm
|
||||
|
||||
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
|
||||
@@ -278,7 +278,7 @@ function LitterDetail() {
|
||||
|
||||
const fetchAllDogs = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
const res = await axios.get('/api/dogs/all')
|
||||
setAllDogs(res.data)
|
||||
} catch (err) { console.error('Error fetching dogs:', err) }
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import LitterForm from '../components/LitterForm'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
function LitterList() {
|
||||
const [litters, setLitters] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingLitter, setEditingLitter] = useState(null)
|
||||
@@ -13,7 +17,7 @@ function LitterList() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchLitters()
|
||||
fetchLitters(1)
|
||||
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
|
||||
const stored = sessionStorage.getItem('prefillLitter')
|
||||
if (stored) {
|
||||
@@ -27,10 +31,12 @@ function LitterList() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchLitters = async () => {
|
||||
const fetchLitters = async (p = page) => {
|
||||
try {
|
||||
const res = await axios.get('/api/litters')
|
||||
setLitters(res.data)
|
||||
const res = await axios.get('/api/litters', { params: { page: p, limit: LIMIT } })
|
||||
setLitters(res.data.data)
|
||||
setTotal(res.data.total)
|
||||
setPage(p)
|
||||
} catch (error) {
|
||||
console.error('Error fetching litters:', error)
|
||||
} finally {
|
||||
@@ -38,6 +44,8 @@ function LitterList() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / LIMIT)
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingLitter(null)
|
||||
setPrefill(null)
|
||||
@@ -56,14 +64,14 @@ function LitterList() {
|
||||
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
||||
try {
|
||||
await axios.delete(`/api/litters/${id}`)
|
||||
fetchLitters()
|
||||
fetchLitters(page)
|
||||
} catch (error) {
|
||||
console.error('Error deleting litter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
fetchLitters()
|
||||
fetchLitters(page)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -80,7 +88,7 @@ function LitterList() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{litters.length === 0 ? (
|
||||
{total === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
||||
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||
<h2>No litters recorded yet</h2>
|
||||
@@ -143,6 +151,31 @@ function LitterList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchLitters(page - 1)}
|
||||
disabled={page <= 1 || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchLitters(page + 1)}
|
||||
disabled={page >= totalPages || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<LitterForm
|
||||
litter={editingLitter}
|
||||
|
||||
@@ -11,9 +11,12 @@ export default function PairingSimulator() {
|
||||
const [dogsLoading, setDogsLoading] = useState(true)
|
||||
const [relationWarning, setRelationWarning] = useState(null)
|
||||
const [relationChecking, setRelationChecking] = useState(false)
|
||||
const [geneticRisk, setGeneticRisk] = useState(null)
|
||||
const [geneticChecking, setGeneticChecking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
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 || []))
|
||||
@@ -26,17 +29,28 @@ export default function PairingSimulator() {
|
||||
const checkRelation = useCallback(async (sid, did) => {
|
||||
if (!sid || !did) {
|
||||
setRelationWarning(null)
|
||||
setGeneticRisk(null)
|
||||
return
|
||||
}
|
||||
setRelationChecking(true)
|
||||
setGeneticChecking(true)
|
||||
try {
|
||||
const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
|
||||
const data = await res.json()
|
||||
setRelationWarning(data.related ? data.relationship : null)
|
||||
const [relRes, genRes] = await Promise.all([
|
||||
fetch(`/api/pedigree/relations/${sid}/${did}`),
|
||||
fetch(`/api/genetics/pairing-risk?sireId=${sid}&damId=${did}`)
|
||||
])
|
||||
|
||||
const relData = await relRes.json()
|
||||
setRelationWarning(relData.related ? relData.relationship : null)
|
||||
|
||||
const genData = await genRes.json()
|
||||
setGeneticRisk(genData)
|
||||
} catch {
|
||||
setRelationWarning(null)
|
||||
setGeneticRisk(null)
|
||||
} finally {
|
||||
setRelationChecking(false)
|
||||
setGeneticChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -54,9 +68,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 +78,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 +90,189 @@ 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 and genetics...
|
||||
</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>
|
||||
<ShieldAlert size={16} />
|
||||
<strong>Related:</strong> {relationWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
|
||||
<div style={{
|
||||
padding: '0.6rem 1rem', marginBottom: '0.75rem',
|
||||
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.3)',
|
||||
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--warning)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', fontWeight: 600 }}>
|
||||
<ShieldAlert size={16} /> Genetic Risks Detected
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
|
||||
{geneticRisk.risks.map(r => (
|
||||
<li key={r.marker}>
|
||||
<strong>{r.marker}</strong>: {r.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{geneticRisk && geneticRisk.missing_data && !geneticChecking && (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem', fontStyle: 'italic' }}>
|
||||
* Sire or dam has missing genetic tests. Clearances cannot be fully verified.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!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>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, GitBranch, AlertCircle, Loader } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import PedigreeTree from '../components/PedigreeTree'
|
||||
import { transformPedigreeData, formatCOI, getPedigreeCompleteness } from '../utils/pedigreeHelpers'
|
||||
import { transformPedigreeData, transformDescendantData, formatCOI, getPedigreeCompleteness } from '../utils/pedigreeHelpers'
|
||||
|
||||
function PedigreeView() {
|
||||
const { id } = useParams()
|
||||
@@ -14,28 +14,39 @@ function PedigreeView() {
|
||||
const [pedigreeData, setPedigreeData] = useState(null)
|
||||
const [coiData, setCoiData] = useState(null)
|
||||
const [generations, setGenerations] = useState(5)
|
||||
const [viewMode, setViewMode] = useState('ancestors')
|
||||
|
||||
useEffect(() => {
|
||||
fetchPedigreeData()
|
||||
}, [id, generations])
|
||||
}, [id, generations, viewMode])
|
||||
|
||||
const fetchPedigreeData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
|
||||
const dogData = pedigreeRes.data
|
||||
setDog(dogData)
|
||||
if (viewMode === 'ancestors') {
|
||||
const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
|
||||
const dogData = pedigreeRes.data
|
||||
setDog(dogData)
|
||||
|
||||
const treeData = transformPedigreeData(dogData, generations)
|
||||
setPedigreeData(treeData)
|
||||
const treeData = transformPedigreeData(dogData, generations)
|
||||
setPedigreeData(treeData)
|
||||
|
||||
try {
|
||||
const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
|
||||
setCoiData(coiRes.data)
|
||||
} catch (coiError) {
|
||||
console.warn('COI calculation unavailable:', coiError)
|
||||
try {
|
||||
const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
|
||||
setCoiData(coiRes.data)
|
||||
} catch (coiError) {
|
||||
console.warn('COI calculation unavailable:', coiError)
|
||||
setCoiData(null)
|
||||
}
|
||||
} else {
|
||||
const descendantRes = await axios.get(`/api/pedigree/${id}/descendants?generations=${generations}`)
|
||||
const dogData = descendantRes.data
|
||||
setDog(dogData)
|
||||
|
||||
const treeData = transformDescendantData(dogData, generations)
|
||||
setPedigreeData(treeData)
|
||||
setCoiData(null)
|
||||
}
|
||||
|
||||
@@ -86,7 +97,7 @@ function PedigreeView() {
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(`/dogs/${id}`)}
|
||||
@@ -96,10 +107,10 @@ function PedigreeView() {
|
||||
Back to Profile
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<GitBranch size={32} style={{ color: 'var(--primary)' }} />
|
||||
{dog?.name}'s Pedigree
|
||||
{dog?.name}'s {viewMode === 'ancestors' ? 'Pedigree' : 'Descendants'}
|
||||
</h1>
|
||||
{dog?.registration_number && (
|
||||
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0 0' }}>
|
||||
@@ -107,65 +118,86 @@ function PedigreeView() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', background: 'var(--bg-tertiary)', padding: '4px', borderRadius: 'var(--radius)' }}>
|
||||
<button
|
||||
className={`btn ${viewMode === 'ancestors' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setViewMode('ancestors')}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Ancestors
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${viewMode === 'descendants' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setViewMode('descendants')}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Descendants
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
|
||||
|
||||
{/* COI */}
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||
Coefficient of Inbreeding
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: '700', color: coiInfo.color }}>
|
||||
{coiInfo.value}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: coiInfo.color + '20',
|
||||
color: coiInfo.color,
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{coiInfo.level}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
{coiInfo.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completeness */}
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||
Pedigree Completeness
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
|
||||
{completeness}%
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${completeness}%`,
|
||||
background: barColor,
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.4s ease',
|
||||
boxShadow: `0 0 6px ${barColor}`
|
||||
}} />
|
||||
{viewMode === 'ancestors' && (
|
||||
<>
|
||||
{/* COI */}
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||
Coefficient of Inbreeding
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: '700', color: coiInfo.color }}>
|
||||
{coiInfo.value}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: coiInfo.color + '20',
|
||||
color: coiInfo.color,
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{coiInfo.level}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
{coiInfo.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completeness */}
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||
Pedigree Completeness
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
|
||||
{completeness}%
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${completeness}%`,
|
||||
background: barColor,
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.4s ease',
|
||||
boxShadow: `0 0 6px ${barColor}`
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Generations */}
|
||||
<div>
|
||||
|
||||
@@ -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',
|
||||
@@ -180,4 +180,48 @@ export const getPedigreeCompleteness = (treeData, targetGenerations = 5) => {
|
||||
const expectedTotal = Math.pow(2, targetGenerations) - 1
|
||||
const actualCount = countAncestors(treeData)
|
||||
return Math.min(100, Math.round((actualCount / expectedTotal) * 100))
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API descendant data to react-d3-tree format
|
||||
* @param {Object} dog - Dog object from API with nested offspring array
|
||||
* @param {number} maxGenerations - Maximum generations to display (default 3)
|
||||
* @returns {Object} Tree data in react-d3-tree format
|
||||
*/
|
||||
export const transformDescendantData = (dog, maxGenerations = 3) => {
|
||||
if (!dog) return null
|
||||
|
||||
const buildTree = (dogData, generation = 0) => {
|
||||
if (!dogData || generation >= maxGenerations) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node = {
|
||||
name: dogData.name || 'Unknown',
|
||||
attributes: {
|
||||
id: dogData.id,
|
||||
sex: dogData.sex,
|
||||
registration: dogData.registration_number || '',
|
||||
birth_year: dogData.birth_date ? new Date(dogData.birth_date).getFullYear() : ''
|
||||
},
|
||||
children: []
|
||||
}
|
||||
|
||||
if (dogData.offspring && dogData.offspring.length > 0) {
|
||||
dogData.offspring.forEach(child => {
|
||||
const childNode = buildTree(child, generation + 1)
|
||||
if (childNode) {
|
||||
node.children.push(childNode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
delete node.children
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return buildTree(dog)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
# BREEDR Verification Checklist
|
||||
|
||||
## Microchip Field Fix Verification
|
||||
|
||||
### ✅ Schema Files (All Correct)
|
||||
|
||||
#### 1. Database Schema: `server/db/init.js`
|
||||
- [x] **Line 29:** `microchip TEXT,` (no UNIQUE constraint)
|
||||
- [x] **Lines 38-43:** Partial unique index created
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
|
||||
ON dogs(microchip)
|
||||
WHERE microchip IS NOT NULL
|
||||
```
|
||||
|
||||
**Status:** ✅ Correct for future installations
|
||||
|
||||
---
|
||||
|
||||
#### 2. UI Form: `client/src/components/DogForm.jsx`
|
||||
- [x] **Line 150:** Microchip input has NO `required` attribute
|
||||
- [x] Label shows "Microchip Number" (no asterisk)
|
||||
- [x] Field is truly optional in the UI
|
||||
|
||||
**Status:** ✅ Correct - users can leave microchip blank
|
||||
|
||||
---
|
||||
|
||||
#### 3. Migration Script: `server/db/migrate_microchip.js`
|
||||
- [x] Exists and is executable
|
||||
- [x] Safely migrates existing databases
|
||||
- [x] Idempotent (can run multiple times)
|
||||
- [x] Preserves all data during migration
|
||||
|
||||
**Status:** ✅ Available for existing installations
|
||||
|
||||
---
|
||||
|
||||
#### 4. Migration Helper: `migrate-now.sh`
|
||||
- [x] Shell script for easy execution
|
||||
- [x] Checks if container is running
|
||||
- [x] Runs migration inside container
|
||||
- [x] Restarts container after migration
|
||||
|
||||
**Status:** ✅ User-friendly migration tool
|
||||
|
||||
---
|
||||
|
||||
#### 5. Documentation: `docs/MICROCHIP_FIX.md`
|
||||
- [x] Problem explanation
|
||||
- [x] Solution details
|
||||
- [x] Migration instructions (3 options)
|
||||
- [x] Verification tests
|
||||
- [x] Troubleshooting guide
|
||||
|
||||
**Status:** ✅ Complete documentation
|
||||
|
||||
---
|
||||
|
||||
#### 6. README: `README.md`
|
||||
- [x] Migration notice at top
|
||||
- [x] Link to detailed documentation
|
||||
- [x] Upgrade instructions included
|
||||
- [x] Recent updates section added
|
||||
|
||||
**Status:** ✅ Users will see migration notice
|
||||
|
||||
---
|
||||
|
||||
## For Future Installations
|
||||
|
||||
### Fresh Install (No Migration Needed)
|
||||
|
||||
When a user does a **fresh install** (no existing database):
|
||||
|
||||
1. Container starts
|
||||
2. `server/db/init.js` runs automatically
|
||||
3. Database created with **correct schema**
|
||||
4. Microchip field is **optional from the start**
|
||||
5. No migration required ✅
|
||||
|
||||
### Existing Installation (Migration Required)
|
||||
|
||||
When a user **upgrades** from an old version:
|
||||
|
||||
1. Pull latest code
|
||||
2. Rebuild Docker image
|
||||
3. Start container
|
||||
4. **Must run migration:** `docker exec -it breedr node server/db/migrate_microchip.js`
|
||||
5. Restart container
|
||||
6. Microchip field now optional ✅
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Test 1: Add Dog Without Microchip
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/dogs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TestDog1","breed":"Lab","sex":"male"}'
|
||||
```
|
||||
**Expected:** ✅ Success (201 Created)
|
||||
|
||||
### Test 2: Add Multiple Dogs Without Microchips
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/dogs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TestDog2","breed":"Lab","sex":"female"}'
|
||||
```
|
||||
**Expected:** ✅ Success (multiple NULL values allowed)
|
||||
|
||||
### Test 3: Add Dog With Microchip
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/dogs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TestDog3","breed":"Lab","sex":"male","microchip":"123456789"}'
|
||||
```
|
||||
**Expected:** ✅ Success
|
||||
|
||||
### Test 4: Duplicate Microchip Should Fail
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/dogs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TestDog4","breed":"Lab","sex":"female","microchip":"123456789"}'
|
||||
```
|
||||
**Expected:** ❌ Error (UNIQUE constraint still enforced for non-NULL)
|
||||
|
||||
---
|
||||
|
||||
## Database Verification
|
||||
|
||||
### Check Schema Directly
|
||||
|
||||
```bash
|
||||
# Enter container
|
||||
docker exec -it breedr sh
|
||||
|
||||
# Open SQLite CLI
|
||||
sqlite3 /app/data/breedr.db
|
||||
|
||||
# Check table schema
|
||||
.schema dogs
|
||||
|
||||
# Should show:
|
||||
# microchip TEXT, (no UNIQUE)
|
||||
|
||||
# Check indexes
|
||||
.indexes dogs
|
||||
|
||||
# Should show:
|
||||
# idx_dogs_microchip (partial index)
|
||||
|
||||
# Verify partial index
|
||||
SELECT sql FROM sqlite_master
|
||||
WHERE type='index' AND name='idx_dogs_microchip';
|
||||
|
||||
# Should show:
|
||||
# CREATE UNIQUE INDEX idx_dogs_microchip
|
||||
# ON dogs(microchip) WHERE microchip IS NOT NULL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If something goes wrong:
|
||||
|
||||
### Option A: Restore Backup
|
||||
```bash
|
||||
docker stop breedr
|
||||
cp /mnt/user/appdata/breedr/breedr.db.backup \
|
||||
/mnt/user/appdata/breedr/breedr.db
|
||||
docker start breedr
|
||||
```
|
||||
|
||||
### Option B: Re-run Migration
|
||||
```bash
|
||||
docker exec -it breedr node server/db/migrate_microchip.js
|
||||
docker restart breedr
|
||||
```
|
||||
|
||||
### Option C: Fresh Database (Data Loss)
|
||||
```bash
|
||||
docker stop breedr
|
||||
rm /mnt/user/appdata/breedr/breedr.db
|
||||
docker start breedr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Verification
|
||||
|
||||
After deploying to production:
|
||||
|
||||
- [ ] Check container logs for schema initialization
|
||||
- [ ] Verify database schema with SQLite CLI
|
||||
- [ ] Test adding dog without microchip via UI
|
||||
- [ ] Test adding dog with microchip via UI
|
||||
- [ ] Confirm no UNIQUE constraint errors
|
||||
- [ ] Verify partial index exists
|
||||
- [ ] Test duplicate microchip still fails
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: Still Getting UNIQUE Constraint Error
|
||||
|
||||
**Cause:** Migration not run on existing database
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
docker exec -it breedr node server/db/migrate_microchip.js
|
||||
docker restart breedr
|
||||
```
|
||||
|
||||
### Issue: Migration Script Not Found
|
||||
|
||||
**Cause:** Old code still in container
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd /mnt/user/appdata/breedr-build
|
||||
git pull
|
||||
docker build -t breedr:latest .
|
||||
docker stop breedr && docker rm breedr
|
||||
# Recreate container with new image
|
||||
```
|
||||
|
||||
### Issue: Microchip Required in UI
|
||||
|
||||
**Cause:** Browser cached old JavaScript
|
||||
|
||||
**Solution:**
|
||||
- Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
|
||||
- Clear browser cache
|
||||
- Try incognito/private window
|
||||
|
||||
---
|
||||
|
||||
## File Manifest
|
||||
|
||||
### Core Files (Must Be Present)
|
||||
- ✅ `server/db/init.js` - Database schema (corrected)
|
||||
- ✅ `server/db/migrate_microchip.js` - Migration script
|
||||
- ✅ `client/src/components/DogForm.jsx` - UI form (microchip optional)
|
||||
- ✅ `migrate-now.sh` - Helper script
|
||||
- ✅ `docs/MICROCHIP_FIX.md` - Detailed documentation
|
||||
- ✅ `docs/VERIFICATION_CHECKLIST.md` - This file
|
||||
- ✅ `README.md` - Updated with migration notice
|
||||
|
||||
### Validation Commands
|
||||
|
||||
```bash
|
||||
# Check all files exist
|
||||
ls -la server/db/init.js
|
||||
ls -la server/db/migrate_microchip.js
|
||||
ls -la client/src/components/DogForm.jsx
|
||||
ls -la migrate-now.sh
|
||||
ls -la docs/MICROCHIP_FIX.md
|
||||
ls -la docs/VERIFICATION_CHECKLIST.md
|
||||
|
||||
# Verify microchip field in init.js
|
||||
grep -n "microchip TEXT" server/db/init.js
|
||||
# Should show line 29 with NO UNIQUE
|
||||
|
||||
# Verify partial index
|
||||
grep -A2 "idx_dogs_microchip" server/db/init.js
|
||||
# Should show WHERE clause
|
||||
|
||||
# Verify form has no required
|
||||
grep -n 'name="microchip"' client/src/components/DogForm.jsx
|
||||
# Should NOT have required attribute
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
- [x] Schema file correct (no UNIQUE on microchip)
|
||||
- [x] Partial index created (WHERE IS NOT NULL)
|
||||
- [x] UI form allows empty microchip
|
||||
- [x] Migration script tested
|
||||
- [x] Documentation complete
|
||||
- [x] README updated
|
||||
- [x] Tests passing
|
||||
|
||||
### Post-Deployment Checklist
|
||||
- [ ] Container started successfully
|
||||
- [ ] Schema verified in database
|
||||
- [ ] UI allows empty microchip
|
||||
- [ ] Multiple NULL values work
|
||||
- [ ] Unique constraint still enforced for non-NULL
|
||||
- [ ] No errors in logs
|
||||
|
||||
---
|
||||
|
||||
**Last Verified:** March 8, 2026
|
||||
|
||||
**Status:** ✅ All files correct for future installations
|
||||
|
||||
**Migration Required:** Only for existing databases (one-time)
|
||||
@@ -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,46 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Cancer History ───────────────────────────────────────────────────
|
||||
const geneticMigrations = [
|
||||
['test_provider', 'TEXT'],
|
||||
['marker', "TEXT NOT NULL DEFAULT 'unknown'"],
|
||||
['result', "TEXT NOT NULL DEFAULT 'not_tested'"],
|
||||
['test_date', 'TEXT'],
|
||||
['document_url', 'TEXT'],
|
||||
['notes', 'TEXT']
|
||||
];
|
||||
for (const [col, def] of geneticMigrations) {
|
||||
try { db.exec(`ALTER TABLE genetic_tests ADD COLUMN ${col} ${def}`); } catch (_) {}
|
||||
}
|
||||
|
||||
// ── Cancer History ────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cancer_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL,
|
||||
cancer_type TEXT,
|
||||
age_at_diagnosis TEXT,
|
||||
age_at_death TEXT,
|
||||
cause_of_death TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
const cancerMigrations = [
|
||||
['cancer_type', 'TEXT'],
|
||||
['age_at_diagnosis', 'TEXT'],
|
||||
['age_at_death', 'TEXT'],
|
||||
['cause_of_death', 'TEXT'],
|
||||
['notes', 'TEXT']
|
||||
];
|
||||
for (const [col, def] of cancerMigrations) {
|
||||
try { db.exec(`ALTER TABLE cancer_history ADD COLUMN ${col} ${def}`); } catch (_) {}
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -31,15 +31,105 @@ 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 (paginated)
|
||||
// Default: kennel dogs only (is_external = 0)
|
||||
// ?include_external=1 : all active dogs (kennel + external)
|
||||
// ?external_only=1 : external dogs only
|
||||
// ?page=1&limit=50 : pagination
|
||||
// ?search=term : filter by name or registration_number
|
||||
// ?sex=male|female : filter by sex
|
||||
// Response: { data, total, page, limit, stats: { total, males, females } }
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
||||
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
||||
const search = (req.query.search || '').trim();
|
||||
const sex = req.query.sex === 'male' || req.query.sex === 'female' ? req.query.sex : '';
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let baseWhere;
|
||||
if (externalOnly) {
|
||||
baseWhere = 'is_active = 1 AND is_external = 1';
|
||||
} else if (includeExternal) {
|
||||
baseWhere = 'is_active = 1';
|
||||
} else {
|
||||
baseWhere = 'is_active = 1 AND is_external = 0';
|
||||
}
|
||||
|
||||
const filters = [];
|
||||
const params = [];
|
||||
if (search) {
|
||||
filters.push('(name LIKE ? OR registration_number LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
if (sex) {
|
||||
filters.push('sex = ?');
|
||||
params.push(sex);
|
||||
}
|
||||
|
||||
const whereClause = 'WHERE ' + [baseWhere, ...filters].join(' AND ');
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM dogs ${whereClause}`).get(...params).count;
|
||||
|
||||
const statsWhere = externalOnly
|
||||
? 'WHERE is_active = 1 AND is_external = 1'
|
||||
: includeExternal
|
||||
? 'WHERE is_active = 1'
|
||||
: 'WHERE is_active = 1 AND is_external = 0';
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN sex = 'male' THEN 1 ELSE 0 END) as males,
|
||||
SUM(CASE WHEN sex = 'female' THEN 1 ELSE 0 END) as females
|
||||
FROM dogs ${statsWhere}
|
||||
`).get();
|
||||
|
||||
const dogs = db.prepare(`
|
||||
SELECT ${DOG_COLS}
|
||||
FROM dogs
|
||||
${whereClause}
|
||||
ORDER BY name
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({ data: attachParents(db, dogs), total, page, limit, stats });
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET 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 +138,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 +174,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 +184,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 +197,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 +210,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 +222,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 +238,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 +246,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 +269,7 @@ router.put('/:id', (req, res) => {
|
||||
emptyToNull(notes),
|
||||
emptyToNull(litter_id),
|
||||
is_champion ? 1 : 0,
|
||||
is_external ? 1 : 0,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
@@ -202,10 +292,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 +300,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 +314,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 +334,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();
|
||||
|
||||
@@ -10,6 +10,7 @@ const GRCA_CORE = {
|
||||
heart: ['heart_ofa', 'heart_echo'],
|
||||
eye: ['eye_caer'],
|
||||
};
|
||||
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa', 'dna_panel'];
|
||||
|
||||
// Helper: compute clearance summary for a dog
|
||||
function getClearanceSummary(db, dogId) {
|
||||
@@ -103,17 +104,6 @@ router.get('/dog/:dogId/chic-eligible', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET single health record
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||
if (!record) return res.status(404).json({ error: 'Health record not found' });
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create health record
|
||||
router.post('/', (req, res) => {
|
||||
@@ -128,6 +118,10 @@ router.post('/', (req, res) => {
|
||||
return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' });
|
||||
}
|
||||
|
||||
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
|
||||
return res.status(400).json({ error: 'Invalid test_type' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const dbResult = db.prepare(`
|
||||
INSERT INTO health_records
|
||||
@@ -157,6 +151,10 @@ router.put('/:id', (req, res) => {
|
||||
document_url, result, vet_name, next_due, notes
|
||||
} = req.body;
|
||||
|
||||
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
|
||||
return res.status(400).json({ error: 'Invalid test_type' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
db.prepare(`
|
||||
UPDATE health_records
|
||||
@@ -190,4 +188,70 @@ router.delete('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET cancer history for a dog
|
||||
router.get('/dog/:dogId/cancer-history', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM cancer_history
|
||||
WHERE dog_id = ?
|
||||
ORDER BY age_at_diagnosis ASC, created_at DESC
|
||||
`).all(req.params.dogId);
|
||||
res.json(records);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create cancer history record
|
||||
router.post('/cancer-history', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes
|
||||
} = req.body;
|
||||
|
||||
if (!dog_id || !cancer_type) {
|
||||
return res.status(400).json({ error: 'dog_id and cancer_type are required' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Update dog's age_at_death and cause_of_death if provided
|
||||
if (age_at_death || cause_of_death) {
|
||||
db.prepare(`
|
||||
UPDATE dogs SET
|
||||
age_at_death = COALESCE(?, age_at_death),
|
||||
cause_of_death = COALESCE(?, cause_of_death)
|
||||
WHERE id = ?
|
||||
`).run(age_at_death || null, cause_of_death || null, dog_id);
|
||||
}
|
||||
|
||||
const dbResult = db.prepare(`
|
||||
INSERT INTO cancer_history
|
||||
(dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
dog_id, cancer_type, age_at_diagnosis || null,
|
||||
age_at_death || null, cause_of_death || null, notes || null
|
||||
);
|
||||
|
||||
const record = db.prepare('SELECT * FROM cancer_history WHERE id = ?').get(dbResult.lastInsertRowid);
|
||||
res.status(201).json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET single health record (wildcard should go last to prevent overlap)
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||
if (!record) return res.status(404).json({ error: 'Health record not found' });
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,31 +2,50 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDatabase } = require('../db/init');
|
||||
|
||||
// GET all litters
|
||||
// GET all litters (paginated)
|
||||
// ?page=1&limit=50
|
||||
// Response: { data, total, page, limit }
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) as count FROM litters').get().count;
|
||||
|
||||
const litters = db.prepare(`
|
||||
SELECT l.*,
|
||||
SELECT l.*,
|
||||
s.name as sire_name, s.registration_number as sire_reg,
|
||||
d.name as dam_name, d.registration_number as dam_reg
|
||||
FROM litters l
|
||||
JOIN dogs s ON l.sire_id = s.id
|
||||
JOIN dogs d ON l.dam_id = d.id
|
||||
ORDER BY l.breeding_date DESC
|
||||
`).all();
|
||||
|
||||
litters.forEach(litter => {
|
||||
litter.puppies = db.prepare(`
|
||||
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
||||
`).all(litter.id);
|
||||
litter.puppies.forEach(puppy => {
|
||||
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(limit, offset);
|
||||
|
||||
if (litters.length > 0) {
|
||||
const litterIds = litters.map(l => l.id);
|
||||
const placeholders = litterIds.map(() => '?').join(',');
|
||||
const allPuppies = db.prepare(`
|
||||
SELECT * FROM dogs WHERE litter_id IN (${placeholders}) AND is_active = 1
|
||||
`).all(...litterIds);
|
||||
|
||||
const puppiesByLitter = {};
|
||||
allPuppies.forEach(p => {
|
||||
p.photo_urls = p.photo_urls ? JSON.parse(p.photo_urls) : [];
|
||||
if (!puppiesByLitter[p.litter_id]) puppiesByLitter[p.litter_id] = [];
|
||||
puppiesByLitter[p.litter_id].push(p);
|
||||
});
|
||||
litter.actual_puppy_count = litter.puppies.length;
|
||||
});
|
||||
|
||||
res.json(litters);
|
||||
|
||||
litters.forEach(l => {
|
||||
l.puppies = puppiesByLitter[l.id] || [];
|
||||
l.actual_puppy_count = l.puppies.length;
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ data: litters, total, page, limit });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,25 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDatabase } = require('../db/init');
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const ancestorCache = new Map();
|
||||
const coiCache = new Map();
|
||||
|
||||
function getFromCache(cache, key, computeFn) {
|
||||
if (cache.has(key)) {
|
||||
const val = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, val);
|
||||
return val;
|
||||
}
|
||||
const val = computeFn();
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
cache.delete(cache.keys().next().value);
|
||||
}
|
||||
cache.set(key, val);
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* getAncestorMap(db, dogId, maxGen)
|
||||
* Returns Map<id, [{ id, name, generation }, ...]>
|
||||
@@ -9,24 +28,27 @@ const { getDatabase } = require('../db/init');
|
||||
* pairings are correctly detected by calculateCOI.
|
||||
*/
|
||||
function getAncestorMap(db, dogId, maxGen = 6) {
|
||||
const map = new Map();
|
||||
const cacheKey = `${dogId}-${maxGen}`;
|
||||
return getFromCache(ancestorCache, cacheKey, () => {
|
||||
const map = new Map();
|
||||
|
||||
function recurse(id, gen) {
|
||||
if (gen > maxGen) return;
|
||||
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
|
||||
if (!dog) return;
|
||||
if (!map.has(id)) map.set(id, []);
|
||||
map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
|
||||
if (map.get(id).length === 1) {
|
||||
const parents = db.prepare(`
|
||||
SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
|
||||
`).all(id);
|
||||
parents.forEach(p => recurse(p.parent_id, gen + 1));
|
||||
function recurse(id, gen) {
|
||||
if (gen > maxGen) return;
|
||||
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
|
||||
if (!dog) return;
|
||||
if (!map.has(id)) map.set(id, []);
|
||||
map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
|
||||
if (map.get(id).length === 1) {
|
||||
const parents = db.prepare(`
|
||||
SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
|
||||
`).all(id);
|
||||
parents.forEach(p => recurse(p.parent_id, gen + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurse(parseInt(dogId), 0);
|
||||
return map;
|
||||
recurse(parseInt(dogId), 0);
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,54 +90,57 @@ function isDirectRelation(db, sireId, damId) {
|
||||
* self-loops.
|
||||
*/
|
||||
function calculateCOI(db, sireId, damId) {
|
||||
const sid = parseInt(sireId);
|
||||
const did = parseInt(damId);
|
||||
const sireMap = getAncestorMap(db, sid);
|
||||
const damMap = getAncestorMap(db, did);
|
||||
const cacheKey = `${sireId}-${damId}`;
|
||||
return getFromCache(coiCache, cacheKey, () => {
|
||||
const sid = parseInt(sireId);
|
||||
const did = parseInt(damId);
|
||||
const sireMap = getAncestorMap(db, sid);
|
||||
const damMap = getAncestorMap(db, did);
|
||||
|
||||
// Common ancestors: in BOTH maps, but:
|
||||
// - not the dam itself appearing in sireMap (would be a loop)
|
||||
// - not the sire itself appearing in damMap already handled below
|
||||
// We collect all IDs present in both, excluding only the direct
|
||||
// subjects (did from sireMap side, sid excluded already since we
|
||||
// iterate sireMap keys — but sid IS in sireMap at gen 0, and if
|
||||
// damMap also has sid, that is the parent×offspring case we WANT).
|
||||
const commonIds = [...sireMap.keys()].filter(
|
||||
id => damMap.has(id) && id !== did
|
||||
);
|
||||
// Common ancestors: in BOTH maps, but:
|
||||
// - not the dam itself appearing in sireMap (would be a loop)
|
||||
// - not the sire itself appearing in damMap already handled below
|
||||
// We collect all IDs present in both, excluding only the direct
|
||||
// subjects (did from sireMap side, sid excluded already since we
|
||||
// iterate sireMap keys — but sid IS in sireMap at gen 0, and if
|
||||
// damMap also has sid, that is the parent×offspring case we WANT).
|
||||
const commonIds = [...sireMap.keys()].filter(
|
||||
id => damMap.has(id) && id !== did
|
||||
);
|
||||
|
||||
let coi = 0;
|
||||
const processedPaths = new Set();
|
||||
const commonAncestorList = [];
|
||||
let coi = 0;
|
||||
const processedPaths = new Set();
|
||||
const commonAncestorList = [];
|
||||
|
||||
commonIds.forEach(ancId => {
|
||||
const sireOccs = sireMap.get(ancId);
|
||||
const damOccs = damMap.get(ancId);
|
||||
commonIds.forEach(ancId => {
|
||||
const sireOccs = sireMap.get(ancId);
|
||||
const damOccs = damMap.get(ancId);
|
||||
|
||||
sireOccs.forEach(so => {
|
||||
damOccs.forEach(do_ => {
|
||||
const key = `${ancId}-${so.generation}-${do_.generation}`;
|
||||
if (!processedPaths.has(key)) {
|
||||
processedPaths.add(key);
|
||||
coi += Math.pow(0.5, so.generation + do_.generation + 1);
|
||||
}
|
||||
sireOccs.forEach(so => {
|
||||
damOccs.forEach(do_ => {
|
||||
const key = `${ancId}-${so.generation}-${do_.generation}`;
|
||||
if (!processedPaths.has(key)) {
|
||||
processedPaths.add(key);
|
||||
coi += Math.pow(0.5, so.generation + do_.generation + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||
const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||
commonAncestorList.push({
|
||||
id: ancId,
|
||||
name: sireOccs[0].name,
|
||||
sireGen: closestSire.generation,
|
||||
damGen: closestDam.generation
|
||||
});
|
||||
});
|
||||
|
||||
const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||
const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||
commonAncestorList.push({
|
||||
id: ancId,
|
||||
name: sireOccs[0].name,
|
||||
sireGen: closestSire.generation,
|
||||
damGen: closestDam.generation
|
||||
});
|
||||
return {
|
||||
coefficient: coi,
|
||||
commonAncestors: commonAncestorList
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
coefficient: Math.round(coi * 10000) / 100,
|
||||
commonAncestors: commonAncestorList
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -124,8 +149,7 @@ 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) => {
|
||||
const handleTrialPairing = (req, res) => {
|
||||
try {
|
||||
const { sire_id, dam_id } = req.body;
|
||||
if (!sire_id || !dam_id) {
|
||||
@@ -149,13 +173,41 @@ 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) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/pedigree/trial-pairing
|
||||
router.post('/trial-pairing', handleTrialPairing);
|
||||
|
||||
// POST /api/pedigree/coi
|
||||
router.post('/coi', handleTrialPairing);
|
||||
|
||||
// GET /api/pedigree/:id/coi
|
||||
router.get('/:id/coi', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(req.params.id);
|
||||
const sire = parents.find(p => p.parent_type === 'sire');
|
||||
const dam = parents.find(p => p.parent_type === 'dam');
|
||||
|
||||
if (!sire || !dam) {
|
||||
return res.json({ coi: 0, commonAncestors: [], message: 'Incomplete parent data' });
|
||||
}
|
||||
|
||||
const result = calculateCOI(db, sire.parent_id, dam.parent_id);
|
||||
res.json({
|
||||
coi: result.coefficient,
|
||||
commonAncestors: result.commonAncestors
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pedigree/relations/:sireId/:damId
|
||||
@@ -168,6 +220,63 @@ router.get('/relations/:sireId/:damId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pedigree/:id/cancer-lineage
|
||||
router.get('/:id/cancer-lineage', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
// Get ancestor map up to 5 generations
|
||||
const ancestorMap = getAncestorMap(db, req.params.id, 5);
|
||||
|
||||
// Collect all unique ancestor IDs
|
||||
const ancestorIds = Array.from(ancestorMap.keys());
|
||||
|
||||
if (ancestorIds.length === 0) {
|
||||
return res.json({ lineage_cases: [], stats: { total_ancestors: 0, ancestors_with_cancer: 0 } });
|
||||
}
|
||||
|
||||
// Query cancer history for all ancestors
|
||||
const placeholders = ancestorIds.map(() => '?').join(',');
|
||||
const cancerRecords = db.prepare(`
|
||||
SELECT c.*, d.name, d.sex
|
||||
FROM cancer_history c
|
||||
JOIN dogs d ON c.dog_id = d.id
|
||||
WHERE c.dog_id IN (${placeholders})
|
||||
`).all(...ancestorIds);
|
||||
|
||||
// Structure the response
|
||||
const cases = cancerRecords.map(record => {
|
||||
// Find the closest generation this ancestor appears in
|
||||
const occurrences = ancestorMap.get(record.dog_id);
|
||||
const closestGen = occurrences.reduce((min, occ) => occ.generation < min ? occ.generation : min, 999);
|
||||
|
||||
return {
|
||||
...record,
|
||||
generation_distance: closestGen
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by generation distance (closer relatives first)
|
||||
cases.sort((a, b) => a.generation_distance - b.generation_distance);
|
||||
|
||||
// Count unique dogs with cancer (excluding generation 0 if we only want stats on ancestors)
|
||||
const ancestorCases = cases.filter(c => c.generation_distance > 0);
|
||||
const uniqueAncestorsWithCancer = new Set(ancestorCases.map(c => c.dog_id)).size;
|
||||
|
||||
// Number of ancestors is total unique IDs minus 1 for the dog itself
|
||||
const numAncestors = ancestorIds.length > 0 && ancestorMap.get(parseInt(req.params.id)) ? ancestorIds.length - 1 : ancestorIds.length;
|
||||
|
||||
res.json({
|
||||
lineage_cases: cases,
|
||||
stats: {
|
||||
total_ancestors: numAncestors,
|
||||
ancestors_with_cancer: uniqueAncestorsWithCancer
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Wildcard routes last
|
||||
// =====================================================================
|
||||
|
||||
26
server/test_app.js
Normal file
26
server/test_app.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const app = require('./index');
|
||||
const http = require('http');
|
||||
|
||||
// Start temporary server
|
||||
const server = http.createServer(app);
|
||||
server.listen(3030, async () => {
|
||||
console.log('Server started on 3030');
|
||||
try {
|
||||
const res = await fetch('http://localhost:3030/api/pedigree/relations/1/2');
|
||||
const text = await res.text();
|
||||
console.log('GET /api/pedigree/relations/1/2 RESPONSE:', res.status, text.substring(0, 150));
|
||||
|
||||
const postRes = await fetch('http://localhost:3030/api/pedigree/trial-pairing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sire_id: 1, dam_id: 2 })
|
||||
});
|
||||
const postText = await postRes.text();
|
||||
console.log('POST /api/pedigree/trial-pairing RESPONSE:', postRes.status, postText.substring(0, 150));
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
8
server/test_express.js
Normal file
8
server/test_express.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.post(['/a', '/b'], (req, res) => {
|
||||
res.send('ok');
|
||||
});
|
||||
|
||||
console.log('Started successfully');
|
||||
Reference in New Issue
Block a user