Compare commits
No commits in common. "b811b5c1a21d3f5ce63609667457afb5babcd9e5" and "4544aa8060941c85cc70be934686cdbc3e6d6331" have entirely different histories.
b811b5c1a2
...
4544aa8060
|
|
@ -1,313 +0,0 @@
|
||||||
# MyKindlebox — OCR Pedigree Scanning Feature
|
|
||||||
|
|
||||||
**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
|
|
||||||
**Stack:** Node.js / React / SQLite
|
|
||||||
**Packages:** `tesseract.js`, `jimp`, `react-image-crop`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Point
|
|
||||||
|
|
||||||
OCR scanning integrates into the **existing Add Rabbit wizard** rather than a separate route. No `/herd/scan` page is needed.
|
|
||||||
|
|
||||||
- Step 1 of the wizard gets a **"Scan Pedigree"** button alongside the existing manual entry option
|
|
||||||
- Scanning pre-fills the wizard's `PedigreeTree` + `details` state
|
|
||||||
- After OCR completes, the user is dropped into the normal wizard flow at Step 1 with fields populated
|
|
||||||
- The zone picker panel renders within the wizard context — no separate route or modal
|
|
||||||
- The existing wizard review/save path handles confirmation; no duplicate save flow is needed
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Allow users to photograph or upload a printed rabbit pedigree and have the app auto-populate the pedigree entry form. The system attempts automatic extraction first; if any field is wrong or missing, the user can click that field and manually draw a zone on the image to re-run OCR on just that region.
|
|
||||||
|
|
||||||
This feature does **not** use any LLM API calls. All processing is client-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural Decisions
|
|
||||||
|
|
||||||
### Q1 — Integration point
|
|
||||||
Resolved: option (a). OCR integrates into the existing Add Rabbit wizard via a "Scan Pedigree" button on Step 1. No separate `/herd/scan` route. See Integration Point section above.
|
|
||||||
|
|
||||||
### Q2 — How extracted ancestors land in PedigreeTree
|
|
||||||
- **Gen1 (sire/dam):** attempt herd match first using `rabbitName` + `earNumber` as the lookup key against existing herd rabbits via the existing autocomplete/search. On confident match → insert as a linked node. On no match → fall back to `serializeManualAncestor`. No user prompt to resolve — auto-link or auto-manual, silently.
|
|
||||||
- **Gen2/gen3:** always insert as manual JSON nodes. No FK columns exist below `sire_id`/`dam_id`. This is the intended target and no special handling is needed.
|
|
||||||
|
|
||||||
### Q3 — Subject populates both wizard steps simultaneously
|
|
||||||
Yes. On OCR complete, both state slices are pre-filled at once:
|
|
||||||
- `PedigreeTree` state ← sire, dam, gen2/gen3 ancestors
|
|
||||||
- `details` state ← subject `farmName` → `farm`, `rabbitName` → `name`, `earNumber` → `ear_number`, `dob` → `dob`, `weight` → `weight_lbs`
|
|
||||||
|
|
||||||
User reviews Step 1 (tree), advances normally, reviews Step 2 (details) which is already populated, confirms and saves. No special wizard behavior beyond pre-populating both slices.
|
|
||||||
|
|
||||||
### Q4 — Weight and DOB normalization
|
|
||||||
- **Weight:** normalize to decimal lbs at extraction time. Examples: `"8 lbs 4 oz"` → `8.25`, `"8.5 lbs"` → `8.5`, `"142 oz"` → `8.875`. The app's lb+oz vs decimal display setting handles rendering; storage is always a decimal float.
|
|
||||||
- **DOB:** convert to `YYYY-MM-DD` at extraction time. Common input formats: `MM/DD/YYYY`, `MM-DD-YY`, `M/D/YY`. If parsing fails or no DOB is found, use `"Unknown"` — the app's existing sentinel convention for manual ancestors.
|
|
||||||
|
|
||||||
### Q5 — Zone picker behavior for ancestor slots
|
|
||||||
Zone picker result opens/refills the slot's **manual-entry editor** — it does not write silently to the tree. Ancestor slots are mode-bearing buttons; the user must see the populated values in the editor and explicitly confirm. Silent write would bypass the existing UX contract for those slots.
|
|
||||||
|
|
||||||
### Q6 — Locking & matching behavior
|
|
||||||
The pedigree tree's chain-authority locking (a gen1 node linked to a herd rabbit causes that branch's grandparents to lock with a 🔒 and derive from the linked rabbit's own record) interacts with OCR population. Resolved model:
|
|
||||||
|
|
||||||
1. **OCR populates everything as editable manual nodes.** Subject → `details`; sire/dam/grandparents → manual ancestor JSON nodes (`mode: 'manual'`). Nothing locks during Step 1 — every scanned node is reviewable/editable exactly like hand entry, right up until the user clicks Next.
|
|
||||||
2. **Gen1 matches resolve into links at the Next boundary, not live.** When the user advances from Step 1, each gen1 node (sire/dam) that *confidently* matches an existing herd rabbit is converted to a linked node (`mode: 'linked'`, setting `sire_id`/`dam_id`). That branch's grandparents then **lock and defer to the stored record** — the OCR-extracted grandparents on that branch are intentionally **discarded** in favor of the linked rabbit's own pedigree (the herd record is authoritative).
|
|
||||||
3. **Unmatched ancestors stay manual.** A gen1 with no confident match remains a manual node, fully editable, and its scanned grandparents persist as manual `_name_text` JSON. Identical to hand entry.
|
|
||||||
|
|
||||||
**Mechanical caveat — only gen1 can link/lock.** The schema has FK columns only for the subject's sire/dam (`sire_id`/`dam_id`); there is no column to tie a grandparent (gen2/gen3) directly to a herd rabbit. Therefore:
|
|
||||||
- A gen2/gen3 node can never produce a link or a lock *on its own*, even if its scanned name happens to exist in the herd.
|
|
||||||
- A grandparent only ends up locked/stored-derived **because its gen1 parent linked** (chain-authority cascade). If gen1 stays manual, its grandparents stay manual text regardless.
|
|
||||||
- So "does this scanned ancestor match a herd rabbit?" is only ever asked at gen1; the gen2/gen3 case folds entirely into "did its gen1 parent match."
|
|
||||||
|
|
||||||
"Confident match" definition (gen1): exact `ear_number` match → link; else exactly one exact-`name` match → link; otherwise → manual node. Ear number takes precedence as the primary key (unique-ish and OCR-reliable); the existing `GET /api/rabbits/search` substring search is too loose to auto-link on directly.
|
|
||||||
|
|
||||||
### Q7 — Tesseract assets are self-hosted, not CDN-fetched
|
|
||||||
By default `tesseract.js` downloads its WASM core, worker script, and the `eng.traineddata` language file (~10–15 MB) from a CDN (jsDelivr/unpkg) on first use. This app is **self-hosted on a single PC** and is built to work offline (service worker, offline banner, PWA), so all OCR assets must be **bundled and served locally** — no third-party CDN calls at runtime.
|
|
||||||
|
|
||||||
- Vendor the WASM core, worker, and `eng.traineddata` into the client (e.g. `client/public/tesseract/`) and point `tesseract.js` at them via its `workerPath` / `corePath` / `langPath` options.
|
|
||||||
- The service worker (`public/sw.js`) should treat these as cacheable app-shell assets so the scanner works fully offline after first load. (Note: the `.traineddata` is several MB — confirm the SW cache strategy handles it; it's static and versioned, so cache-first is appropriate, unlike the network-first app shell.)
|
|
||||||
- Accept the resulting client bundle/footprint increase as a deliberate trade for offline, dependency-self-contained operation — consistent with the project's self-hosted ethos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Supported Input
|
|
||||||
|
|
||||||
- Printed pedigrees of any layout (ARBA-standard and non-standard)
|
|
||||||
- 3-generation family trees (subject + 2 parents + 4 grandparents)
|
|
||||||
- Photo captures from device camera or file upload
|
|
||||||
- Scanned flat documents or photographed paper (angled/shadowed)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] Image Input
|
|
||||||
↓
|
|
||||||
[2] Client-side Preprocessing
|
|
||||||
↓
|
|
||||||
[3] Tesseract.js Full-Image OCR (with bounding boxes)
|
|
||||||
↓
|
|
||||||
[4] Layout Analysis → Tree Position Mapping
|
|
||||||
↓
|
|
||||||
[5] Form Population (nulls highlighted)
|
|
||||||
↓
|
|
||||||
[6] User Review
|
|
||||||
├── Happy → Save
|
|
||||||
└── Field wrong → Zone Picker → Re-OCR → Field Update → Save
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step-by-Step Implementation
|
|
||||||
|
|
||||||
### Step 1 — Image Input
|
|
||||||
|
|
||||||
Use a standard file input with camera capture support:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Both file picker and camera capture work via this single input on mobile and desktop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2 — Client-Side Preprocessing
|
|
||||||
|
|
||||||
Before passing to Tesseract, normalize the image using canvas or `jimp`:
|
|
||||||
|
|
||||||
- **Resize:** Clamp max width to 1600px. Larger images don't improve OCR and slow processing.
|
|
||||||
- **Grayscale:** Convert to single-channel.
|
|
||||||
- **Binarization (threshold):** Apply adaptive threshold to convert to black-and-white. This is the single most impactful step for photos of paper.
|
|
||||||
- **Deskew:** Optional. Detect dominant angle via Hough transform and rotate to correct. Skip if too complex for v1 — binarization recovers most of the value.
|
|
||||||
|
|
||||||
Recommended: use the canvas API for resize and grayscale, then apply a simple threshold pass. `jimp` can handle all of this if preferred.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3 — Tesseract.js OCR
|
|
||||||
|
|
||||||
Run Tesseract on the preprocessed image with bounding box output enabled:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import Tesseract from 'tesseract.js';
|
|
||||||
|
|
||||||
const { data } = await Tesseract.recognize(preprocessedImage, 'eng', {
|
|
||||||
logger: m => console.log(m),
|
|
||||||
});
|
|
||||||
|
|
||||||
// data.words → array of { text, bbox: { x0, y0, x1, y1 }, confidence }
|
|
||||||
// data.lines → array of { text, bbox, words[] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `data.words` with bounding boxes as the primary input to layout analysis. Discard words with confidence below ~60.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4 — Layout Analysis
|
|
||||||
|
|
||||||
Pedigrees are structured as a left-to-right generational tree. Map word bounding boxes to tree positions using x/y clustering.
|
|
||||||
|
|
||||||
#### Column detection
|
|
||||||
|
|
||||||
Divide the image width into 3 bands (or 4 if great-grandparents are present):
|
|
||||||
|
|
||||||
| Band | Generation |
|
|
||||||
|------|------------|
|
|
||||||
| Leftmost ~25% | Subject |
|
|
||||||
| 25–50% | Parents (sire top, dam bottom) |
|
|
||||||
| 50–75% | Grandparents (sire_sire, sire_dam, dam_sire, dam_dam) |
|
|
||||||
| 75–100% | Great-grandparents (optional) |
|
|
||||||
|
|
||||||
Band boundaries can be tuned — start with even splits of image width.
|
|
||||||
|
|
||||||
#### Sire / dam split within each column
|
|
||||||
|
|
||||||
For each column, sort all words by `y0`. Find the largest vertical gap — this is the dividing line between the sire half and dam half. Everything above the gap → sire lineage, below → dam lineage.
|
|
||||||
|
|
||||||
#### Text aggregation
|
|
||||||
|
|
||||||
Within each detected cell region, sort words by `y0` then `x0` and join into lines. The first line is typically the rabbit's name, subsequent lines are metadata (tattoo, DOB, breed, variety, weight).
|
|
||||||
|
|
||||||
#### Field parsing
|
|
||||||
|
|
||||||
Only five fields are extracted per cell:
|
|
||||||
|
|
||||||
- **Name:** first non-label line in the cell. Split on possessive pattern to extract farm name and rabbit name:
|
|
||||||
```js
|
|
||||||
function parsePedigreeName(fullName) {
|
|
||||||
// Handles both "Cody's Black Betty" and "Peachy Buns' Hoppin-pop"
|
|
||||||
const match = fullName.match(/^(.+?'s?)\s+(.+)$/);
|
|
||||||
if (match) return { farmName: match[1], rabbitName: match[2] };
|
|
||||||
return { farmName: null, rabbitName: fullName };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Ear number:** matches `[A-Z0-9]{2,8}` after label "Tattoo:", "Ear #:", "Ear No:", or similar
|
|
||||||
- **DOB:** matches date patterns (MM/DD/YYYY, MM-DD-YY, etc.)
|
|
||||||
- **Weight:** matches `\d+(\.\d+)?\s*(lbs?|oz)?`
|
|
||||||
|
|
||||||
Breed, variety, sex, and all other fields are not extracted. The user fills them in manually after scanning.
|
|
||||||
|
|
||||||
Unmatched or low-confidence fields → `null`.
|
|
||||||
|
|
||||||
#### Output shape
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
subject: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire_sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire_dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam_sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam_dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
}
|
|
||||||
// breed, variety, sex — user fills in manually after scanning
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5 — Form Population
|
|
||||||
|
|
||||||
Populate the existing pedigree entry form with extracted values.
|
|
||||||
|
|
||||||
- **Filled fields:** render normally
|
|
||||||
- **Null fields:** highlight with yellow background and a small "?" indicator
|
|
||||||
- Show a banner: *"OCR complete — review highlighted fields before saving."*
|
|
||||||
|
|
||||||
Do not auto-save. The user must explicitly confirm.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 6 — User Review & Zone Picker
|
|
||||||
|
|
||||||
#### Normal flow
|
|
||||||
|
|
||||||
User scans the form, corrects any highlighted or wrong fields by typing, and saves.
|
|
||||||
|
|
||||||
#### Zone picker flow (triggered per field)
|
|
||||||
|
|
||||||
When the user clicks a field they want to correct using the image:
|
|
||||||
|
|
||||||
1. A panel slides open showing the original (un-preprocessed) image
|
|
||||||
2. The bounding region currently assigned to that field is highlighted with a colored overlay
|
|
||||||
3. User drags to draw a new rectangle over the correct area on the image
|
|
||||||
4. On release, `react-image-crop` returns the crop coordinates
|
|
||||||
5. Crop is extracted from the preprocessed image, passed back to Tesseract
|
|
||||||
6. Tesseract result populates the field
|
|
||||||
7. User can accept or discard the re-OCR result
|
|
||||||
|
|
||||||
The zone picker panel should sit alongside or below the form, not in a modal, so the user can see both at once.
|
|
||||||
|
|
||||||
#### Zone picker trigger
|
|
||||||
|
|
||||||
Add a small camera/scan icon button next to each form field. Clicking the field label or the icon opens the zone picker for that field. Do not require the user to enter a separate "correction mode."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
PedigreeScanFlow/
|
|
||||||
├── ImageCapture — file input + camera, triggers preprocessing
|
|
||||||
├── OCRProcessor — runs Tesseract, exposes result + status
|
|
||||||
├── LayoutAnalyzer — maps bounding boxes to tree positions
|
|
||||||
├── PedigreeFormWithOCR — existing pedigree form, extended with:
|
|
||||||
│ ├── OCR value injection
|
|
||||||
│ ├── Null field highlighting
|
|
||||||
│ └── Zone picker trigger per field
|
|
||||||
└── ZonePicker — image display + react-image-crop + re-OCR
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State Shape
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
rawImage: Blob, // original upload
|
|
||||||
processedImage: ImageData, // after preprocessing
|
|
||||||
ocrResult: { // output of layout analysis
|
|
||||||
subject, sire, dam,
|
|
||||||
sire_sire, sire_dam,
|
|
||||||
dam_sire, dam_dam
|
|
||||||
},
|
|
||||||
ocrStatus: 'idle' | 'preprocessing' | 'scanning' | 'done' | 'error',
|
|
||||||
activeZoneField: string|null, // which field has zone picker open
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `tesseract.js` | Client-side OCR |
|
|
||||||
| `jimp` | Image preprocessing (grayscale, threshold, resize) |
|
|
||||||
| `react-image-crop` | Zone selection UI for manual correction |
|
|
||||||
|
|
||||||
All processing is client-side. No server calls, no API tokens.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- If Tesseract fails entirely → show error, fall back to manual entry
|
|
||||||
- If all fields come back null → warn the user the image may be too blurry or skewed, suggest retaking
|
|
||||||
- Individual null fields → highlighted in form, zone picker available
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope (v1)
|
|
||||||
|
|
||||||
- Deskew / perspective correction (nice to have, not required)
|
|
||||||
- Great-grandparent (4th generation) extraction
|
|
||||||
- Handwriting support (degrades gracefully to nulls, zone picker handles correction)
|
|
||||||
- Multi-page pedigrees
|
|
||||||
|
|
@ -1,489 +0,0 @@
|
||||||
# MyKindlebox — OCR Pedigree Scanning: Implementation Plan
|
|
||||||
|
|
||||||
**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
|
|
||||||
**Reference:** `FEATURE_OCR_Pedigree_Scan.md`, `architecture.md`
|
|
||||||
**Scope:** Base feature only — image capture, OCR pipeline, form pre-fill, zone picker correction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This plan wires a "Scan Pedigree" button into the existing Add Rabbit wizard (Step 1). Tapping it lets the user photograph or upload a printed pedigree; the app extracts up to five fields per ancestor cell and pre-fills the wizard. The user reviews, corrects with a zone picker if needed, then proceeds through the normal wizard flow.
|
|
||||||
|
|
||||||
All OCR processing is client-side. No server changes are required for the OCR feature itself (Phase 5 adds one new API call for herd matching).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural notes
|
|
||||||
|
|
||||||
**PedigreeTree injection:** `PedigreeTree` reads `initialState` once at mount and owns its state internally. OCR results must be ready before `PedigreeTree` mounts. The pattern mirrors the existing "Add Kit" flow: block `PedigreeTree` render in `AddRabbit.jsx` until the OCR `initialState` object is fully built, then mount. If the user rescans, bump `PedigreeTree`'s `key` prop to remount with a fresh `initialState`. Do not refactor `PedigreeTree` to accept controlled post-mount updates.
|
|
||||||
|
|
||||||
**Chain-authority cascade:** `PedigreeTree` cascades live — the moment a gen1 node is `mode: 'linked'`, `fetchCascade` fires and its grandparent branch locks immediately. There is no deferred-link model. If a gen1 node matches a herd rabbit, seed it as `mode: 'linked'` in `initialState`; the OCR-extracted grandparents on that branch are discarded at mount and replaced by the linked rabbit's stored pedigree. This is correct behavior — the herd record is authoritative.
|
|
||||||
|
|
||||||
**Herd matching endpoint:** Use `GET /api/rabbits/quick-search?fields=ear_number&q=<earNumber>` and filter client-side to an exact `ear_number` match. If no exact ear number match, fall back to `quick-search?fields=name&q=<rabbitName>` with a single exact name match. Do not use `/search` — it is name-only substring and will not match ear numbers.
|
|
||||||
|
|
||||||
**Field remapping:** OCR emits `{ farmName, rabbitName, earNumber, weight, dob }`. `serializeManualAncestor` expects `{ name, farm, ear_number, weight_lbs, dob }`. Always remap explicitly: `rabbitName → name`, `farmName → farm`, `earNumber → ear_number`, `weight → weight_lbs`. Never pass OCR output to `serializeManualAncestor` directly.
|
|
||||||
|
|
||||||
**Zone picker image:** The zone picker displays the preprocessed image blob (not the original). All stored bounding box coordinates are in preprocessed (≤1600px) coordinate space and only align correctly against the preprocessed image. Discard the original file after preprocessing completes — only the preprocessed blob is retained in state.
|
|
||||||
|
|
||||||
**Null farmName and the Save guard:** The manual-entry editor requires farm + name + ear number before enabling Save. An ancestor whose OCR-extracted `farmName` is null will pre-fill with an empty farm field and the Save button will be disabled until the user fills it in. The yellow highlight on the farm field signals this. This is intentional — do not weaken the existing Save guard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Install and vendor OCR dependencies
|
|
||||||
|
|
||||||
### Step 1.1 — Install packages
|
|
||||||
|
|
||||||
Install the two required packages into the client:
|
|
||||||
|
|
||||||
- `tesseract.js`
|
|
||||||
- `react-image-crop`
|
|
||||||
|
|
||||||
Do not install `jimp` — all image preprocessing is handled via the canvas API (see Phase 2).
|
|
||||||
|
|
||||||
**Test:** Run the client dev server. Confirm no import errors in the browser console. Confirm both packages appear in `client/package.json` dependencies.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 1.2 — Vendor Tesseract assets locally
|
|
||||||
|
|
||||||
Copy the following Tesseract runtime assets into `client/public/tesseract/`:
|
|
||||||
|
|
||||||
- The Tesseract WASM core file
|
|
||||||
- The Tesseract worker script
|
|
||||||
- The English language training data file (`eng.traineddata`)
|
|
||||||
|
|
||||||
These files must be served from the app itself — no CDN calls at runtime. The app is self-hosted and must work offline.
|
|
||||||
|
|
||||||
Configure `tesseract.js` (wherever it is initialized) to point `workerPath`, `corePath`, and `langPath` at `./tesseract/` relative to the app's public root.
|
|
||||||
|
|
||||||
**Test:** Disconnect the dev machine from the internet. Load the app. Trigger a Tesseract recognize call on any test image (a photo of text). Confirm it completes successfully without any network requests to jsDelivr, unpkg, or any external CDN. Check the Network tab in DevTools to verify.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 1.3 — Add Tesseract assets to service worker cache
|
|
||||||
|
|
||||||
In `client/public/sw.js`, add the `tesseract/` directory assets to the app-shell cache list alongside other static assets. Use cache-first strategy for these files — they are static, versioned, and large.
|
|
||||||
|
|
||||||
**Test:** Load the app once online. Open DevTools → Application → Cache Storage. Confirm the Tesseract WASM, worker, and `eng.traineddata` files are present in the cache. Disconnect from the internet and reload. Confirm the scanner still initializes without errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Build the preprocessing utility
|
|
||||||
|
|
||||||
### Step 2.1 — Create `client/src/lib/ocrPreprocess.js`
|
|
||||||
|
|
||||||
Create a new utility module using only the browser canvas API (no third-party image library). It exports a single async function `preprocessImage(file)` that accepts a `File` or `Blob` and returns a processed image blob ready for Tesseract.
|
|
||||||
|
|
||||||
The function must perform these steps in order:
|
|
||||||
|
|
||||||
1. Decode the file into an `HTMLImageElement` via `URL.createObjectURL`
|
|
||||||
2. Draw it onto a canvas, scaling down proportionally if width exceeds 1600px
|
|
||||||
3. Read pixel data via `getImageData` and convert each pixel to grayscale (standard luminance formula: `0.299R + 0.587G + 0.114B`)
|
|
||||||
4. Apply a binarization pass: convert each grayscale pixel to black (0) or white (255) based on a luminance threshold (start at 128, tune if needed)
|
|
||||||
5. Write the result back to the canvas and return it as a PNG `Blob` via `canvas.toBlob`
|
|
||||||
|
|
||||||
**Test:** Import the function in a temporary test component. Pass it a phone photo of printed text. Render the output blob in an `<img>` tag. Confirm the result is black-and-white, high-contrast (text clearly black on white background), and no wider than 1600px.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2.2 — Validate preprocessing on a real pedigree photo
|
|
||||||
|
|
||||||
Using a real printed pedigree photographed at a slight angle or in non-ideal lighting, run it through `preprocessImage`. Confirm the output is readable.
|
|
||||||
|
|
||||||
**Test:** Visually inspect the output image. Text must be legible and clearly distinct from the background. If binarization produces large black blobs obscuring text, lower the threshold value. If text is faint or missing, raise it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Build the OCR and layout analysis utilities
|
|
||||||
|
|
||||||
### Step 3.1 — Create `client/src/lib/ocrRun.js`
|
|
||||||
|
|
||||||
Create a utility module that exports `runOCR(imageBlob)`. It accepts a preprocessed image blob and returns the raw Tesseract result object containing `words` (with bounding boxes and confidence scores) and `lines`.
|
|
||||||
|
|
||||||
Initialize Tesseract using the local vendor paths configured in Step 1.2.
|
|
||||||
|
|
||||||
Before returning, filter out any word objects with a confidence score below 60.
|
|
||||||
|
|
||||||
**Test:** Pass a preprocessed pedigree image into `runOCR`. Log the returned `words` array. Confirm word objects contain `text`, `bbox` (with `x0`, `y0`, `x1`, `y1`), and `confidence`. Confirm low-confidence noise words are absent from the result.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3.2 — Create `client/src/lib/ocrLayout.js` — column detection
|
|
||||||
|
|
||||||
Create a utility module for layout analysis. First export: `detectColumns(words, imageWidth)`.
|
|
||||||
|
|
||||||
This function divides the image width into 3 equal bands and assigns each word to a column (0 = subject, 1 = parents, 2 = grandparents) based on its `x0` position. Returns an array of three word arrays, one per column.
|
|
||||||
|
|
||||||
**Test:** Run `detectColumns` on a word list from a real pedigree OCR result. Log the three column arrays. Confirm the subject column contains the leftmost text, and the grandparent column contains the rightmost text.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3.3 — Add sire/dam split to `ocrLayout.js`
|
|
||||||
|
|
||||||
Add export: `splitSireDam(columnWords)`.
|
|
||||||
|
|
||||||
For a given column's word array, sort by `y0`. Find the largest vertical gap between adjacent words. Return `{ sire: wordsAboveGap, dam: wordsBelowGap }`.
|
|
||||||
|
|
||||||
**Test:** Run `splitSireDam` on the parents column from a real pedigree OCR result. Confirm the top half contains the sire entry and the bottom half contains the dam entry. The split point should fall in the blank space between the two ancestor cells.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3.4 — Add cell text aggregation to `ocrLayout.js`
|
|
||||||
|
|
||||||
Add export: `aggregateCellText(words)`.
|
|
||||||
|
|
||||||
For a given array of words belonging to one cell, sort by `y0` then `x0`. Group words whose `y0` values are within ~10px of each other onto the same line. Join lines with newline characters.
|
|
||||||
|
|
||||||
Returns a plain multiline string representing everything OCR'd in that cell.
|
|
||||||
|
|
||||||
**Test:** Run `aggregateCellText` on the sire word array from a real pedigree. Confirm the output reads as natural lines of text matching what is printed in that cell on the pedigree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3.5 — Create `client/src/lib/ocrParse.js` — field extraction
|
|
||||||
|
|
||||||
Create a utility module that exports `parseCellFields(cellText)`.
|
|
||||||
|
|
||||||
It accepts a multiline string (output of `aggregateCellText`) and returns:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
farmName: string | null,
|
|
||||||
rabbitName: string | null,
|
|
||||||
earNumber: string | null,
|
|
||||||
dob: string | null, // YYYY-MM-DD or null
|
|
||||||
weight: number | null, // decimal lbs or null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules per field:
|
|
||||||
|
|
||||||
- **Name (farmName + rabbitName):** Take the first non-label line of the cell. Apply the possessive split: match `^(.+?'s?)\s+(.+)$`. If matched, `farmName` = first capture group, `rabbitName` = second. If no possessive pattern is found, set `farmName = null` and `rabbitName` = the full first line.
|
|
||||||
- **Ear number:** Scan all lines for a label (`Tattoo:`, `Ear #:`, `Ear No:`, or similar) followed by a value matching `[A-Z0-9]{2,8}`. Also accept a bare `[A-Z0-9]{2,8}` token on its own line when no label is present.
|
|
||||||
- **DOB:** Scan all lines for date patterns: `MM/DD/YYYY`, `MM-DD-YYYY`, `MM/DD/YY`, `M/D/YY`. Convert any match to `YYYY-MM-DD`. Return `null` if no date is found or parsing fails. Do not use `"Unknown"` here — that sentinel is only written at save time.
|
|
||||||
- **Weight:** Scan all lines for weight patterns and normalize to a decimal float in lbs: `"8 lbs 4 oz"` → `8.25`, `"8.5 lbs"` → `8.5`, `"8.5"` with a trailing `lbs`/`lb` → `8.5`. Return `null` if no weight is found.
|
|
||||||
|
|
||||||
**Test:** Write at least three test cases using real cell text strings extracted from pedigree OCR results:
|
|
||||||
1. A cell with all five fields present and labelled
|
|
||||||
2. A cell with only name and ear number (DOB and weight absent)
|
|
||||||
3. A cell where the name has no possessive (rabbit name only, no farm)
|
|
||||||
|
|
||||||
Confirm outputs match expected values for each case.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3.6 — Create `client/src/lib/ocrPipeline.js` — full pipeline orchestrator
|
|
||||||
|
|
||||||
Create a utility that exports `extractPedigree(imageBlob)`. It:
|
|
||||||
|
|
||||||
1. Calls `runOCR(imageBlob)` to get the word list
|
|
||||||
2. Calls `detectColumns` to split words into subject / parents / grandparents columns
|
|
||||||
3. Subject column: calls `aggregateCellText` on all words, then `parseCellFields`
|
|
||||||
4. Parents column: calls `splitSireDam`, then `aggregateCellText` + `parseCellFields` on each half
|
|
||||||
5. Grandparents column: divides into four quadrants at the horizontal and vertical midpoints of the column (top-left = sire_sire, bottom-left = sire_dam, top-right = dam_sire, bottom-right = dam_dam), then `aggregateCellText` + `parseCellFields` on each
|
|
||||||
6. Returns the full output shape:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
subject: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire_sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
sire_dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam_sire: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
dam_dam: { farmName, rabbitName, earNumber, dob, weight },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test:** Run `extractPedigree` on a preprocessed real pedigree image. Log the output. Confirm all seven keys are present. Confirm at minimum the subject name and sire name are correctly extracted. Null values on sparse fields are acceptable — confirm no crashes or thrown errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Build the OCR UI components
|
|
||||||
|
|
||||||
### Step 4.1 — Create `ImageCapture` component
|
|
||||||
|
|
||||||
Create `client/src/components/ocr/ImageCapture.jsx`.
|
|
||||||
|
|
||||||
Renders a styled button labelled "Scan Pedigree". When clicked, it triggers a hidden file input that accepts images and requests the rear camera on mobile (`accept="image/*" capture="environment"`).
|
|
||||||
|
|
||||||
On file selection, calls an `onImage(file)` prop with the selected `File` object. Does not perform any processing — the caller handles that.
|
|
||||||
|
|
||||||
**Test:** Mount `ImageCapture` in isolation. Click the button. Confirm the file picker opens. Select an image file. Confirm `onImage` is called with the correct `File` object. In Chrome's mobile device emulator, confirm the camera capture option appears.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4.2 — Create `OCRProcessor` component
|
|
||||||
|
|
||||||
Create `client/src/components/ocr/OCRProcessor.jsx`.
|
|
||||||
|
|
||||||
Accepts props: `file` (File | null), `onResult({ ocrData, processedBlob })`, `onError(message)`.
|
|
||||||
|
|
||||||
When `file` changes to a non-null value, it:
|
|
||||||
|
|
||||||
1. Sets internal status to `'preprocessing'`
|
|
||||||
2. Calls `preprocessImage(file)` → stores the resulting blob as `processedBlob`
|
|
||||||
3. Sets status to `'scanning'`
|
|
||||||
4. Calls `extractPedigree(processedBlob)`
|
|
||||||
5. Calls `onResult` with `{ ocrData, processedBlob }` — both are needed by the caller (ocrData to build `initialState`, processedBlob to retain for the zone picker)
|
|
||||||
6. Sets status to `'done'`
|
|
||||||
|
|
||||||
Renders a loading indicator with status-appropriate text while processing (`'preprocessing'` → "Preparing image…", `'scanning'` → "Reading pedigree…"). Renders nothing when idle or done.
|
|
||||||
|
|
||||||
On any error, calls `onError` with a human-readable message.
|
|
||||||
|
|
||||||
**Test:** Mount `OCRProcessor` with a real pedigree image `File`. Confirm the loading states render in sequence. Confirm `onResult` is called with a non-null `ocrData` object containing the seven ancestor keys and a valid `processedBlob`. Confirm no errors on a clean image.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4.3 — Create `ZonePicker` component
|
|
||||||
|
|
||||||
Create `client/src/components/ocr/ZonePicker.jsx`.
|
|
||||||
|
|
||||||
Accepts props: `image` (Blob — the **preprocessed** image), `fieldLabel` (string), `onConfirm(rawText)`, `onCancel()`.
|
|
||||||
|
|
||||||
There is no `initialCrop` prop — bounding boxes are not retained by `extractPedigree`, so no pre-drawn rectangle is shown. The user always draws the crop region from scratch.
|
|
||||||
|
|
||||||
Renders:
|
|
||||||
- The preprocessed image displayed via `react-image-crop` with no initial selection
|
|
||||||
- A "Re-scan" button that crops the image canvas to the drawn rectangle, runs `runOCR` on the crop, extracts the raw text from the result, and calls `onConfirm(rawText)`
|
|
||||||
- A "Cancel" button that calls `onCancel`
|
|
||||||
|
|
||||||
`ZonePicker` does not parse or apply field values — it returns raw OCR text only. The caller is responsible for parsing and updating state.
|
|
||||||
|
|
||||||
**Test:** Mount `ZonePicker` with a preprocessed pedigree image. Confirm the image renders in the crop UI. Draw a rectangle around a single line of text on the pedigree. Click "Re-scan". Confirm `onConfirm` is called with a string that matches the text in the drawn region.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Integrate into the Add Rabbit wizard
|
|
||||||
|
|
||||||
### Step 5.1 — Add "Scan Pedigree" entry point to Step 1
|
|
||||||
|
|
||||||
In `AddRabbit.jsx`, add a "Scan Pedigree" button to Step 1 (the pedigree tree step), above the existing tree controls.
|
|
||||||
|
|
||||||
Add the following state to `AddRabbit.jsx`:
|
|
||||||
|
|
||||||
- `ocrFile` — the raw `File` selected by the user (null until a scan is triggered)
|
|
||||||
- `ocrData` — the structured result from `extractPedigree` (null until OCR completes)
|
|
||||||
- `processedBlob` — the preprocessed image blob retained for the zone picker (null until OCR completes)
|
|
||||||
- `ocrStatus` — `'idle' | 'preprocessing' | 'scanning' | 'done' | 'error'`
|
|
||||||
|
|
||||||
Clicking "Scan Pedigree" renders `ImageCapture`. On file selection, set `ocrFile`. `OCRProcessor` watches `ocrFile` and runs the pipeline. On `onResult`, store `ocrData` and `processedBlob` in state and set `ocrStatus` to `'done'`.
|
|
||||||
|
|
||||||
The existing pedigree tree controls remain unchanged and fully functional.
|
|
||||||
|
|
||||||
**Test:** Open the Add Rabbit wizard. Confirm "Scan Pedigree" is visible on Step 1. Confirm clicking it reveals the image picker. Confirm selecting an image triggers the OCR pipeline (loading states appear). Confirm the existing tree controls are still usable after the button is added.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5.2 — Build `initialState` from OCR result and mount `PedigreeTree`
|
|
||||||
|
|
||||||
`PedigreeTree` must not mount until `initialState` is ready. Block its render while `ocrStatus` is `'preprocessing'` or `'scanning'`.
|
|
||||||
|
|
||||||
When `ocrData` is available, build `initialState` for `PedigreeTree` as follows:
|
|
||||||
|
|
||||||
**Gen1 (sire/dam) — herd matching (see Step 5.3 for matching logic):**
|
|
||||||
- Confident herd match found → seed as `{ mode: 'linked', id: <matchedRabbitId> }`
|
|
||||||
- No match → seed as `{ mode: 'manual', name: serializeManualAncestor(remapped) }`
|
|
||||||
|
|
||||||
**Gen2/gen3 — always manual:**
|
|
||||||
- Seed as `{ mode: 'manual', name: serializeManualAncestor(remapped) }`
|
|
||||||
|
|
||||||
**Field remapping for `serializeManualAncestor`** (apply to every manual node):
|
|
||||||
|
|
||||||
| OCR field | `serializeManualAncestor` field |
|
|
||||||
|---|---|
|
|
||||||
| `rabbitName` | `name` |
|
|
||||||
| `farmName` | `farm` |
|
|
||||||
| `earNumber` | `ear_number` |
|
|
||||||
| `weight` (null → `0`) | `weight_lbs` |
|
|
||||||
| `dob` (null → `"Unknown"`) | `dob` |
|
|
||||||
|
|
||||||
Once `initialState` is built, mount `PedigreeTree` with it. If the user rescans, bump `PedigreeTree`'s `key` prop to remount with a fresh `initialState` built from the new `ocrData`.
|
|
||||||
|
|
||||||
**Test:** Scan a pedigree. Confirm `PedigreeTree` does not render during preprocessing/scanning. Confirm it mounts with the correct ancestor data once `ocrData` is ready. Rescan with a different pedigree — confirm the tree remounts and shows the new data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5.3 — Gen1 herd matching
|
|
||||||
|
|
||||||
For each of the sire and dam nodes independently, attempt a herd match before building `initialState`:
|
|
||||||
|
|
||||||
1. Call `GET /api/rabbits/quick-search?fields=ear_number&q=<earNumber>` (skip if `earNumber` is null)
|
|
||||||
2. Filter results client-side to an exact `ear_number` match (case-insensitive)
|
|
||||||
3. If exactly one exact match → use that rabbit's id for a linked node
|
|
||||||
4. If no exact ear number match → call `GET /api/rabbits/quick-search?fields=name&q=<rabbitName>` (skip if `rabbitName` is null)
|
|
||||||
5. Filter results client-side to an exact `name` match
|
|
||||||
6. If exactly one exact name match → use that rabbit's id for a linked node
|
|
||||||
7. If still no match → fall back to a manual node
|
|
||||||
|
|
||||||
Ear number takes precedence. Both searches must complete before `initialState` is built and `PedigreeTree` is allowed to mount.
|
|
||||||
|
|
||||||
**Test:**
|
|
||||||
- Add a rabbit to the herd with a known ear number. Scan a pedigree where that ear number appears as the sire. Confirm the sire slot mounts as a linked node showing the matched rabbit's name.
|
|
||||||
- Scan a pedigree where neither sire nor dam exists in the herd. Confirm both gen1 slots mount as manual nodes with the OCR-extracted name and ear number.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5.4 — Wire subject fields into `details` state
|
|
||||||
|
|
||||||
When `ocrData` is available, apply the subject fields to the wizard's `details` state slice:
|
|
||||||
|
|
||||||
| OCR field | `details` field |
|
|
||||||
|---|---|
|
|
||||||
| `subject.farmName` | `farm` |
|
|
||||||
| `subject.rabbitName` | `name` |
|
|
||||||
| `subject.earNumber` | `ear_number` |
|
|
||||||
| `subject.dob` | `dob` (already `YYYY-MM-DD`) |
|
|
||||||
| `subject.weight` | `weight_lbs` |
|
|
||||||
|
|
||||||
For any field where the OCR result is `null`, leave the existing `details` value unchanged. Do not overwrite with null.
|
|
||||||
|
|
||||||
Use the "adjusting state during render" pattern (a `useRef` flag) consistent with `AddRabbit.jsx`'s existing one-shot default-fill pattern (e.g. how `details.farm` is seeded from `settings.rabbitry_name`). Do not use a `useEffect` that calls `setState`.
|
|
||||||
|
|
||||||
**Test:** Scan a pedigree. Navigate to Step 2. Confirm the subject's farm, name, ear number, DOB, and weight are pre-filled. Confirm null fields are blank (not showing "null"). Confirm already-typed details values are not overwritten by null OCR results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5.5 — Highlight null OCR fields
|
|
||||||
|
|
||||||
After OCR data is applied, any field that received a `null` OCR result should display a yellow background and a small "?" indicator alongside it.
|
|
||||||
|
|
||||||
This applies to:
|
|
||||||
- Individual fields within each ancestor slot's manual editor in `PedigreeTree`
|
|
||||||
- The five subject fields in the Step 2 details form
|
|
||||||
|
|
||||||
The highlight is visual only — the field remains fully editable. Remove the highlight when the user types a non-empty value into that field.
|
|
||||||
|
|
||||||
**Test:** Scan a pedigree where DOB is absent from one ancestor. Open that ancestor's manual editor. Confirm the DOB field has a yellow highlight and "?" indicator. Type a value. Confirm the highlight disappears.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5.6 — Show OCR status banner
|
|
||||||
|
|
||||||
After OCR completes, display a dismissible banner above the pedigree tree on Step 1:
|
|
||||||
|
|
||||||
- **Success (some fields populated):** *"Pedigree scanned — review the tree and check highlighted fields before continuing."*
|
|
||||||
- **All fields null (no text found):** *"No text detected. Try retaking the photo with better lighting."*
|
|
||||||
- **Tesseract error:** *"Couldn't read this pedigree. Check the image quality or enter manually."*
|
|
||||||
|
|
||||||
The wizard must remain fully usable for manual entry in all three cases.
|
|
||||||
|
|
||||||
**Test:**
|
|
||||||
- Scan a clean pedigree. Confirm the success banner appears.
|
|
||||||
- Pass a completely black or blank image. Confirm the no-text banner appears.
|
|
||||||
- Temporarily move the Tesseract WASM out of `public/tesseract/` to force a load failure. Confirm the error banner appears and the wizard is still usable for manual entry.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — Zone picker correction flow
|
|
||||||
|
|
||||||
### Step 6.1 — Add zone picker trigger to ancestor slot editors
|
|
||||||
|
|
||||||
In the manual-entry editor for each ancestor slot (the panel that opens when the user clicks a manual node in `PedigreeTree.jsx`), add a small scan/camera icon button next to each of the five fields (farm name, rabbit name, ear number, DOB, weight).
|
|
||||||
|
|
||||||
Clicking the icon opens the `ZonePicker` panel. Pass it:
|
|
||||||
- `image` — the `processedBlob` from `AddRabbit` state
|
|
||||||
Do not pass `initialCrop` — the prop does not exist. The user draws the crop region from scratch.
|
|
||||||
- `fieldLabel` — a human-readable label for the field being corrected
|
|
||||||
|
|
||||||
Track which field triggered the picker in local state (`activeZoneField`).
|
|
||||||
|
|
||||||
The `ZonePicker` panel should render alongside or below the editor, not in a modal, so the user can see both simultaneously.
|
|
||||||
|
|
||||||
**Test:** After scanning a pedigree, click an ancestor slot to open its editor. Confirm each field has a scan icon button. Click the scan icon next to the ear number field. Confirm the `ZonePicker` opens showing the preprocessed pedigree image with no pre-drawn selection. Confirm the user can draw a crop rectangle freely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 6.2 — Apply zone picker result to the ancestor editor
|
|
||||||
|
|
||||||
When `ZonePicker` calls `onConfirm(rawText)`:
|
|
||||||
|
|
||||||
1. Run `rawText` through the appropriate parser from `ocrParse.js` for the active field (ear number regex, possessive name split, date parser, or weight normalizer)
|
|
||||||
2. Update the relevant field(s) in the ancestor editor's local state
|
|
||||||
3. Close `ZonePicker` (clear `activeZoneField`)
|
|
||||||
4. Remove the yellow highlight from the updated field
|
|
||||||
|
|
||||||
When `ZonePicker` calls `onCancel`, close the panel without modifying any state.
|
|
||||||
|
|
||||||
Note: for a name re-scan, the possessive split may produce both `farmName` and `rabbitName`. Update both fields if the scan icon was on either name field.
|
|
||||||
|
|
||||||
**Test:** After scanning a pedigree where ear number was null, click the ear number scan icon. Draw a rectangle over the ear number. Click "Re-scan". Confirm the ear number field updates with the correct value. Confirm the yellow highlight is gone. Confirm no other fields in the editor changed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 6.3 — Add zone picker trigger to Step 2 subject fields
|
|
||||||
|
|
||||||
In the Step 2 details form, add scan icon buttons next to the five OCR-populated fields: farm name, rabbit name, ear number, DOB, weight.
|
|
||||||
|
|
||||||
These work identically to Steps 6.1–6.2 but update `details` state in `AddRabbit` instead of an ancestor editor's local state.
|
|
||||||
|
|
||||||
**Test:** After scanning a pedigree where subject DOB was null (yellow highlight visible on DOB field), navigate to Step 2. Click the scan icon next to DOB. Draw a rectangle over the DOB on the pedigree image. Click "Re-scan". Confirm the DOB field updates with the correct value and the yellow highlight is removed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7 — End-to-end validation
|
|
||||||
|
|
||||||
### Step 7.1 — Full happy-path scan and save
|
|
||||||
|
|
||||||
Using a real printed pedigree (ARBA-format or non-standard):
|
|
||||||
|
|
||||||
1. Open Add Rabbit wizard
|
|
||||||
2. Click "Scan Pedigree" and photograph or upload the pedigree
|
|
||||||
3. Wait for OCR to complete
|
|
||||||
4. Review Step 1 — confirm the pedigree tree is populated with ancestor data
|
|
||||||
5. Click Next
|
|
||||||
6. Review Step 2 — confirm subject fields are pre-filled
|
|
||||||
7. Fill in breed and any null fields manually
|
|
||||||
8. Save
|
|
||||||
|
|
||||||
**Test:** Confirm the saved rabbit appears in the herd with correct name, ear number, and pedigree ancestors. Confirm no console errors during the entire flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 7.2 — Linked gen1 cascade behavior
|
|
||||||
|
|
||||||
Scan a pedigree where the sire exists in the herd (matched by ear number).
|
|
||||||
|
|
||||||
**Test:** Confirm the sire slot in the tree shows as a linked node. Confirm the sire's grandparent branch (sire_sire, sire_dam) is locked and derives from the linked rabbit's own stored pedigree — not from the OCR-extracted grandparent text. Confirm the dam branch (if unmatched) retains OCR-extracted manual text.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 7.3 — Zone picker correction round-trip
|
|
||||||
|
|
||||||
1. Scan a pedigree where at least one field is wrong or null
|
|
||||||
2. Use the zone picker to correct that field
|
|
||||||
3. Complete the wizard and save
|
|
||||||
|
|
||||||
**Test:** Confirm the corrected value — not the original OCR extraction — is saved to the database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 7.4 — Offline scan
|
|
||||||
|
|
||||||
1. Load the app once while online
|
|
||||||
2. Disconnect from the internet
|
|
||||||
3. Complete a full scan and save
|
|
||||||
|
|
||||||
**Test:** Confirm OCR completes without network errors. Confirm the saved rabbit appears in the herd. Confirm no console errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 7.5 — Manual entry fallback after failed scan
|
|
||||||
|
|
||||||
1. Scan an unreadable image (blank paper, very dark photo)
|
|
||||||
2. Confirm the appropriate error banner appears
|
|
||||||
3. Dismiss the banner and complete the wizard via manual entry
|
|
||||||
|
|
||||||
**Test:** Confirm the wizard is fully usable for manual entry after a failed scan. Confirm no state corruption from the failed OCR attempt — the tree and details form behave as if no scan was attempted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope (do not implement in this phase)
|
|
||||||
|
|
||||||
- Deskew / perspective correction
|
|
||||||
- Great-grandparent (4th generation) extraction
|
|
||||||
- Handwriting-specific handling
|
|
||||||
- Multi-page pedigrees
|
|
||||||
- Bulk scanning multiple rabbits at once
|
|
||||||
|
|
@ -59,7 +59,6 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
|
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
|
||||||
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
|
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
|
||||||
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
|
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
|
||||||
| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
|
|
||||||
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
|
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
|
||||||
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
|
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
|
||||||
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
|
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
|
||||||
|
|
@ -73,11 +72,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
|
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
|
||||||
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
|
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
|
||||||
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
|
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
|
||||||
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. **OCR addition:** the five vendored `/tesseract/` assets (see `public/tesseract/` below) are precached on `install` (a `TESSERACT_URLS` list alongside the existing `SHELL_URLS`, same cache) and served **cache-first** via a dedicated `fetch`-handler branch — they're large/static/versioned, so once stored they're served straight from cache with no network round-trip (the rest of the shell stays network-first). See the OCR-assets convention note below. |
|
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. |
|
||||||
| `public/tesseract/` | Vendored Tesseract OCR runtime (OCR Phase 1 Step 1.2), served from the app's own origin so the pedigree scanner makes **no CDN calls** and works offline. Five files: `worker.min.js` (the tesseract.js web-worker script); `eng.traineddata` (the **uncompressed** English LSTM language data); and three feature-detect WASM core builds — `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (each ~3.9 MB, self-contained — the WASM is base64-embedded in the `.wasm.js`). At runtime tesseract.js (default OEM `LSTM_ONLY`) feature-detects and loads exactly **one** of the three cores; all three are vendored so a directory-based `corePath` auto-selects correctly on any browser. See the OCR-assets convention note below. |
|
|
||||||
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
|
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
|
||||||
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
|
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
|
||||||
| `Dockerfile` | Multi-stage production build: a Node 20 Alpine stage runs `npm ci && npm run build`, then an nginx 1.27 Alpine stage serves the built `dist/` with `nginx.conf` (which reverse-proxies `/api` + `/uploads` to the server container and serves the SPA fallback + `sw.js` no-cache header). The Vite dev server is never run in Docker. |
|
| `Dockerfile` | Builds a Node 20 Alpine image and runs the Vite dev server bound to `0.0.0.0` (required so the container's port mapping works — Vite binds to localhost only by default). |
|
||||||
|
|
||||||
## Root
|
## Root
|
||||||
|
|
||||||
|
|
@ -108,5 +106,4 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
|
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
|
||||||
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
||||||
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
||||||
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
|
|
||||||
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,655 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# MyKindlebox — Implementation Plan
|
||||||
|
|
||||||
|
**Version:** 1.1
|
||||||
|
**Last Updated:** 2026-06-20
|
||||||
|
**Audience:** AI developers
|
||||||
|
**Scope:** Base application only — core herd and litter management, no advanced features
|
||||||
|
|
||||||
|
Reference documents: `app-design-document.md`, `tech-stack.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Each step has a single, concrete objective.
|
||||||
|
- Each step ends with a **Validation** block — a specific check that confirms the step is complete and correct before moving on.
|
||||||
|
- Do not proceed to the next step until the current step's validation passes.
|
||||||
|
- No placeholders or stubs — every step should leave the codebase in a working, testable state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred: Authentication
|
||||||
|
|
||||||
|
The tech stack specifies single-user session token auth. Auth is **intentionally out of scope for this base plan**. All `/api/*` routes are unprotected in this phase. A future plan addendum will cover the login screen, token issuance, and route protection middleware. Do not add auth stubs or placeholders — implement it fully when that phase is added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Project Scaffolding
|
||||||
|
|
||||||
|
### Step 1.1 — Initialize the monorepo structure
|
||||||
|
|
||||||
|
Create a root project folder named `mykindlebox`. Inside it, create two subdirectories: `server` for the backend and `client` for the frontend. Add a root-level `README.md` with the project name and one-line description.
|
||||||
|
|
||||||
|
**Validation:** The folder structure `mykindlebox/server` and `mykindlebox/client` exists. The README is present at the root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1.2 — Initialize the backend project
|
||||||
|
|
||||||
|
Inside `server`, initialize a Node.js project. Install Express as a dependency. Create a main entry file (`index.js` or `index.ts`) that starts an HTTP server on port 3001 and responds to `GET /health` with a JSON body of `{ "status": "ok" }`.
|
||||||
|
|
||||||
|
**Validation:** Running the server and sending a request to `http://localhost:3001/health` returns HTTP 200 with `{ "status": "ok" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1.3 — Initialize the frontend project
|
||||||
|
|
||||||
|
Inside `client`, scaffold a new React + Vite project. Install Tailwind CSS and configure it with a `tailwind.config` file. Install React Router v7. The default page should render the text "MyKindlebox" in the center of the screen.
|
||||||
|
|
||||||
|
**Validation:** Running the dev server and opening the browser shows a page with the text "MyKindlebox". No console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1.4 — Configure Tailwind design tokens
|
||||||
|
|
||||||
|
In the Tailwind config, extend the theme with the custom color tokens from the design document. Define all ten color values as named tokens matching the CSS variable names (e.g., `surface`, `surface-raised`, `surface-sunken`, `border`, `text-primary`, `text-secondary`, `accent`, `accent-hover`, `accent-warm`, `danger`).
|
||||||
|
|
||||||
|
**Validation:** Apply a background of `bg-surface` and text of `text-text-primary` to the root element. The page background should render as `#F7F3EC` and text as `#1C1A16`. Inspect in browser devtools to confirm the computed colors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1.5 — Load custom fonts
|
||||||
|
|
||||||
|
Add DM Sans and Inter via Google Fonts (or self-hosted). Configure Tailwind's font family extension so that `font-display` maps to DM Sans and `font-body` maps to Inter. Apply `font-body` to the root `<body>` element.
|
||||||
|
|
||||||
|
**Validation:** In the browser, inspect the computed font of any text element on the page. It should show Inter. Temporarily apply `font-display` to a heading and confirm it shows DM Sans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1.6 — Set up Docker Compose
|
||||||
|
|
||||||
|
Create a `docker-compose.yml` at the project root. Define two services: `server` (Node.js backend on port 3001) and `client` (Vite dev server or Nginx static on port 5173). Mount a named volume for the SQLite database file under `server/data/`.
|
||||||
|
|
||||||
|
**Validation:** Running `docker compose up` starts both services without errors. The health endpoint at `http://localhost:3001/health` and the frontend at `http://localhost:5173` are both reachable from the host machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Database
|
||||||
|
|
||||||
|
### Step 2.1 — Install and configure Drizzle ORM
|
||||||
|
|
||||||
|
Inside `server`, install Drizzle ORM and the `better-sqlite3` driver. Create a `db` directory inside `server`. Add a `db/client.ts` file that opens a SQLite connection to `server/data/mykindlebox.db` and exports the database instance.
|
||||||
|
|
||||||
|
**Validation:** Starting the server creates the file `server/data/mykindlebox.db` if it does not already exist. No errors on startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2.2 — Define the Rabbit schema
|
||||||
|
|
||||||
|
In `server/db/schema.ts`, define the `rabbits` table with all fields from the data model in the design document: `id`, `name`, `farm`, `sex`, `dob`, `weight_lbs`, `ear_number`, `location`, `status`, `color_name`, `photo_path`, `registration_number`, `gc_number`, `sire_id`, `dam_id`, the seven locus fields (`locus_a` through `locus_v`), `created_at`, and `updated_at`.
|
||||||
|
|
||||||
|
- `sex` must be constrained to the values: `buck`, `doe`, `unknown`.
|
||||||
|
- `status` must be constrained to: `active`, `for_sale`, `on_hold`, `sold`, `deceased`, `other`.
|
||||||
|
- `sire_id` and `dam_id` are self-referencing foreign keys to `rabbits.id`, nullable.
|
||||||
|
- Add twelve freeform text columns for ancestors not in the database, covering all three ancestor generations:
|
||||||
|
- Generation 1 (parents): `sire_name_text`, `dam_name_text`
|
||||||
|
- Generation 2 (grandparents): `paternal_grandsire_name_text`, `paternal_granddam_name_text`, `maternal_grandsire_name_text`, `maternal_granddam_name_text`
|
||||||
|
- Generation 3 (great-grandparents): `pp_grandsire_name_text`, `pp_granddam_name_text`, `pm_grandsire_name_text`, `pm_granddam_name_text`, `mp_grandsire_name_text`, `mp_granddam_name_text`, `mm_grandsire_name_text`, `mm_granddam_name_text`
|
||||||
|
All nullable text. The following rule governs when these columns are written and read:
|
||||||
|
|
||||||
|
**Chain-authority rule:** A `_name_text` column for a given ancestor slot is written *only* when the immediate parent node in that slot's chain has no FK (i.e., the chain is broken at the parent). Once a node resolves through a FK to a real DB record, all deeper generations in that branch come exclusively from that ancestor's own `sire_id`/`dam_id`/`_name_text` fields — the subject's own flat columns for those deeper slots are never written and never read. They are dead columns in an unbroken chain.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
- `sire_name_text` is written only when `sire_id` is null.
|
||||||
|
- `paternal_grandsire_name_text` is written only when `sire_id` is null (chain broken at Sire). If `sire_id` is set, the Sire's Sire always comes from `Sire.sire_id` or `Sire.sire_name_text` — never from the subject's `paternal_grandsire_name_text`.
|
||||||
|
- The same rule applies recursively at every generation and every branch.
|
||||||
|
- `created_at` and `updated_at` default to the current timestamp.
|
||||||
|
|
||||||
|
**Validation:** Running the Drizzle migration (or `db.run` equivalent) creates a `rabbits` table in the SQLite file with the correct columns. Open the database with a SQLite browser and confirm all columns and constraints are present, including all fourteen `_name_text` columns (two parents, four grandparents, eight great-grandparents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2.3 — Define the Litter schema
|
||||||
|
|
||||||
|
In the same `schema.ts`, define the `litters` table with fields: `id`, `sire_id` (FK → rabbits, nullable), `dam_id` (FK → rabbits, nullable), `kindle_date`, `kit_count`, `notes`, and `created_at`.
|
||||||
|
|
||||||
|
**Validation:** After migration, the `litters` table exists in the database with the correct columns. Confirm the foreign key references to the `rabbits` table are present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2.4 — Define the Settings schema
|
||||||
|
|
||||||
|
Define a `settings` table with fields: `key` (text, primary key) and `value` (text). This is a simple key-value store for app-wide configuration such as rabbitry name and weight display preference.
|
||||||
|
|
||||||
|
Seed it with two default rows on first run: `rabbitry_name` with a value of `"My Rabbitry"` and `weight_unit` with a value of `"decimal_lbs"`.
|
||||||
|
|
||||||
|
**Validation:** After startup, the `settings` table exists and contains exactly two rows with the keys `rabbitry_name` and `weight_unit`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2.5 — Run and validate all migrations on startup
|
||||||
|
|
||||||
|
Configure the server so that on startup it runs all pending migrations automatically. Migrations should be idempotent — running them twice must not produce errors or duplicate data.
|
||||||
|
|
||||||
|
**Validation:** Stop the server, delete the database file, and restart. The database is recreated with all three tables and the two settings seed rows. Stop and restart again without deleting — no errors, no duplicate rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Backend API
|
||||||
|
|
||||||
|
### Step 3.1 — Add a global error handler and request logger
|
||||||
|
|
||||||
|
Add middleware to the Express app that logs every incoming request (method and path) to the console. Add a global error-handling middleware that catches unhandled errors and returns a JSON response with HTTP 500 and a body of `{ "error": "Internal server error" }`.
|
||||||
|
|
||||||
|
**Validation:** Make a request to a non-existent route (e.g., `GET /nonexistent`). The server logs the request and returns HTTP 404. Trigger a deliberate error in a test route and confirm HTTP 500 is returned with the correct JSON body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.2 — Rabbits API — GET all rabbits
|
||||||
|
|
||||||
|
Create a `GET /api/rabbits` endpoint. It must return an array of all rabbit records. Support the following optional query parameters:
|
||||||
|
|
||||||
|
- `sex` — filter by `buck`, `doe`, `unknown`, or the special value `kits` (returns rabbits of any sex whose `dob` is within the last 56 days)
|
||||||
|
- `status` — filter by any valid status value
|
||||||
|
- `search` — case-insensitive partial match on the `name` field
|
||||||
|
- `sort` — one of `name`, `dob`, `weight_lbs`, `created_at` (default: `name`)
|
||||||
|
|
||||||
|
**Validation:** With an empty database, the endpoint returns `[]`. After inserting two test rabbits directly into the DB (one buck, one doe), confirm that `GET /api/rabbits` returns both, `GET /api/rabbits?sex=buck` returns only the buck, and `GET /api/rabbits?search=<partial name>` filters correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.3 — Rabbits API — GET single rabbit
|
||||||
|
|
||||||
|
Create a `GET /api/rabbits/:id` endpoint. It must return the full rabbit record for the given ID. If no rabbit with that ID exists, return HTTP 404 with `{ "error": "Rabbit not found" }`.
|
||||||
|
|
||||||
|
**Validation:** Requesting a valid ID returns the correct rabbit record with all fields. Requesting ID `99999` (nonexistent) returns HTTP 404 with the error message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.4 — Rabbits API — POST create rabbit
|
||||||
|
|
||||||
|
Create a `POST /api/rabbits` endpoint. It must accept a JSON body, validate that `name` is present and non-empty, and insert the record. Return the newly created rabbit record with its assigned `id` and HTTP 201.
|
||||||
|
|
||||||
|
Reject requests missing `name` with HTTP 400 and `{ "error": "Name is required" }`.
|
||||||
|
|
||||||
|
**Validation:** `POST /api/rabbits` with a valid body including at minimum `{ "name": "Test Rabbit", "sex": "buck" }` returns HTTP 201 and the new record with an `id`. A subsequent `GET /api/rabbits/:id` with that ID returns the same record. Posting without a name returns HTTP 400.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.5 — Rabbits API — PATCH update rabbit
|
||||||
|
|
||||||
|
Create a `PATCH /api/rabbits/:id` endpoint. It must accept a partial JSON body and update only the provided fields. Return the full updated rabbit record. Return HTTP 404 if the rabbit does not exist.
|
||||||
|
|
||||||
|
**Validation:** Create a rabbit via POST. Send a PATCH with `{ "weight_lbs": 3.5 }`. Confirm the returned record has `weight_lbs: 3.5` and all other fields are unchanged. Send a PATCH to a nonexistent ID and confirm HTTP 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.6 — Rabbits API — DELETE rabbit
|
||||||
|
|
||||||
|
Create a `DELETE /api/rabbits/:id` endpoint. Return HTTP 204 on success. Return HTTP 404 if the rabbit does not exist. Before deleting, set `sire_id` and `dam_id` to `null` on any rabbit that references the deleted rabbit, to preserve referential integrity.
|
||||||
|
|
||||||
|
**Validation:** Create two rabbits where one is the sire of the other. Delete the sire. Confirm the child rabbit's `sire_id` is now `null`. Confirm the deleted rabbit returns 404 on a subsequent GET.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.7 — Rabbits API — GET ancestry
|
||||||
|
|
||||||
|
Create a `GET /api/rabbits/:id/ancestors` endpoint. It must return a nested object representing the rabbit's full 4-generation pedigree (root + 3 ancestor generations). Each node should include the rabbit's `id`, `name`, `farm`, `sex`, `color_name`, and their own `sire_id` / `dam_id`. Unknown ancestors (no FK and no freeform text) should be represented as `null`.
|
||||||
|
|
||||||
|
The endpoint must resolve each node using the chain-authority rule defined in Step 2.2:
|
||||||
|
- If a node's parent resolved via FK, read the node's data from that parent DB record's own `sire_id`/`dam_id`/`_name_text` fields. Do not read from the subject's flat columns for that slot.
|
||||||
|
- If a node's parent had no FK (chain broken), read the node's data from the subject's corresponding `_name_text` column.
|
||||||
|
- Never mix sources within a branch: once the chain is broken, all remaining nodes in that branch come from the subject's flat columns; once the chain is intact, all nodes come from recursive ancestor records.
|
||||||
|
|
||||||
|
**Validation:** Create a set of 15 linked rabbit records (root → 2 parents → 4 grandparents → 8 great-grandparents). Call the ancestors endpoint on the root rabbit and confirm the returned tree has the correct nested structure with all 14 ancestors populated at the correct positions.
|
||||||
|
|
||||||
|
Then test the broken-chain case: create a root rabbit with `sire_id` null and `sire_name_text = "Bob's Buck"` and `paternal_grandsire_name_text = "Old Bob"`. Call the endpoint and confirm the Sire node returns the freeform text "Bob's Buck" with no `id`, and the Sire's Sire node returns "Old Bob" from the subject's flat column.
|
||||||
|
|
||||||
|
Then test the unbroken-chain case: create a root rabbit with a real `sire_id`. Set a value in the subject's `paternal_grandsire_name_text` column directly in the DB (simulating stale or erroneous data). Call the endpoint and confirm the Sire's Sire node comes from `Sire.sire_id` or `Sire.sire_name_text` — not from the subject's flat column. The subject's flat column must be ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.8 — Rabbits API — GET rabbit search (autocomplete)
|
||||||
|
|
||||||
|
Create a `GET /api/rabbits/search?q=<query>` endpoint specifically for the pedigree tree autocomplete. It must return only `id`, `name`, `farm`, and `sex` for up to 10 matching rabbits. Match on any portion of the `name` field, case-insensitive.
|
||||||
|
|
||||||
|
Also support an optional `sex` query parameter (`buck`, `doe`, or `unknown`) to restrict results to a single sex. This is required by the Add Litter form, which must restrict the Sire field to bucks and the Dam field to does.
|
||||||
|
|
||||||
|
**Validation:** With several rabbits in the database, querying `?q=jim` returns only rabbits whose names contain "jim" (case-insensitive). Querying `?q=&sex=buck` returns only bucks. Querying `?q=&sex=doe` returns only does. The response contains no more than 10 results and only the four specified fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.9 — Litters API — CRUD endpoints
|
||||||
|
|
||||||
|
Create the following endpoints for litters:
|
||||||
|
|
||||||
|
- `GET /api/litters` — return all litters, sorted by `kindle_date` descending. Include the sire and dam's `name` and `id` in each result (joined, not just IDs).
|
||||||
|
- `GET /api/litters/:id` — return a single litter. Include sire and dam details. Include all Rabbit records whose `sire_id` and `dam_id` match the litter's parents and whose `dob` matches the `kindle_date`.
|
||||||
|
- `POST /api/litters` — create a litter. Require `kindle_date`. Return HTTP 201 with the new record.
|
||||||
|
- `PATCH /api/litters/:id` — update a litter's fields. Return the updated record.
|
||||||
|
- `DELETE /api/litters/:id` — delete a litter. Return HTTP 204.
|
||||||
|
|
||||||
|
**Validation:** Create a litter via POST. Confirm it appears in GET all. Add a rabbit with matching `sire_id`, `dam_id`, and `dob` — confirm it appears in the GET single litter's kit list. Delete the litter and confirm it returns 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.10 — Settings API
|
||||||
|
|
||||||
|
Create two endpoints:
|
||||||
|
|
||||||
|
- `GET /api/settings` — return all settings as a flat JSON object (e.g., `{ "rabbitry_name": "My Rabbitry", "weight_unit": "decimal_lbs" }`).
|
||||||
|
- `PATCH /api/settings` — accept a partial JSON object and update the provided keys. Only allow known keys (`rabbitry_name`, `weight_unit`). Reject unknown keys with HTTP 400.
|
||||||
|
|
||||||
|
**Validation:** `GET /api/settings` returns the two seeded defaults. `PATCH /api/settings` with `{ "rabbitry_name": "Belowland Rabbitry" }` updates the value. A subsequent GET reflects the change. Sending an unknown key returns HTTP 400.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.11 — Dashboard stats endpoint
|
||||||
|
|
||||||
|
Create a `GET /api/stats` endpoint that returns a single JSON object with the following counts derived from the `rabbits` table:
|
||||||
|
|
||||||
|
- `herd` — count of all rabbits with status `active`
|
||||||
|
- `bucks` — count of active rabbits with sex `buck`
|
||||||
|
- `does` — count of active rabbits with sex `doe`
|
||||||
|
- `kits` — count of rabbits with status `active` whose `dob` is within the last 56 days (8 weeks). Use this age-window logic consistently; do not use the litter-join method.
|
||||||
|
- `for_sale` — count with status `for_sale`
|
||||||
|
- `on_hold` — count with status `on_hold`
|
||||||
|
- `sold` — count with status `sold`
|
||||||
|
- `archived` — count with status `other` or `deceased`. The dashboard stat card for this value must be labeled **"Archived"**, not "Other", to distinguish it from the sex-based "Other" filter tab on the Herd page.
|
||||||
|
|
||||||
|
**Validation:** Insert rabbits with known statuses and DOBs. Call `GET /api/stats` and confirm each count matches the expected value. Insert a rabbit with a `dob` of today — confirm it appears in `kits`. Insert one with a `dob` of 57 days ago — confirm it does not. Confirm the `archived` key is present and correctly counts `other` + `deceased` status rabbits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3.12 — Photo upload endpoint
|
||||||
|
|
||||||
|
Create a `POST /api/rabbits/:id/photo` endpoint that accepts a `multipart/form-data` request containing a single image file in a field named `photo`. The endpoint must:
|
||||||
|
|
||||||
|
- Validate that the uploaded file is an image (accept `image/jpeg`, `image/png`, `image/webp` only). Reject other types with HTTP 400.
|
||||||
|
- Save the file to a designated uploads directory on disk (e.g., `server/data/uploads/<rabbit-id>.<ext>`).
|
||||||
|
- Update the rabbit's `photo_path` field in the database to the saved file path.
|
||||||
|
- Return the updated rabbit record with HTTP 200.
|
||||||
|
|
||||||
|
The `POST /api/rabbits` and `PATCH /api/rabbits/:id` endpoints remain JSON-only. Photo upload is always a separate step after the rabbit record exists.
|
||||||
|
|
||||||
|
Serve the uploads directory as static files at `/uploads/*` so the frontend can display photos via a standard `<img src>` URL.
|
||||||
|
|
||||||
|
**Validation:** Create a rabbit via `POST /api/rabbits`. Then send a `POST /api/rabbits/:id/photo` request with a valid JPEG file. Confirm the file is saved to disk and the rabbit's `photo_path` is updated in the database. Request the photo URL directly in the browser — it should display the image. Send a non-image file (e.g., a `.txt` file) and confirm HTTP 400 is returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Frontend Foundation
|
||||||
|
|
||||||
|
### Step 4.1 — Set up React Router with route definitions
|
||||||
|
|
||||||
|
Configure React Router v7 in the client app. Define routes for all paths in the IA:
|
||||||
|
|
||||||
|
- `/` → Dashboard page component
|
||||||
|
- `/herd` → Herd page component
|
||||||
|
- `/herd/add` → Add Rabbit page component
|
||||||
|
- `/herd/:id` → Rabbit Detail page component
|
||||||
|
- `/herd/:id/edit` → Edit Rabbit page component
|
||||||
|
- `/litters` → Litters page component
|
||||||
|
- `/litters/add` → Add Litter page component
|
||||||
|
- `/litters/:id` → Litter Detail page component
|
||||||
|
- `/genetics` → Genetics Calculator page component
|
||||||
|
- `/settings` → Settings page component
|
||||||
|
|
||||||
|
Each route should render a placeholder component that shows the route name as a heading (e.g., "Dashboard", "Herd", etc.).
|
||||||
|
|
||||||
|
**Validation:** Navigating to each URL in the browser renders the corresponding placeholder heading with no errors. Navigating to `/nonexistent` should show a "Not Found" message (add a catch-all route).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4.2 — Build the app shell layout
|
||||||
|
|
||||||
|
Create a persistent layout component that wraps all pages. It must include:
|
||||||
|
|
||||||
|
- A left sidebar (240px wide) on screens wider than 768px containing: the app name "MyKindlebox" at the top, and navigation links for Dashboard, Herd, Litters, Genetics, and Settings.
|
||||||
|
- A bottom navigation bar on screens 768px and below with five items: Dashboard, Herd, Litters, Add (links to `/herd/add`), and Settings.
|
||||||
|
- A main content area to the right of the sidebar (or below the top on mobile) where the page component renders.
|
||||||
|
|
||||||
|
The active route should be visually highlighted in the navigation.
|
||||||
|
|
||||||
|
**Validation:** On a desktop-width browser, the sidebar is visible and the bottom nav is hidden. On a mobile-width browser (or using devtools), the sidebar is hidden and the bottom nav is visible. Clicking each nav item navigates to the correct route and highlights the active item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4.3 — Set up React Query
|
||||||
|
|
||||||
|
Install and configure React Query. Wrap the app in a `QueryClientProvider`. Create a shared `api.ts` utility file in the client that exports typed fetch functions for each API endpoint defined in Phase 3. All fetch functions should target `http://localhost:3001/api`.
|
||||||
|
|
||||||
|
**Validation:** In the Dashboard placeholder component, call `GET /api/stats` using a React Query `useQuery` hook. Confirm the raw response data is logged to the console without errors. The network tab in devtools should show a successful request to the backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4.4 — Build shared UI components
|
||||||
|
|
||||||
|
Build the following small, reusable components. These will be used throughout the app. Each should accept the appropriate props and apply the correct design tokens from the Tailwind config.
|
||||||
|
|
||||||
|
- `Button` — variants: `primary` (accent background), `secondary` (border only), `danger` (danger background). Sizes: `sm`, `md`.
|
||||||
|
- `Badge` — displays a status label. Color-coded by status value: `active` (accent), `for_sale` (accent-warm), `on_hold` (text-secondary), `sold` (border), `deceased` (danger).
|
||||||
|
- `Card` — a white (`surface-raised`) container with a border and subtle shadow.
|
||||||
|
- `PageHeader` — a heading with an optional subtitle and optional right-side action slot.
|
||||||
|
- `EmptyState` — a centered message with a title, description, and optional action button. Used when lists are empty.
|
||||||
|
|
||||||
|
**Validation:** Create a temporary `/dev` route that renders all five components with representative props. Visually confirm colors, sizes, and layout match the design tokens. Remove the dev route after validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Dashboard Page
|
||||||
|
|
||||||
|
### Step 5.1 — Build the stats bar
|
||||||
|
|
||||||
|
On the Dashboard page, fetch data from `GET /api/stats`. Render eight stat cards in a 4-column grid (2-column on mobile) using the `Card` component. Each card shows the label and count. Each card is a clickable link to the corresponding filtered herd view.
|
||||||
|
|
||||||
|
Show a loading skeleton (grey placeholder boxes) while the data is fetching. Show an error message if the request fails.
|
||||||
|
|
||||||
|
**Validation:** With rabbits of various statuses in the database, the dashboard stats cards display the correct counts. Clicking a stat card navigates to `/herd` (with the appropriate filter). The loading skeleton is visible briefly before data loads on a slow connection (simulate with devtools throttling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5.2 — Build the quick actions section
|
||||||
|
|
||||||
|
Below the stats bar, render three action buttons: "Add Rabbit" (links to `/herd/add`), "Record Litter" (links to `/litters/add`), and "View Herd" (links to `/herd`). Use the `Button` component with the `primary` variant for "Add Rabbit" and `secondary` for the others.
|
||||||
|
|
||||||
|
**Validation:** All three buttons are visible and navigate to the correct routes when clicked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5.3 — Build the recent litters widget
|
||||||
|
|
||||||
|
Below the quick actions, fetch the three most recent litters from `GET /api/litters` and display them as a small list. Each entry shows the sire × dam names, kindle date (formatted as "Month DD, YYYY"), and kit count. Each entry links to its litter detail page. Show the `EmptyState` component if no litters exist yet.
|
||||||
|
|
||||||
|
**Validation:** With litters in the database, the three most recent appear in the widget with correct names, dates, and kit counts. With no litters, the empty state renders. Each entry links to the correct `/litters/:id` route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Herd Page
|
||||||
|
|
||||||
|
### Step 6.1 — Build the rabbit card component
|
||||||
|
|
||||||
|
Create a `RabbitCard` component. It accepts a rabbit record as a prop and renders: a placeholder breed silhouette image (SVG or static asset), the rabbit's name, color name and sex on the same line separated by a bullet, age (calculated from DOB as years/months) and weight on the same line, and litter/kit counts (these can be placeholder zeros until the API supports them). The entire card links to `/herd/:id`. Apply the `Card` component as the wrapper.
|
||||||
|
|
||||||
|
**Validation:** Render the card with a hardcoded rabbit prop. Confirm all fields display correctly. Confirm the card links to the correct route. A rabbit with no DOB should show "Age unknown" rather than crashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6.2 — Build the herd filter bar
|
||||||
|
|
||||||
|
Above the rabbit grid on the Herd page, build a filter bar with:
|
||||||
|
|
||||||
|
- A segmented tab control for sex: All / Bucks / Does / Kits / Other
|
||||||
|
- A sort dropdown: Name / Age / Recent / Weight
|
||||||
|
- A text search input with a small search icon
|
||||||
|
- A view toggle: grid icon and list icon
|
||||||
|
|
||||||
|
> **Clarification on filter axes:** "Kits" filters by `dob` within the last 56 days (age window, same as the dashboard stat). "Other" filters by `sex = unknown` — this is a sex-axis filter, not a status filter. This is intentionally different from the dashboard's "Archived" card, which filters by `status`. Do not conflate them.
|
||||||
|
|
||||||
|
Selecting any control should update the URL query parameters (e.g., `?sex=buck&sort=name&q=jim&view=list`) and persist state on page reload.
|
||||||
|
|
||||||
|
**Validation:** Selecting "Bucks" updates the URL to include `?sex=buck`. Typing in the search box updates `?q=<value>` after a short debounce (no update on every keystroke). Toggling the view switch updates `?view=list` or `?view=grid`. Refreshing the page with those query params restores the filter state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6.3 — Build the herd grid view
|
||||||
|
|
||||||
|
Fetch rabbits from `GET /api/rabbits` using the active filter and sort state from the URL params. Render results as a responsive grid: 2 columns on mobile, 3 on tablet, 4 on desktop. Use the `RabbitCard` component for each rabbit. Show the `EmptyState` component when no results match the current filter. Show loading skeletons (matching the card size) while fetching.
|
||||||
|
|
||||||
|
**Validation:** With 10+ rabbits in the database, all appear in the grid. Applying the "Bucks" filter shows only bucks. Using the search input filters by name in real time. The grid is 2 columns on a 375px viewport and 4 columns on a 1280px viewport.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6.4 — Build the herd list view
|
||||||
|
|
||||||
|
When `?view=list` is active, render a compact table instead of the card grid. Columns: Name, Sex, Color, Age, Weight, Status. Each row links to the rabbit detail page. Zebra-stripe rows using the `surface-sunken` token for alternating rows.
|
||||||
|
|
||||||
|
**Validation:** Toggling to list view renders the table. All columns are populated correctly. Clicking a row navigates to `/herd/:id`. The same filters (sex, search, sort) applied in grid view also apply in list view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Add / Edit Rabbit Flow
|
||||||
|
|
||||||
|
### Step 7.1 — Build the pedigree tree input component
|
||||||
|
|
||||||
|
Create a `PedigreeTree` component. It renders a 4-generation pedigree layout (root + 3 ancestor generations) with 15 nodes: the subject (new rabbit, non-editable label), 2 parents, 4 grandparents, and 8 great-grandparents. The layout follows standard ARBA pedigree orientation — root on the left, each generation branching right.
|
||||||
|
|
||||||
|
Each ancestor node is an interactive input. When focused, it shows a dropdown with a search box. Typing in the search box calls `GET /api/rabbits/search?q=<query>` and displays matching results. Selecting a result fills that node with the rabbit's name and stores the rabbit's ID. The node also shows two non-search options: "Enter manually" (accepts freeform text, saved to the subject's corresponding `_name_text` column with no FK) and "Unknown" (clears both the FK and the text field for that slot).
|
||||||
|
|
||||||
|
**Chain-authority rule in the UI:** This rule governs which nodes are editable and where their data is sourced.
|
||||||
|
|
||||||
|
- When a node is resolved via FK (a real DB record is selected), all child nodes in that branch are automatically populated from that ancestor's own record and become **read-only / locked**. The user cannot enter freeform text into a child node whose parent resolved via FK. Locked nodes display the ancestor's data in a distinct locked style (e.g., greyed out with a lock icon) and are not interactive.
|
||||||
|
- When a node has no FK (empty, freeform, or Unknown), its child nodes are **editable**. The user may search for a DB record, enter freeform text, or mark as Unknown. Values entered here are saved to the subject's flat `_name_text` columns.
|
||||||
|
- This means "Enter manually" and "Unknown" options are only available on a node whose immediate parent has no FK. They must not appear on locked nodes.
|
||||||
|
|
||||||
|
When a DB-record ancestor is selected, cascade-populate its branch as deep as that ancestor's own records go. If a grandparent is a DB record but has no sire or dam recorded, the great-grandparent nodes in that branch remain editable (chain is broken at the grandparent level). If a grandparent has freeform text for its own parent, display that text in the locked node in muted style.
|
||||||
|
|
||||||
|
**Validation:** Render the component in isolation. Typing in the Sire node triggers a search request and shows results. Selecting a DB rabbit as Sire fills the node, auto-populates grandparent nodes from that rabbit's own record, and locks those grandparent nodes — they must not be interactive. Confirm the locked grandparent nodes do not show the search dropdown when clicked.
|
||||||
|
|
||||||
|
Clear the Sire node (set to Unknown). Confirm the grandparent nodes in the sire branch become editable again. Type a freeform name into the Sire's Sire node and confirm it saves to the subject's `paternal_grandsire_name_text` column with no FK.
|
||||||
|
|
||||||
|
Select "Unknown" on a parent node and confirm its child nodes are editable. Select "Enter manually" on a parent node and confirm its child nodes are also editable (chain is broken).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7.2 — Build the rabbit details form
|
||||||
|
|
||||||
|
Create a `RabbitDetailsForm` component. It renders all fields from the design document's Step 2:
|
||||||
|
|
||||||
|
- Name (text input, required, marked with an asterisk)
|
||||||
|
- Farm / Prefix (text input, pre-filled from the `rabbitry_name` settings value)
|
||||||
|
- Sex (three-button segmented control: Buck / Doe / Unknown)
|
||||||
|
- Date of Birth (date input)
|
||||||
|
- Weight (number input plus a unit toggle: "decimal lbs" or "lbs + oz", driven by the `weight_unit` setting)
|
||||||
|
- Ear Number (text input)
|
||||||
|
- Location (text input)
|
||||||
|
- Status (select dropdown with all six valid values, defaulting to "active")
|
||||||
|
- Color Name (text input)
|
||||||
|
- Photo (file input, accepts image files only — `image/jpeg`, `image/png`, `image/webp` — shows a preview thumbnail after selection). Note: the photo is uploaded separately via `POST /api/rabbits/:id/photo` after the rabbit record is created. On the Add Rabbit page, the photo field is uploaded as a second request immediately after the initial `POST /api/rabbits` succeeds. On the Edit Rabbit page, a new photo selection replaces the existing one via the same endpoint.
|
||||||
|
|
||||||
|
**Validation:** Render the form in isolation with no initial values. The Farm field is pre-filled with the settings value. The weight unit toggle switches between a single decimal input and two inputs (lbs + oz). Submitting the form without a Name shows a validation error on the Name field without submitting. All other fields are optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7.3 — Build the genetics section
|
||||||
|
|
||||||
|
Create a `GeneticsForm` component. It renders a collapsible section labeled "Genetics (optional)". When expanded, it shows pill-group toggles for each of the seven loci (A, B, C, D, E, En, V) with the correct allele options from the design document.
|
||||||
|
|
||||||
|
Below the loci selectors, render a read-only text field labeled "Generated color string" that displays a human-readable color description assembled from the selected loci (e.g., selecting `aa`, `BB`, `CC`, `DD`, `ee` produces "Self Black Non-Extension").
|
||||||
|
|
||||||
|
The section defaults to collapsed when adding a new rabbit. It defaults to expanded when editing a rabbit that already has any locus data set.
|
||||||
|
|
||||||
|
**Validation:** On the Add Rabbit page, the genetics section is collapsed by default and expands when clicked. Selecting loci updates the generated color string. On the Edit page for a rabbit with locus data, the section is expanded by default and shows the correct existing selections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7.4 — Build the review step
|
||||||
|
|
||||||
|
Create a `ReviewStep` component. It displays a read-only summary card of all entered data: the pedigree tree (ancestors listed by role), all detail fields with their values, and the selected genetics loci. Fields with no value should show a dash rather than being omitted.
|
||||||
|
|
||||||
|
At the bottom, render a "Save Rabbit" button (`primary` variant) and a "Back" button (`secondary` variant) that returns to the details step.
|
||||||
|
|
||||||
|
**Validation:** Populate all fields in the form steps and navigate to the review step. All entered values appear correctly. Empty optional fields show a dash. The "Back" button returns to the details form without losing any entered data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7.5 — Assemble the Add Rabbit multi-step flow
|
||||||
|
|
||||||
|
On the `/herd/add` page, compose the three steps (Pedigree Tree → Rabbit Details + Genetics → Review) into a linear wizard. Show a step indicator at the top (Step 1 of 3, Step 2 of 3, Step 3 of 3).
|
||||||
|
|
||||||
|
A "Next" button advances to the next step. The "Next" button on Step 1 is always enabled (pedigree is optional). The "Next" button on Step 2 is disabled until the Name field has a value. On Step 3, clicking "Save Rabbit" submits a `POST /api/rabbits` request with all collected data, then redirects to the new rabbit's detail page at `/herd/:id`.
|
||||||
|
|
||||||
|
**Validation:** Complete the full flow with valid data. Confirm the POST request is sent with all fields and the correct ancestor IDs. Confirm the redirect lands on the new rabbit's detail page. Attempt to advance past Step 2 without a Name — confirm the button is disabled and/or shows a validation error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7.6 — Build the Edit Rabbit page
|
||||||
|
|
||||||
|
On `/herd/:id/edit`, fetch the existing rabbit record and its ancestors. Pre-populate all form fields and the pedigree tree with the existing data. The flow is a single page (not a wizard) with all three sections visible: pedigree tree at the top, details form in the middle, genetics section (expanded) below.
|
||||||
|
|
||||||
|
Submitting sends a `PATCH /api/rabbits/:id` request with only the changed fields. On success, redirect to the rabbit's detail page.
|
||||||
|
|
||||||
|
**Validation:** Open the edit page for a rabbit with full data. All fields are pre-filled correctly. Change the weight and save. Confirm the detail page shows the updated weight and all other fields are unchanged. Confirm only the changed field appears in the PATCH request body (check network tab).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Rabbit Detail Page
|
||||||
|
|
||||||
|
### Step 8.1 — Build the rabbit detail left column
|
||||||
|
|
||||||
|
On `/herd/:id`, fetch the rabbit record. Render the left column with:
|
||||||
|
|
||||||
|
- Photo (if `photo_path` is set, display the image; otherwise show the breed silhouette placeholder)
|
||||||
|
- Name as a Display-size heading
|
||||||
|
- Farm name as a secondary subheading (omit if null)
|
||||||
|
- Status badge using the `Badge` component
|
||||||
|
- A stats grid: Sex, DOB (formatted as "Month DD, YYYY"), Age (calculated), Weight, Ear Number, Location
|
||||||
|
- Registration Number and GC Number (only rendered if non-null)
|
||||||
|
- Color Name, followed by loci pills for any locus fields that are set
|
||||||
|
|
||||||
|
**Validation:** View the detail page for a rabbit with all fields populated. Confirm every field renders. View a rabbit with minimal data (name and sex only) — confirm no crashes and null fields are omitted gracefully. The status badge shows the correct color for each status value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8.2 — Build the read-only pedigree tree
|
||||||
|
|
||||||
|
In the right column of the rabbit detail page, fetch from `GET /api/rabbits/:id/ancestors`. Render the full 4-generation pedigree tree (root + 3 ancestor generations, 15 nodes) in the same visual layout as the entry tree, but read-only. Each ancestor node shows the rabbit's name and farm. Nodes with a database record are clickable links to that rabbit's detail page. Nodes with a freeform text value (stored in a `_name_text` column) display that text in a muted style with no link. Nodes where both the FK and text field are null display "Unknown" in a muted style.
|
||||||
|
|
||||||
|
**Validation:** On a rabbit with a full 4-generation tree in the database, all 14 ancestor nodes are populated and link to the correct detail pages. On a rabbit with unknown parents, those nodes display "Unknown" in muted text with no link. A node backed by freeform text displays the text in muted style with no link. Clicking a linked ancestor node navigates correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8.3 — Build the litter history section
|
||||||
|
|
||||||
|
Below the pedigree tree in the right column, query `GET /api/litters` filtered by the current rabbit appearing as sire or dam. Display matching litters in a compact table: partner name, kindle date, and kit count. Each row links to the litter detail page. Show nothing (no section heading) if the rabbit has no litter history.
|
||||||
|
|
||||||
|
**Validation:** For a doe with 3 recorded litters, the litter history table shows all 3 with correct dates and kit counts. For a rabbit with no litters, the section is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8.4 — Build the detail page action bar
|
||||||
|
|
||||||
|
At the top of the detail page, render an action bar with:
|
||||||
|
|
||||||
|
- "Edit" button — links to `/herd/:id/edit`
|
||||||
|
- "Add Litter" button — links to `/litters/add` with the rabbit's ID pre-filled as a query param (as sire or dam based on sex)
|
||||||
|
- "Mark For Sale" button — sends `PATCH /api/rabbits/:id` with `{ "status": "for_sale" }` and refreshes the page. If the rabbit's status is already `for_sale`, show "Mark Active" instead, which sets status back to `active`.
|
||||||
|
- "Archive" button — shows a confirmation prompt before sending `PATCH /api/rabbits/:id` with `{ "status": "deceased" }`. Uses the `danger` style.
|
||||||
|
|
||||||
|
**Validation:** Clicking "Edit" navigates correctly. Clicking "Mark For Sale" updates the status badge on the page without a full reload. Clicking "Archive" shows a confirmation prompt; cancelling does nothing; confirming updates the status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Litters
|
||||||
|
|
||||||
|
### Step 9.1 — Build the litter card component
|
||||||
|
|
||||||
|
Create a `LitterCard` component. It renders: sire name × dam name (with "×" as a separator), kindle date formatted as "Month DD, YYYY", kit count (e.g., "3 kits"), and two buttons: "View Kits" linking to `/litters/:id` and "Edit" linking to the litter's edit state. Use the `Card` component as the wrapper.
|
||||||
|
|
||||||
|
**Validation:** Render the card with a hardcoded litter prop. Confirm all fields display correctly. A litter with no sire should show "Unknown × [dam name]". Both buttons link to the correct routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9.2 — Build the Litters list page
|
||||||
|
|
||||||
|
On `/litters`, fetch all litters from `GET /api/litters`. Render them as a vertical list of `LitterCard` components, newest first. Add a "Record Litter" button in the page header action slot. Show the `EmptyState` component if no litters exist.
|
||||||
|
|
||||||
|
**Validation:** With litters in the database, they appear newest first. The "Record Litter" button links to `/litters/add`. With no litters, the empty state is shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9.3 — Build the Add Litter form
|
||||||
|
|
||||||
|
On `/litters/add`, render a form with the following fields:
|
||||||
|
|
||||||
|
- Sire (autocomplete search using `GET /api/rabbits/search?q=&sex=buck`, restricted to bucks only — or "Unknown")
|
||||||
|
- Dam (autocomplete search using `GET /api/rabbits/search?q=&sex=doe`, restricted to does only — or "Unknown")
|
||||||
|
- Kindle Date (date input, required)
|
||||||
|
- Kit Count (number input, minimum 0)
|
||||||
|
- Notes (textarea, optional)
|
||||||
|
|
||||||
|
If the page is loaded with a `?sire=<id>` or `?dam=<id>` query param (coming from a rabbit's detail page), pre-fill the corresponding field.
|
||||||
|
|
||||||
|
Submitting sends `POST /api/litters`. On success, redirect to the new litter's detail page.
|
||||||
|
|
||||||
|
**Validation:** Submit the form with a sire, dam, and kindle date. Confirm the POST is sent and the redirect lands on the new litter's detail page. Load the page with `?dam=<id>` and confirm the dam field is pre-filled. Submit without a kindle date and confirm a validation error appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9.4 — Build the Litter detail page
|
||||||
|
|
||||||
|
On `/litters/:id`, fetch the litter from `GET /api/litters/:id`. Render:
|
||||||
|
|
||||||
|
- Page header: "Sire × Dam" as the title, kindle date as subtitle
|
||||||
|
- Sire and Dam name links (to their respective rabbit detail pages)
|
||||||
|
- Kindle date, and an expected wean date calculated as 8 weeks after kindle date, formatted as "Month DD, YYYY"
|
||||||
|
- Kit table: Name, Sex, Color, Ear Number, Status badge, Weight. Each row's name links to the kit's rabbit detail page.
|
||||||
|
- "Add Kit" button that links to `/herd/add` with `?sire=<id>&dam=<id>&dob=<kindle_date>` pre-filled
|
||||||
|
|
||||||
|
> **Note (v1):** The litter detail page derives its kit list by matching Rabbit records where `sire_id` and `dam_id` match the litter's parents and `dob` equals the litter's `kindle_date` exactly. This is intentional for v1. To prevent mismatches, the Add Rabbit form must default the DOB field to the litter's kindle date when launched from the "Add Kit" button — the user should not have to type it. A date-range tolerance is deferred to a future release.
|
||||||
|
|
||||||
|
**Validation:** View a litter with 3 kits. All kit rows appear with correct data and link to the correct rabbit detail pages. The expected wean date is exactly 56 days after the kindle date. The "Add Kit" button pre-fills the Add Rabbit form with the correct sire, dam, and DOB. Manually clear the DOB field on the Add Rabbit form before saving — confirm that kit does not appear in the litter's kit list (exact match enforcement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Settings Page
|
||||||
|
|
||||||
|
### Step 10.1 — Build the Settings form
|
||||||
|
|
||||||
|
On `/settings`, fetch settings from `GET /api/settings`. Render a form with:
|
||||||
|
|
||||||
|
- Rabbitry Name (text input, pre-filled from `rabbitry_name`)
|
||||||
|
- Weight Display (segmented control: "Decimal lbs" / "lbs + oz", driven by `weight_unit`)
|
||||||
|
|
||||||
|
A "Save Settings" button sends `PATCH /api/settings` with the changed values and shows a success toast notification on completion.
|
||||||
|
|
||||||
|
**Validation:** Load the settings page and confirm the fields are pre-filled with the current values. Change the rabbitry name and save. Reload the page and confirm the new name persists. Switch the weight unit and confirm it persists across reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 10.2 — Build the database export
|
||||||
|
|
||||||
|
Add a "Export Database" button on the settings page. Clicking it sends a `GET /api/export` request. The backend should return the raw SQLite database file as a binary download with the filename `mykindlebox-backup-<YYYY-MM-DD>.db` and the content type `application/octet-stream`.
|
||||||
|
|
||||||
|
**Validation:** Clicking the export button triggers a file download in the browser. The downloaded file can be opened with a SQLite browser (e.g., DB Browser for SQLite) and contains the correct tables and data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11 — Polish and PWA
|
||||||
|
|
||||||
|
### Step 11.1 — Add a PWA manifest
|
||||||
|
|
||||||
|
Add a `manifest.json` to the client's public directory. Include: `name` ("MyKindlebox"), `short_name` ("Kindlebox"), `start_url` ("/"), `display` ("standalone"), `background_color` ("#F7F3EC"), `theme_color` ("#4A6741"), and at least one icon (192×192 PNG).
|
||||||
|
|
||||||
|
**Validation:** In Chrome devtools, open the Application tab → Manifest. All fields appear correctly with no warnings. On a mobile device (or devtools mobile emulation), the browser offers an "Add to Home Screen" prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 11.2 — Add a service worker for offline shell caching
|
||||||
|
|
||||||
|
Register a service worker that caches the app shell (HTML, JS, CSS bundles, and the static silhouette image). When the device is offline, the app should load and display cached pages rather than a browser error. API requests that fail offline should show an inline "You're offline" notice rather than a crash.
|
||||||
|
|
||||||
|
**Validation:** Load the app in Chrome. Go to devtools → Application → Service Workers and confirm the service worker is registered. Enable "Offline" mode in devtools Network tab. Reload the page — the app shell loads. Navigate to `/herd` — the page renders (even if the rabbit list shows the offline notice).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 11.3 — Add reduced motion support
|
||||||
|
|
||||||
|
Wrap all CSS transitions and animations in a `@media (prefers-reduced-motion: reduce)` media query so they are disabled when the user has this OS-level preference enabled.
|
||||||
|
|
||||||
|
**Validation:** In devtools, enable "Emulate CSS prefers-reduced-motion: reduce" in the Rendering panel. Confirm that any hover transitions and loading skeleton animations are absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 11.4 — Keyboard navigation audit
|
||||||
|
|
||||||
|
Verify that all interactive elements — buttons, links, form inputs, autocomplete dropdowns, segmented controls — are reachable and operable via keyboard alone (Tab to focus, Enter/Space to activate). All focused elements must have a visible focus ring using the accent color.
|
||||||
|
|
||||||
|
**Validation:** Starting from the top of any page, tab through all interactive elements. Every element receives a visible focus ring. No interactive element is skipped. The pedigree tree autocomplete dropdown can be opened, navigated with arrow keys, and confirmed with Enter, entirely without a mouse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of base implementation plan. Advanced features (genetics calculator, show tracking, health log, exports, public listing) are documented separately.*
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
# MyKindlebox: Shows Section — Design Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a **Shows** section to MyKindlebox that pulls live show listings from the ARBA show search page, lets users filter and browse upcoming shows, build personal show events, assign rabbits from their herd, track judges and placements, and generate a show entries report for email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ARBA Data Source
|
||||||
|
|
||||||
|
**Source URL:** `https://arba.net/show-search/`
|
||||||
|
|
||||||
|
The page renders a DataTables-powered table client-side from an embedded dataset. The table columns map to the following data fields:
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `sanctionNumber` | e.g. `YOA4567` — unique per show |
|
||||||
|
| `showDateStart` | String, format varies (`06/26/2026` or `6/27/2026`) |
|
||||||
|
| `stateProvinceCountry` | State/province abbreviation; blank for international |
|
||||||
|
| `district` | ARBA district number (1–9+); blank for international |
|
||||||
|
| `city` | City name |
|
||||||
|
| `showInformation` | Club name + show type (e.g. `OPEN ALL BREED SHOW`, `YOUTH SPECIALTY BREED SHOW, HOLLAND LOP`) |
|
||||||
|
| `showAddress` | Full street address or just city/state if no address on file |
|
||||||
|
| `mapsUrl` | Google Maps link (may be absent) |
|
||||||
|
| `showSecretary` | Secretary name |
|
||||||
|
| `email` | Secretary email |
|
||||||
|
| `catalogUrl` | PDF catalog link on `arba.net` (optional, ~20% of shows) |
|
||||||
|
|
||||||
|
### Data Fetch Strategy
|
||||||
|
|
||||||
|
The ARBA page renders via JavaScript (DataTables). Options:
|
||||||
|
|
||||||
|
**Option A — Server-side proxy scrape (recommended)**
|
||||||
|
- Node.js backend route: `GET /api/shows/arba`
|
||||||
|
- Use `puppeteer` or `playwright` headless browser to load the page, wait for the table to render, then extract the full dataset as JSON.
|
||||||
|
- Cache result in SQLite with a `fetched_at` timestamp. Refresh on first request of the day (ARBA updates Mon–Fri during business hours).
|
||||||
|
- Cache table: `arba_shows_cache` with columns: `id`, `fetched_at`, `data` (JSON blob).
|
||||||
|
|
||||||
|
**Option B — Parse embedded data**
|
||||||
|
- The DataTables source may be an inline JSON variable in the page source. Inspect and parse with `cheerio` if available — faster than headless browser, but brittle if ARBA changes rendering.
|
||||||
|
|
||||||
|
Recommendation: start with Option B, fall back to headless if the JSON isn't in source.
|
||||||
|
|
||||||
|
### Parsed Show Fields
|
||||||
|
|
||||||
|
After scraping, normalize to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sanctionNumber": "YOA4567",
|
||||||
|
"date": "2026-06-26",
|
||||||
|
"state": "SC",
|
||||||
|
"district": 9,
|
||||||
|
"city": "LAURENS",
|
||||||
|
"clubName": "FLINT FIRE UNITED RABBIT REBELS",
|
||||||
|
"showType": "OPEN ALL BREED SHOW",
|
||||||
|
"showTypeFull": "OPEN ALL BREED SHOW",
|
||||||
|
"specialBreed": null,
|
||||||
|
"address": "186 FAIRGROUNDS RD, LAURENS, SC 29360",
|
||||||
|
"mapsUrl": "https://local.google.com/maps/?q=...",
|
||||||
|
"secretary": "SHELLY KURTZ",
|
||||||
|
"email": "SKURTZSHOWSECRETARY@GMAIL.COM",
|
||||||
|
"catalogUrl": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse `showInformation` to split into `clubName`, `showType` (OPEN/YOUTH ALL BREED or SPECIALTY), and `specialBreed` (the breed name after the last comma in specialty shows).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Shows Browser (Read-Only View)
|
||||||
|
|
||||||
|
### Route
|
||||||
|
`/shows` — top-level nav item in MyKindlebox sidebar.
|
||||||
|
|
||||||
|
### Filters (above the table)
|
||||||
|
|
||||||
|
| Filter | Type | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| State / Province | Multi-select dropdown | Populated dynamically from cached data. Includes blank option for international. |
|
||||||
|
| District | Multi-select dropdown | Values 1–9 plus blank. |
|
||||||
|
| Date From | Date picker | Defaults to today's date. |
|
||||||
|
| Date To | Date picker | Defaults to empty (no upper bound). |
|
||||||
|
| Show Type | Multi-select | ALL BREED OPEN, ALL BREED YOUTH, SPECIALTY OPEN, SPECIALTY YOUTH, FAIR SHOW |
|
||||||
|
| Keyword | Text input | Filters against club name, city, secretary |
|
||||||
|
|
||||||
|
All filters are client-side after the initial load. A **Reset Filters** button restores defaults.
|
||||||
|
|
||||||
|
A **Refresh Data** button (with last-fetched timestamp) triggers a server-side re-scrape.
|
||||||
|
|
||||||
|
### Results Table
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- Date
|
||||||
|
- State
|
||||||
|
- District
|
||||||
|
- City
|
||||||
|
- Club / Show Name (parsed from showInformation)
|
||||||
|
- Show Type
|
||||||
|
- Specialty Breed (blank if all-breed)
|
||||||
|
- Secretary
|
||||||
|
- Email (mailto link)
|
||||||
|
- Address (links to Maps URL if present)
|
||||||
|
- Catalog (PDF icon link if present)
|
||||||
|
- Action — **"Add to Event"** button
|
||||||
|
|
||||||
|
Default sort: Date ascending, then State.
|
||||||
|
|
||||||
|
Pagination: 50 rows per page, client-side.
|
||||||
|
|
||||||
|
Each row is selectable. Selecting multiple rows enables a bulk **"Add selected to Event"** action in the toolbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model Additions
|
||||||
|
|
||||||
|
### `show_events` table
|
||||||
|
A user-created event grouping one or more ARBA shows together (e.g. a weekend away with 4 shows across 2 days).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE show_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `show_event_shows` table
|
||||||
|
Junction: which ARBA shows are included in an event.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE show_event_shows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
show_event_id INTEGER NOT NULL,
|
||||||
|
sanction_number TEXT NOT NULL,
|
||||||
|
show_date TEXT NOT NULL,
|
||||||
|
club_name TEXT NOT NULL,
|
||||||
|
show_type TEXT NOT NULL,
|
||||||
|
specialty_breed TEXT,
|
||||||
|
city TEXT,
|
||||||
|
state TEXT,
|
||||||
|
address TEXT,
|
||||||
|
secretary TEXT,
|
||||||
|
secretary_email TEXT,
|
||||||
|
catalog_url TEXT,
|
||||||
|
maps_url TEXT,
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (show_event_id) REFERENCES show_events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Store a snapshot of the ARBA data at time of adding (the ARBA listing may change or disappear).
|
||||||
|
|
||||||
|
### `show_event_judges` table
|
||||||
|
Judges assigned per show within an event.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE show_event_judges (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
show_event_show_id INTEGER NOT NULL,
|
||||||
|
judge_name TEXT NOT NULL,
|
||||||
|
judge_arba_number TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
FOREIGN KEY (show_event_show_id) REFERENCES show_event_shows(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `show_event_entries` table
|
||||||
|
Rabbits entered into a specific show within an event.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE show_event_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
show_event_show_id INTEGER NOT NULL,
|
||||||
|
rabbit_id INTEGER NOT NULL,
|
||||||
|
entry_class TEXT,
|
||||||
|
entry_number TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (show_event_show_id) REFERENCES show_event_shows(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (rabbit_id) REFERENCES rabbits(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`entry_class` is a free-text field — user fills in the class the rabbit is competing in (e.g. `Sr Buck`, `6-8 Doe`).
|
||||||
|
|
||||||
|
### `show_event_placements` table
|
||||||
|
Post-show placement tracking per entry.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE show_event_placements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entry_id INTEGER NOT NULL UNIQUE,
|
||||||
|
placement TEXT,
|
||||||
|
legs_earned INTEGER DEFAULT 0,
|
||||||
|
bos BOOLEAN DEFAULT 0,
|
||||||
|
bob BOOLEAN DEFAULT 0,
|
||||||
|
bis BOOLEAN DEFAULT 0,
|
||||||
|
ris BOOLEAN DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
FOREIGN KEY (entry_id) REFERENCES show_event_entries(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`placement` is free text to handle variety: `1st`, `2nd`, `BOB`, `BOSV`, etc. The boolean flags (`bob`, `bos`, `bis`, `ris`) are quick toggles for rollup stats.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Show Event Management
|
||||||
|
|
||||||
|
### Route
|
||||||
|
`/shows/events` — list of user's show events.
|
||||||
|
`/shows/events/:id` — event detail / management page.
|
||||||
|
`/shows/events/new` — create new event.
|
||||||
|
|
||||||
|
### Event List View
|
||||||
|
- Cards or table showing event name, date range of included shows, number of shows, number of rabbits entered.
|
||||||
|
- Quick access to open/edit/delete.
|
||||||
|
|
||||||
|
### Event Detail View
|
||||||
|
|
||||||
|
Organized in tabs or sections:
|
||||||
|
|
||||||
|
**Shows Tab**
|
||||||
|
- List of all ARBA shows added to this event, sorted by date.
|
||||||
|
- Each show card shows: date, sanction #, club name, show type, city/state, secretary email, catalog link.
|
||||||
|
- Per show: Judge(s) management (add/edit/remove judges with name and optional ARBA number).
|
||||||
|
- Button to remove a show from the event.
|
||||||
|
- Button to add more shows (opens the browser modal).
|
||||||
|
|
||||||
|
**Entries Tab**
|
||||||
|
- Per-show accordion or grouped list.
|
||||||
|
- Each show shows the rabbits entered into it.
|
||||||
|
- "Add Rabbit" opens a picker from the user's herd — searchable by name, tattoo, breed, variety.
|
||||||
|
- Each entry row: Rabbit name, tattoo, breed, variety, entry class (editable inline), notes.
|
||||||
|
- Remove entry button.
|
||||||
|
- After the show date, an "Enter Placements" toggle switches the row into placement-entry mode: placement text field, legs +/- counter, BOB/BOS/BIS/RIS checkboxes.
|
||||||
|
|
||||||
|
**Results Summary Tab**
|
||||||
|
- Aggregate view of all placements across the event.
|
||||||
|
- Rabbits sorted by total legs earned.
|
||||||
|
- Highlight BOB/BOS/BIS/RIS with badge icons.
|
||||||
|
- Read-only — auto-generated from placement data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Show Entries Report
|
||||||
|
|
||||||
|
Accessible from the Entries tab as **"Generate Entries Report"**.
|
||||||
|
|
||||||
|
Output is a plain-text formatted block in a modal with a **"Copy to Clipboard"** button. Suitable for pasting directly into an email to the show secretary.
|
||||||
|
|
||||||
|
### Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
SHOW ENTRIES — [Event Name]
|
||||||
|
Generated: [date]
|
||||||
|
|
||||||
|
==============================================
|
||||||
|
[Club Name] — [Show Type]
|
||||||
|
Sanction #: [YOA1234]
|
||||||
|
Date: [MM/DD/YYYY]
|
||||||
|
Secretary: [Name] <[email]>
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
RABBITS ENTERED:
|
||||||
|
|
||||||
|
1. [Rabbit Name] ([Tattoo])
|
||||||
|
Breed: [Breed] / [Variety]
|
||||||
|
Class: [Entry Class]
|
||||||
|
Owner: [User's rabbitry name or full name]
|
||||||
|
ARBA #: [user's ARBA membership number if stored]
|
||||||
|
|
||||||
|
2. [Rabbit Name] ([Tattoo])
|
||||||
|
Breed: [Breed] / [Variety]
|
||||||
|
Class: [Entry Class]
|
||||||
|
Owner: [User's rabbitry name or full name]
|
||||||
|
ARBA #: [user's ARBA membership number if stored]
|
||||||
|
|
||||||
|
[Additional rabbits...]
|
||||||
|
|
||||||
|
==============================================
|
||||||
|
[Next Show in Event if applicable...]
|
||||||
|
==============================================
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-show sections are repeated for each show in the event. If a rabbit is entered in multiple shows within the event, it appears under each show it is entered in.
|
||||||
|
|
||||||
|
### Report Settings (optional, stored per user)
|
||||||
|
- Include/exclude owner ARBA number
|
||||||
|
- Include/exclude rabbit registration numbers (if tracked in herd)
|
||||||
|
- Custom header/footer text block
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI / UX Notes
|
||||||
|
|
||||||
|
### Adding Shows to an Event Flow
|
||||||
|
|
||||||
|
1. User clicks **"Add to Event"** on a row in the shows browser.
|
||||||
|
2. Modal appears: "Add to which event?" with dropdown of existing events + "Create new event" option.
|
||||||
|
3. If creating new, prompt for event name and optional notes inline.
|
||||||
|
4. Confirm — show is added to event. Toast confirmation with link to the event.
|
||||||
|
|
||||||
|
Alternatively, from the event detail page, a **"Browse Shows"** button opens the full browser in a slide-over panel where selecting shows adds them directly to the event in context.
|
||||||
|
|
||||||
|
### Placement Entry UX
|
||||||
|
- Placement entry should feel fast. After the show, the user flips a toggle on each row: "Mark Results."
|
||||||
|
- Tab key should advance through fields (placement → legs → BOB → BOS checkboxes).
|
||||||
|
- Legs can be incremented by clicking + since this is a count field (0–3 per show).
|
||||||
|
|
||||||
|
### Linking to Existing Herd Data
|
||||||
|
- Rabbit picker pulls from the `rabbits` table already in MyKindlebox.
|
||||||
|
- Filter by breed and variety to find the right animal quickly.
|
||||||
|
- Show tattoo prominently — this is the primary show identifier.
|
||||||
|
- If a rabbit is already entered in another show within the same event, show a small indicator (useful when the same rabbit runs in multiple shows on the same weekend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend API Endpoints
|
||||||
|
|
||||||
|
| Method | Route | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/shows/arba` | Returns cached ARBA show list. Triggers refresh if stale (>24h). |
|
||||||
|
| POST | `/api/shows/arba/refresh` | Force re-scrape. |
|
||||||
|
| GET | `/api/shows/events` | List user's show events. |
|
||||||
|
| POST | `/api/shows/events` | Create new event. |
|
||||||
|
| GET | `/api/shows/events/:id` | Event detail with shows, judges, entries, placements. |
|
||||||
|
| PATCH | `/api/shows/events/:id` | Update event name/notes. |
|
||||||
|
| DELETE | `/api/shows/events/:id` | Delete event. |
|
||||||
|
| POST | `/api/shows/events/:id/shows` | Add an ARBA show to an event. |
|
||||||
|
| DELETE | `/api/shows/events/:id/shows/:showId` | Remove a show from an event. |
|
||||||
|
| POST | `/api/shows/events/:id/shows/:showId/judges` | Add a judge to a show. |
|
||||||
|
| DELETE | `/api/shows/events/:id/shows/:showId/judges/:judgeId` | Remove a judge. |
|
||||||
|
| POST | `/api/shows/events/:id/shows/:showId/entries` | Add a rabbit entry. |
|
||||||
|
| DELETE | `/api/shows/events/:id/shows/:showId/entries/:entryId` | Remove a rabbit entry. |
|
||||||
|
| PUT | `/api/shows/events/:id/shows/:showId/entries/:entryId/placement` | Set or update placement for an entry. |
|
||||||
|
| GET | `/api/shows/events/:id/report` | Returns the formatted entries report as plain text. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Phases
|
||||||
|
|
||||||
|
**Phase 1 — ARBA Browser**
|
||||||
|
- Scraper/cache layer (Node, SQLite).
|
||||||
|
- `/shows` route with filter UI and results table.
|
||||||
|
- No event functionality yet — just browse and view.
|
||||||
|
|
||||||
|
**Phase 2 — Event Management**
|
||||||
|
- `show_events`, `show_event_shows` tables and all CRUD.
|
||||||
|
- "Add to Event" flow from browser.
|
||||||
|
- Event list and detail views (Shows tab only).
|
||||||
|
|
||||||
|
**Phase 3 — Entries**
|
||||||
|
- `show_event_entries` table.
|
||||||
|
- Rabbit picker integration.
|
||||||
|
- Entries tab in event detail.
|
||||||
|
- Entries report generation.
|
||||||
|
|
||||||
|
**Phase 4 — Judges & Placements**
|
||||||
|
- `show_event_judges` and `show_event_placements` tables.
|
||||||
|
- Judge management UI per show.
|
||||||
|
- Placement entry UI per entry.
|
||||||
|
- Results Summary tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Resolved Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| ARBA scrape reliability | No user fallback. If the scraper fails, surface an error state in the UI and log it server-side. The cache remains available until it can be refreshed. |
|
||||||
|
| ARBA membership number | Not required to show rabbits; not collected or included in the entries report. |
|
||||||
|
| Rabbitry name | Already stored as part of farm setup — pull from existing profile for the report header. No new field needed. |
|
||||||
|
| Mobile filter bar | Filters collapse to a drawer on small screens; wide table scrolls horizontally. No special redesign needed. |
|
||||||
|
| Cavy shows | Filter out at the scrape/normalization layer. Any show with `CAVY` in the `showInformation` field is excluded before being written to the cache. |
|
||||||
|
| International shows | Only US shows displayed. Rows with a blank or non-US `stateProvinceCountry` field are excluded at the normalization layer. |
|
||||||
|
|
@ -245,58 +245,3 @@
|
||||||
- Validated via Playwright: confirmed `outline-color`/`outline-style`/`outline-width` computed as the accent green (`rgb(74, 103, 65)`) / `solid` / `2px` on focus across a `Link`-wrapped header button, a `FilterBar` sex-tab button, the sort `<select>`, the search `<input>`, and a sidebar `NavLink`. For the pedigree tree's Sire dropdown: searched against the real seeded data already in the dev DB (no test rows created), confirmed ArrowDown from the input moved focus to the first result button, ArrowUp from there returned focus to the input, and Escape closed the dropdown and returned focus to the slot's trigger button. Ran `npm run build` clean and confirmed no new ESLint errors were introduced (the 5 pre-existing `react-hooks/refs`/`react-hooks/set-state-in-effect` errors in `AddRabbit.jsx`/`Settings.jsx`/`PedigreeTree.jsx` predate this phase — see the "one-shot default-fill" and mount-only-cascade-fetch conventions in `architecture.md`).
|
- Validated via Playwright: confirmed `outline-color`/`outline-style`/`outline-width` computed as the accent green (`rgb(74, 103, 65)`) / `solid` / `2px` on focus across a `Link`-wrapped header button, a `FilterBar` sex-tab button, the sort `<select>`, the search `<input>`, and a sidebar `NavLink`. For the pedigree tree's Sire dropdown: searched against the real seeded data already in the dev DB (no test rows created), confirmed ArrowDown from the input moved focus to the first result button, ArrowUp from there returned focus to the input, and Escape closed the dropdown and returned focus to the slot's trigger button. Ran `npm run build` clean and confirmed no new ESLint errors were introduced (the 5 pre-existing `react-hooks/refs`/`react-hooks/set-state-in-effect` errors in `AddRabbit.jsx`/`Settings.jsx`/`PedigreeTree.jsx` predate this phase — see the "one-shot default-fill" and mount-only-cascade-fetch conventions in `architecture.md`).
|
||||||
|
|
||||||
**Phase 11 — Polish and PWA is now complete. This was the last phase in the base implementation plan.**
|
**Phase 11 — Polish and PWA is now complete. This was the last phase in the base implementation plan.**
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# OCR Pedigree Scanning (post-base feature)
|
|
||||||
|
|
||||||
**Reference:** `memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md` (step-by-step plan), `memory-bank/FEATURE_OCR_Pedigree_Scan.md` (feature spec). All OCR processing is client-side; no server changes for the base feature (Phase 5 of that plan adds one herd-matching API *call*, not a new endpoint).
|
|
||||||
|
|
||||||
## OCR Phase 1 — Install and vendor OCR dependencies (complete, validated)
|
|
||||||
|
|
||||||
- **Step 1.1 — Install packages**: added `tesseract.js@^7.0.0` and `react-image-crop@^11.1.2` to `client/package.json`. Deliberately did **not** install `jimp` — all image preprocessing is done with the browser canvas API (OCR Phase 2), per the plan.
|
|
||||||
- Validated by the user: client dev server runs, no import errors, both packages present in `package.json`.
|
|
||||||
|
|
||||||
- **Step 1.2 — Vendor Tesseract assets locally** into `client/public/tesseract/` so nothing is fetched from a CDN at runtime (the app is self-hosted and must work offline). Five files:
|
|
||||||
- `worker.min.js` (copied from `tesseract.js/dist/`).
|
|
||||||
- `eng.traineddata` (5.0 MB, **uncompressed**). Downloaded as `.gz` from `@tesseract.js-data/eng/4.0.0_best_int` and decompressed before vendoring. Verified the result is a real tessdata binary (header bytes `18 00 00 00`), not an HTML error page or a still-gzipped blob.
|
|
||||||
- Three WASM core variants: `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (~3.8 MB each, copied from the `tesseract.js-core` package).
|
|
||||||
- **Why three core files (decision):** tesseract.js v7 defaults to OEM `LSTM_ONLY`, so at worker-load time it runs `wasm-feature-detect` and requests exactly **one** of the three `-lstm` cores from the `corePath` directory — `relaxedsimd-lstm` if the browser has relaxed-SIMD, else `simd-lstm`, else plain `lstm` (logic in `tesseract.js/src/worker-script/browser/getCore.js`). Vendoring all three lets a **directory-based** `corePath` ('/tesseract/') auto-select the correct core on any browser, instead of hard-pinning one variant. The non-`-lstm` and `.asm` cores are never requested unless `legacyCore`/a non-LSTM OEM is set, so they're intentionally omitted.
|
|
||||||
- **Why only the `.wasm.js` files, not the bare `.wasm`:** the `tesseract-core-*-lstm.wasm.js` files (~3.9 MB) embed the WASM binary as base64 — they're the self-contained single-file builds that `getCore.js` loads via `importScripts`. The separate `.wasm` files pair with the small (~89 KB) non-`.wasm.js` loaders, which we don't use. Confirmed by the size ratio (3.9 MB JS ≈ 89 KB loader + base64 of the 2.85 MB binary) and by inspecting the file head.
|
|
||||||
- **HANDOFF NOTE for OCR Phase 3 (`ocrRun.js`):** because the vendored `eng.traineddata` is **uncompressed**, the Tesseract worker must be initialized with `langPath: '/tesseract/'` **and `gzip: false`** — the default `gzip: true` makes the worker request `eng.traineddata.gz` (404 here). Pair with `corePath: '/tesseract/'` (directory → auto-select, per above) and `workerPath: '/tesseract/worker.min.js'`.
|
|
||||||
- Validated by the user (asset files present and served; full offline-recognize check is only fully exercisable once Phase 3 wires up `ocrRun.js`).
|
|
||||||
|
|
||||||
- **Step 1.3 — Service worker caching** (`client/public/sw.js`): the five `/tesseract/` assets are added to a `TESSERACT_URLS` list and precached on `install` alongside the existing `SHELL_URLS` (same `mykindlebox-shell-v1` cache, which the `activate` cleanup already preserves). The `fetch` handler gained a dedicated **cache-first** branch for `/tesseract/*` (serve the stored copy outright when present — no network round-trip — and only fetch+store on a cache miss), because these assets are large, static, and versioned with the app. The rest of the app shell stays **network-first** (unchanged); `/api/*` and `/uploads/*` are still skipped entirely. `node --check` passes.
|
|
||||||
- **Note:** `cache.addAll` is atomic, so all five `/tesseract/` URLs must be served correctly or the SW won't install — they resolve from `public/` at the site root in both Vite dev and the nginx/dist production build.
|
|
||||||
- Validated by the user: after one online load, the Tesseract assets appear in Cache Storage and the app still resolves them offline.
|
|
||||||
|
|
||||||
**OCR Phase 1 is complete and user-validated.**
|
|
||||||
|
|
||||||
## OCR Phase 2 — Image preprocessing utility (complete, validated)
|
|
||||||
|
|
||||||
- **Step 2.1 — Create `client/src/lib/ocrPreprocess.js`**: a canvas-only (no `jimp`) module exporting `async preprocessImage(file)` plus two tunable constants, `MAX_WIDTH` (1600) and `BINARIZE_THRESHOLD`. `preprocessImage` accepts a `File`/`Blob` and resolves to a PNG `Blob`: decode via `URL.createObjectURL` (object URL revoked on load), draw to a canvas scaling **down** proportionally only when wider than `MAX_WIDTH` (never upscales), then a single pass over the `getImageData` buffer does grayscale (luminance `0.299R + 0.587G + 0.114B`) and binarization to pure black/white in one loop, and `toBlob` returns it as PNG. `BINARIZE_THRESHOLD` is exported (not inlined) precisely so the Step 2.2 tuning is a one-line change and is reusable if any later step needs the same cutoff.
|
|
||||||
- ESLint clean.
|
|
||||||
|
|
||||||
- **Step 2.2 — Validate on a real pedigree photo + threshold tuning**: validated against a real photographed Holland Lop pedigree (2880×2160 phone photo) via a **temporary** dev harness — `src/pages/OcrDev.jsx` on a throwaway `/ocr-dev` route (file picker + original / module-output / live-threshold-preview panels with dimension readouts). Both the harness file and its route were **removed** after validation (App.jsx reverted), consistent with the project's remove-temporary-scaffolding convention.
|
|
||||||
- **Threshold tuned 128 → 103**: at the 128 starting point, the photo's uneven lighting and shadowed/torn paper edges binarized into large black blobs that swallowed the bottom-row ancestor cells. Dropping to **103** cleared the blobs and kept every cell legible (confirmed visually in the harness, then baked into `BINARIZE_THRESHOLD`). Output correctly downscaled 2880→1600px wide (1600×1200).
|
|
||||||
- **Decision — keep a fixed global threshold, do not add Otsu (yet)**: a single fixed global threshold is inherently lighting-sensitive (103 is tuned to typical photos; a much darker/brighter one could still blob or fade). Otsu's method (per-image optimal global threshold from the histogram) was offered as a more robust, still-global alternative. The user chose to **keep the fixed 103** and stay within the plan's Phase 2 scope, revisiting only if real photos give trouble. If that day comes, Otsu is the intended upgrade — it slots into the same single binarization pass without touching the rest of the pipeline.
|
|
||||||
|
|
||||||
**OCR Phase 2 is complete and user-validated.**
|
|
||||||
|
|
||||||
## OCR Phase 3 — OCR + layout/parse utilities (complete, pending user validation)
|
|
||||||
|
|
||||||
All four modules are pure client-side libs under `client/src/lib/` with no server changes. The two stages that are pure data transforms (`ocrLayout.js`, `ocrParse.js`) got Node `--test` unit tests (matching the existing `pedigree.test.js`/`genetics.test.js` convention); the two browser/WASM-dependent stages (`ocrRun.js`, `ocrPipeline.js`) can't run under `node --test` (Tesseract worker + `createImageBitmap`) and get the plan's manual browser validation instead — which is what's pending user sign-off.
|
|
||||||
|
|
||||||
- **Step 3.1 — `ocrRun.js`** exports `runOCR(imageBlob)` and the `MIN_WORD_CONFIDENCE` (60) constant. A **single lazily-created worker** is memoized in a module-level `workerPromise` and reused across scans (loading the ~9MB core + traineddata is expensive). The worker is created via `createWorker('eng', undefined, {...})` with the OCR-Phase-1 handoff paths: `workerPath: '/tesseract/worker.min.js'`, `corePath: '/tesseract/'` (a **directory**, so tesseract.js v7 auto-selects the lstm/simd/relaxedsimd core for the browser), `langPath: '/tesseract/'`, and **`gzip: false`** (the vendored `eng.traineddata` is uncompressed — default `gzip:true` would 404 on `.gz`).
|
|
||||||
- **v7 output shape**: `Page` has no top-level `words`/`lines`; they're nested `blocks → paragraphs → lines → words`, and that structure is only populated when `blocks: true` is requested (default output is text-only). So `runOCR` calls `worker.recognize(blob, {}, { blocks: true })` and **flattens** the nesting into plain `{ words, lines }` arrays — the shape the layout stage expects — dropping any word with `confidence < 60` during the flatten.
|
|
||||||
|
|
||||||
- **Step 3.2–3.4 — `ocrLayout.js`** (three pure exports + `LINE_GROUP_TOLERANCE` = 10px). `detectColumns(words, imageWidth)` buckets words into 3 equal vertical bands by `bbox.x0` (clamped to 0..2 so an edge word can't overflow). `splitSireDam(columnWords)` sorts by `y0` and cuts at the largest adjacent vertical gap (degrades cleanly for 0/1-word columns). `aggregateCellText(words)` groups words into lines by anchoring each line to its first word's `y0` and admitting later words within `LINE_GROUP_TOLERANCE`, orders each line left-to-right by `x0`, and joins lines with `\n`.
|
|
||||||
- `ocrLayout.test.js`: synthetic word objects (just `text` + `bbox`) drive deterministic checks of band bucketing/edge-clamp, the largest-gap split, the empty/single-word degraders, and line grouping + left-to-right ordering from a deliberately out-of-order input.
|
|
||||||
|
|
||||||
- **Step 3.5 — `ocrParse.js`** exports `parseCellFields(cellText)` → `{ farmName, rabbitName, earNumber, dob, weight }` (nulls for missing fields — the `"Unknown"`/`0` sentinels are applied later at save, never here). Name uses the possessive split `^(.+?'s?)\s+(.+)$` on the **first non-label line** (lines starting with a known field-label keyword, or that are themselves a detectable ear/date/weight value, are skipped). Ear number prefers a labelled value (`Tattoo:`/`Ear #:`/etc., label matched case-insensitively, value extracted case-sensitively as `[A-Z0-9]{2,8}` so trailing lowercase letters of a following word aren't absorbed), falling back to a bare alphanumeric token alone on a line. DOB parses `M/D/YY(YY)` with `/` or `-`, maps 2-digit years to `20xx`, validates the month/day range, and emits `YYYY-MM-DD`. Weight checks the `lbs+oz` form first (`8 lbs 4 oz` → 8.25) then `lbs`-only; a bare number with no unit is intentionally **not** a weight.
|
|
||||||
- `ocrParse.test.js`: the plan's three required cases (all-fields-labelled, name+ear-only, name-without-possessive) plus per-rule coverage (apostrophe-only possessive, decimal-lbs, bare-number-rejected, 2-digit-year + dash dates, invalid-month rejection, labelled-ear-beats-bare-token, all-whitespace → all nulls).
|
|
||||||
|
|
||||||
- **Step 3.6 — `ocrPipeline.js`** exports `extractPedigree(imageBlob)` and a testable helper `splitGrandparentQuadrants(words)`. `extractPedigree` runs `runOCR` and reads the image's true width (`createImageBitmap`, since `detectColumns` needs it and the `Page` doesn't expose it) **in parallel** (`Promise.all`), splits into subject/parents/grandparents columns, splits parents via `splitSireDam`, splits grandparents into a 2×2 grid, and returns the 7-key `{ subject, sire, dam, sire_sire, sire_dam, dam_sire, dam_dam }` shape (each value the `parseCellFields` output).
|
|
||||||
- **Quadrant split deviation (documented)**: the plan says split the grandparent column "at the horizontal and vertical midpoints of the column." Image *height* isn't carried through the pipeline, so `splitGrandparentQuadrants` instead takes the midpoints of the grandparent **words' own bounding extent** (min/max of their `x0/x1`, `y0/y1`) and assigns each word by its center — top-left→`sire_sire`, bottom-left→`sire_dam`, top-right→`dam_sire`, bottom-right→`dam_dam`, per the plan's mapping. Using the text extent (vs. band geometry) also better reflects where the four cells actually sit.
|
|
||||||
|
|
||||||
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
// public/tesseract holds the vendored minified tesseract.js runtime — not our code.
|
globalIgnores(['dist']),
|
||||||
globalIgnores(['dist', 'public/tesseract']),
|
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Rabbit photos can be a few MB and CSV/JSON imports are allowed up to 10 MB
|
# Rabbit photos can be a few MB; the nginx default (1m) would reject them.
|
||||||
# (multer's fileSize limit, which counts only the file). nginx counts the whole
|
client_max_body_size 10m;
|
||||||
# request body including multipart framing, so give it headroom above 10m —
|
|
||||||
# otherwise an import file near the cap 413s here before the server can reply.
|
|
||||||
client_max_body_size 12m;
|
|
||||||
|
|
||||||
# API and uploaded files are reverse-proxied to the Node server so the whole
|
# API and uploaded files are reverse-proxied to the Node server so the whole
|
||||||
# app stays same-origin (session cookies, relative fetch URLs all just work).
|
# app stays same-origin (session cookies, relative fetch URLs all just work).
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-image-crop": "^11.1.2",
|
"react-router-dom": "^7.18.0"
|
||||||
"react-router-dom": "^7.18.0",
|
|
||||||
"tesseract.js": "7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
@ -1168,12 +1166,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bmp-js": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
|
|
@ -1890,12 +1882,6 @@
|
||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb-keyval": {
|
|
||||||
"version": "6.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
|
|
||||||
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -1978,12 +1964,6 @@
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-url": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -2496,26 +2476,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.48",
|
"version": "2.0.48",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
||||||
|
|
@ -2556,15 +2516,6 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/opencollective-postinstall": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"opencollective-postinstall": "index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -2907,15 +2858,6 @@
|
||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-image-crop": {
|
|
||||||
"version": "11.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
|
|
||||||
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.13.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||||
|
|
@ -2990,12 +2932,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.13.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
|
|
@ -3216,30 +3152,6 @@
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tesseract.js": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"bmp-js": "^0.1.0",
|
|
||||||
"idb-keyval": "^6.2.0",
|
|
||||||
"is-url": "^1.2.4",
|
|
||||||
"node-fetch": "^2.6.9",
|
|
||||||
"opencollective-postinstall": "^2.0.3",
|
|
||||||
"regenerator-runtime": "^0.13.3",
|
|
||||||
"tesseract.js-core": "^7.0.0",
|
|
||||||
"wasm-feature-detect": "^1.8.0",
|
|
||||||
"zlibjs": "^0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tesseract.js-core": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
|
@ -3293,12 +3205,6 @@
|
||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|
@ -3453,28 +3359,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wasm-feature-detect": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -3521,15 +3405,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zlibjs": {
|
|
||||||
"version": "0.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
|
||||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-image-crop": "^11.1.2",
|
"react-router-dom": "^7.18.0"
|
||||||
"react-router-dom": "^7.18.0",
|
|
||||||
"tesseract.js": "7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,8 @@
|
||||||
const CACHE_NAME = 'mykindlebox-shell-v1'
|
const CACHE_NAME = 'mykindlebox-shell-v1'
|
||||||
const SHELL_URLS = ['/', '/favicon.svg', '/icon-192.png', '/manifest.json']
|
const SHELL_URLS = ['/', '/favicon.svg', '/icon-192.png', '/manifest.json']
|
||||||
|
|
||||||
// Vendored Tesseract OCR runtime: the worker, the three feature-detect WASM core
|
|
||||||
// variants (relaxedSIMD / SIMD / plain LSTM — only one is loaded per browser, but
|
|
||||||
// we can't know which ahead of time, so all are cached), and the English language
|
|
||||||
// data. These are large (~16 MB total) but static and versioned with the app, so
|
|
||||||
// they're precached on install and served cache-first (see the fetch handler):
|
|
||||||
// once stored they never need re-fetching, and the pedigree scanner must work
|
|
||||||
// fully offline. See FEATURE_OCR_Pedigree_Scan.md Q7.
|
|
||||||
//
|
|
||||||
// UPGRADE CONTRACT: cache-first means installed clients serve these from cache
|
|
||||||
// forever. If tesseract.js is ever upgraded (package.json pins the exact version
|
|
||||||
// for this reason) the files under public/tesseract/ must be re-vendored AND
|
|
||||||
// CACHE_NAME must be bumped, or existing clients keep the old worker/WASM while
|
|
||||||
// the app bundle uses the new library.
|
|
||||||
const TESSERACT_URLS = [
|
|
||||||
'/tesseract/worker.min.js',
|
|
||||||
'/tesseract/tesseract-core-lstm.wasm.js',
|
|
||||||
'/tesseract/tesseract-core-simd-lstm.wasm.js',
|
|
||||||
'/tesseract/tesseract-core-relaxedsimd-lstm.wasm.js',
|
|
||||||
'/tesseract/eng.traineddata',
|
|
||||||
]
|
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)))
|
||||||
caches.open(CACHE_NAME).then((cache) =>
|
|
||||||
Promise.all([
|
|
||||||
cache.addAll(SHELL_URLS),
|
|
||||||
// The ~16 MB Tesseract batch is best-effort: if it fails (flaky first
|
|
||||||
// load, missing file in a bad deploy), the shell must still install —
|
|
||||||
// the cache-first fetch branch below backfills these lazily the next
|
|
||||||
// time the scanner runs online.
|
|
||||||
cache.addAll(TESSERACT_URLS).catch(() => {}),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
|
|
@ -57,26 +25,6 @@ self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) return
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) return
|
||||||
|
|
||||||
// Tesseract assets are static and large — cache-first. Serve the stored copy
|
|
||||||
// outright when present (no network round-trip, works offline), and only hit
|
|
||||||
// the network on a cache miss, storing the result for next time.
|
|
||||||
if (url.pathname.startsWith('/tesseract/')) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(request).then(
|
|
||||||
(cached) =>
|
|
||||||
cached ??
|
|
||||||
fetch(request).then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
const copy = response.clone()
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy))
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,6 @@ import ShowEvents from './pages/ShowEvents'
|
||||||
import ShowEventDetail from './pages/ShowEventDetail'
|
import ShowEventDetail from './pages/ShowEventDetail'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import NotFound from './pages/NotFound'
|
import NotFound from './pages/NotFound'
|
||||||
import OcrDev from './pages/OcrDev' // TEMP: OCR Phase 3 validation harness — remove with its route
|
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
|
@ -49,8 +48,6 @@ function App() {
|
||||||
{/* Standalone print route — deliberately outside <Layout> so the
|
{/* Standalone print route — deliberately outside <Layout> so the
|
||||||
sidebar/bottom-nav don't render (or print) on the pedigree slip. */}
|
sidebar/bottom-nav don't render (or print) on the pedigree slip. */}
|
||||||
<Route path="/herd/:id/pedigree" element={<PedigreePrint />} />
|
<Route path="/herd/:id/pedigree" element={<PedigreePrint />} />
|
||||||
{/* TEMP: OCR Phase 3 validation harness — remove with its import once validated. */}
|
|
||||||
<Route path="/ocr-dev" element={<OcrDev />} />
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/herd" element={<Herd />} />
|
<Route path="/herd" element={<Herd />} />
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,7 @@ import {
|
||||||
formatManualAncestorDisplay,
|
formatManualAncestorDisplay,
|
||||||
} from '../lib/pedigree'
|
} from '../lib/pedigree'
|
||||||
|
|
||||||
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: false }
|
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: true }
|
||||||
|
|
||||||
// Upper bound on the popup's height (its tallest state is the manual-entry
|
|
||||||
// form). Used to decide drop direction synchronously on open, before the popup
|
|
||||||
// mounts, so the very first click is positioned correctly.
|
|
||||||
const POPUP_MAX_HEIGHT = 360
|
|
||||||
|
|
||||||
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
||||||
// is the separate "Unknown" checkbox. They collapse into one stored string
|
// is the separate "Unknown" checkbox. They collapse into one stored string
|
||||||
|
|
@ -45,20 +40,6 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const triggerRef = useRef(null)
|
const triggerRef = useRef(null)
|
||||||
// Open the popup above the trigger when there isn't room below it (e.g. nodes
|
|
||||||
// near the bottom of the window) so the tall manual form doesn't get cut off.
|
|
||||||
const [dropUp, setDropUp] = useState(false)
|
|
||||||
|
|
||||||
// Decide drop direction from the trigger's position in the viewport. Called
|
|
||||||
// synchronously on open (the trigger already exists in the DOM) so the first
|
|
||||||
// click lands in the right place rather than flipping on the next open.
|
|
||||||
function updateDropUp() {
|
|
||||||
if (!triggerRef.current) return
|
|
||||||
const rect = triggerRef.current.getBoundingClientRect()
|
|
||||||
const spaceBelow = window.innerHeight - rect.bottom
|
|
||||||
const spaceAbove = rect.top
|
|
||||||
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
@ -144,7 +125,6 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!open) updateDropUp()
|
|
||||||
setOpen((v) => !v)
|
setOpen((v) => !v)
|
||||||
if (node.mode === 'manual') {
|
if (node.mode === 'manual') {
|
||||||
setMode('manual')
|
setMode('manual')
|
||||||
|
|
@ -165,11 +145,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute left-0 top-full z-10 mt-1 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm">
|
||||||
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
|
|
||||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex gap-1">
|
<div className="mb-2 flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -247,9 +223,9 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={manualForm.ear_number}
|
value={manualForm.ear_number}
|
||||||
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
onChange={(e) => setManualField('ear_number', e.target.value)}
|
||||||
placeholder="Ear Number *"
|
placeholder="Ear Number *"
|
||||||
className="w-full rounded border border-border px-2 py-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
||||||
|
|
@ -337,18 +313,14 @@ function PedigreeTree({
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
|
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
|
||||||
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
|
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
|
||||||
// Ids with a fetch already started, kept outside state so fetchCascade never
|
|
||||||
// sets state synchronously (it's called from a mount effect below). An id is
|
|
||||||
// never removed: on failure the cache stores null, and we don't retry.
|
|
||||||
const cascadeFetchStarted = useRef(new Set())
|
|
||||||
|
|
||||||
function setSlotState(slot, value) {
|
function setSlotState(slot, value) {
|
||||||
setState((prev) => ({ ...prev, [slot]: value }))
|
setState((prev) => ({ ...prev, [slot]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchCascade(id) {
|
function fetchCascade(id) {
|
||||||
if (cascadeCache.has(id) || cascadeFetchStarted.current.has(id)) return
|
if (cascadeCache.has(id)) return
|
||||||
cascadeFetchStarted.current.add(id)
|
setCascadeCache((prev) => new Map(prev).set(id, undefined))
|
||||||
getRabbitAncestors(id)
|
getRabbitAncestors(id)
|
||||||
.then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree)))
|
.then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree)))
|
||||||
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
|
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,6 @@ function AncestorNodeView({ path, label, ancestors }) {
|
||||||
{node.name}
|
{node.name}
|
||||||
{node.farm ? ` (${node.farm})` : ''}
|
{node.farm ? ` (${node.farm})` : ''}
|
||||||
</span>
|
</span>
|
||||||
{node.ear_number ? (
|
|
||||||
<span className="w-full truncate font-body text-xs text-text-secondary">Ear #{node.ear_number}</span>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -51,26 +48,19 @@ function Generation({ entries, ancestors }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `generations` controls how many ancestor columns to render. The detail page
|
function PedigreeTreeReadOnly({ subjectName, ancestors }) {
|
||||||
// uses 2 (parents + grandparents) to keep the inline tree compact; the full
|
|
||||||
// 3-generation tree lives on the dedicated print page.
|
|
||||||
function PedigreeTreeReadOnly({ subjectName, ancestors, generations = 3 }) {
|
|
||||||
// Each generation column is ~176px (incl. gap); narrower min-width for fewer
|
|
||||||
// generations lets the tree fit its column without horizontal scrolling.
|
|
||||||
const minWidth = generations >= 3 ? 720 : 544
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The scroll container fills (and shrinks with) its column; the tree scrolls
|
// The scroll container fills (and shrinks with) its column; the 720px-wide
|
||||||
// horizontally inside it rather than forcing the page wider and pushing the
|
// tree scrolls horizontally inside it rather than forcing the page wider and
|
||||||
// timeline column off-screen.
|
// pushing the timeline column off-screen.
|
||||||
<div className="w-full overflow-x-auto p-4">
|
<div className="w-full overflow-x-auto p-4">
|
||||||
<div className="flex items-center gap-4" style={{ minWidth }}>
|
<div className="flex min-w-[720px] items-center gap-4">
|
||||||
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
||||||
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
||||||
</div>
|
</div>
|
||||||
<Generation entries={GEN1} ancestors={ancestors} />
|
<Generation entries={GEN1} ancestors={ancestors} />
|
||||||
{generations >= 2 && <Generation entries={GEN2} ancestors={ancestors} />}
|
<Generation entries={GEN2} ancestors={ancestors} />
|
||||||
{generations >= 3 && <Generation entries={GEN3} ancestors={ancestors} />}
|
<Generation entries={GEN3} ancestors={ancestors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,8 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value.ear_number ?? ''}
|
value={value.ear_number ?? ''}
|
||||||
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
onChange={(e) => setField('ear_number', e.target.value)}
|
||||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
|
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
// Pedigree layout analysis (OCR Pedigree Scanning, Phase 3, Steps 3.2–3.4).
|
|
||||||
//
|
|
||||||
// A printed pedigree lays its ancestor cells out in vertical columns — one per
|
|
||||||
// generation — that fan left→right: subject, then parents, then grandparents,
|
|
||||||
// and (on a real 4–5 generation slip) great- and great-great-grandparents
|
|
||||||
// beyond. Within a column the cells stack vertically and double each generation
|
|
||||||
// (1 subject, 2 parents, 4 grandparents, …).
|
|
||||||
//
|
|
||||||
// These pure helpers take the flat word list from `runOCR` (each word carrying a
|
|
||||||
// `bbox` of {x0,y0,x1,y1}) and slice it into those columns/cells purely from the
|
|
||||||
// words' geometry — no OCR is re-run here. Column detection is *adaptive*: it
|
|
||||||
// finds the real whitespace gutters between generations rather than assuming a
|
|
||||||
// fixed number of equal-width bands, so it works regardless of how many
|
|
||||||
// generations the photo happens to contain. The pipeline (ocrPipeline.js) then
|
|
||||||
// anchors on the subject column and keeps only the first three generations.
|
|
||||||
|
|
||||||
// Two words whose top edges (y0) fall within this many pixels of each other are
|
|
||||||
// treated as being on the same printed line by `aggregateCellText`. Coordinates
|
|
||||||
// are in the preprocessed image space (<=1600px wide), so a few px of slop
|
|
||||||
// absorbs OCR baseline jitter without merging adjacent rows.
|
|
||||||
export const LINE_GROUP_TOLERANCE = 10
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the pedigree's generation columns from the whitespace gutters between
|
|
||||||
* them. Projects every word onto an x-axis coverage histogram, treats a run of
|
|
||||||
* empty bins at least `minGutterPx` wide as a column break, and buckets each
|
|
||||||
* word into the column whose x-range contains the word's horizontal center.
|
|
||||||
*
|
|
||||||
* Unlike a fixed 3-band split, this adapts to however many generations are in
|
|
||||||
* the image — the caller decides which columns are subject/parents/grandparents.
|
|
||||||
*
|
|
||||||
* @param {object[]} words words with a `bbox` ({x0,x1})
|
|
||||||
* @param {number} imageWidth full image width in px
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {number} [opts.binSize] x-histogram bin width in px
|
|
||||||
* @param {number} [opts.minGutterPx] min empty-run width that splits two columns
|
|
||||||
* @returns {object[][]} non-empty column word arrays, ordered left→right
|
|
||||||
*/
|
|
||||||
export function detectColumns(words, imageWidth, opts = {}) {
|
|
||||||
if (words.length === 0) return []
|
|
||||||
|
|
||||||
const binSize = opts.binSize ?? Math.max(2, Math.round(imageWidth / 400))
|
|
||||||
const minGutterPx = opts.minGutterPx ?? imageWidth * 0.02
|
|
||||||
const minGutterBins = Math.max(1, Math.round(minGutterPx / binSize))
|
|
||||||
|
|
||||||
const binCount = Math.max(1, Math.ceil(imageWidth / binSize))
|
|
||||||
const coverage = new Array(binCount).fill(0)
|
|
||||||
for (const word of words) {
|
|
||||||
const start = Math.max(0, Math.floor(word.bbox.x0 / binSize))
|
|
||||||
const end = Math.min(binCount - 1, Math.floor(word.bbox.x1 / binSize))
|
|
||||||
for (let b = start; b <= end; b += 1) coverage[b] += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the histogram; a covered run is a column, an empty run >= minGutterBins
|
|
||||||
// closes the current column.
|
|
||||||
const ranges = []
|
|
||||||
let runStart = null
|
|
||||||
let emptyRun = 0
|
|
||||||
for (let b = 0; b < binCount; b += 1) {
|
|
||||||
if (coverage[b] > 0) {
|
|
||||||
if (runStart === null) runStart = b
|
|
||||||
emptyRun = 0
|
|
||||||
} else if (runStart !== null) {
|
|
||||||
emptyRun += 1
|
|
||||||
if (emptyRun >= minGutterBins) {
|
|
||||||
ranges.push({ x0: runStart * binSize, x1: (b - emptyRun + 1) * binSize })
|
|
||||||
runStart = null
|
|
||||||
emptyRun = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runStart !== null) ranges.push({ x0: runStart * binSize, x1: binCount * binSize })
|
|
||||||
|
|
||||||
const columns = ranges.map((r) => ({ ...r, words: [] }))
|
|
||||||
for (const word of words) {
|
|
||||||
const cx = (word.bbox.x0 + word.bbox.x1) / 2
|
|
||||||
let target = columns.find((c) => cx >= c.x0 && cx < c.x1)
|
|
||||||
if (!target) {
|
|
||||||
// Word center landed in a gutter — attach to the nearest column.
|
|
||||||
let bestDist = Infinity
|
|
||||||
for (const c of columns) {
|
|
||||||
const d = Math.abs(cx - (c.x0 + c.x1) / 2)
|
|
||||||
if (d < bestDist) {
|
|
||||||
bestDist = d
|
|
||||||
target = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (target) target.words.push(word)
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns.map((c) => c.words).filter((w) => w.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split one column's words into `n` vertically-stacked cells by cutting at the
|
|
||||||
* `n - 1` largest vertical gaps between adjacent words (sorted by top edge).
|
|
||||||
* If the column has fewer words than cells, the trailing cells come back empty.
|
|
||||||
*
|
|
||||||
* @param {object[]} columnWords one column's words
|
|
||||||
* @param {number} n number of cells (e.g. 2 for parents, 4 for grandparents)
|
|
||||||
* @returns {object[][]} exactly `n` word arrays, top→bottom
|
|
||||||
*/
|
|
||||||
export function splitIntoCells(columnWords, n) {
|
|
||||||
const sorted = [...columnWords].sort((a, b) => a.bbox.y0 - b.bbox.y0)
|
|
||||||
if (n <= 1) return [sorted]
|
|
||||||
|
|
||||||
const gaps = []
|
|
||||||
for (let i = 1; i < sorted.length; i += 1) {
|
|
||||||
gaps.push({ index: i, size: sorted[i].bbox.y0 - sorted[i - 1].bbox.y0 })
|
|
||||||
}
|
|
||||||
const cuts = gaps
|
|
||||||
.sort((a, b) => b.size - a.size)
|
|
||||||
.slice(0, n - 1)
|
|
||||||
.map((g) => g.index)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
const cells = []
|
|
||||||
let start = 0
|
|
||||||
for (const cut of cuts) {
|
|
||||||
cells.push(sorted.slice(start, cut))
|
|
||||||
start = cut
|
|
||||||
}
|
|
||||||
cells.push(sorted.slice(start))
|
|
||||||
while (cells.length < n) cells.push([])
|
|
||||||
return cells
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a parents column into the sire (upper) and dam (lower) halves — the
|
|
||||||
* common n=2 case of `splitIntoCells`, named for readability at the call site.
|
|
||||||
*
|
|
||||||
* @param {object[]} columnWords parents-column words
|
|
||||||
* @returns {{ sire: object[], dam: object[] }}
|
|
||||||
*/
|
|
||||||
export function splitSireDam(columnWords) {
|
|
||||||
const [sire, dam] = splitIntoCells(columnWords, 2)
|
|
||||||
return { sire, dam }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reassemble one cell's words into a multiline string. Words are grouped into
|
|
||||||
* lines by their top edge (within LINE_GROUP_TOLERANCE of the line's first
|
|
||||||
* word), each line is ordered left-to-right, and lines are joined top-to-bottom
|
|
||||||
* with newlines — reproducing roughly what is printed in that cell.
|
|
||||||
*
|
|
||||||
* @param {object[]} words one cell's words
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function aggregateCellText(words) {
|
|
||||||
if (words.length === 0) return ''
|
|
||||||
|
|
||||||
const byTop = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0)
|
|
||||||
const lines = []
|
|
||||||
let anchorY = null
|
|
||||||
for (const word of byTop) {
|
|
||||||
if (anchorY === null || word.bbox.y0 - anchorY > LINE_GROUP_TOLERANCE) {
|
|
||||||
lines.push([])
|
|
||||||
anchorY = word.bbox.y0
|
|
||||||
}
|
|
||||||
lines[lines.length - 1].push(word)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.map((line) =>
|
|
||||||
line
|
|
||||||
.sort((a, b) => a.bbox.x0 - b.bbox.x0)
|
|
||||||
.map((word) => word.text)
|
|
||||||
.join(' '),
|
|
||||||
)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { test } from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { detectColumns, splitIntoCells, splitSireDam, aggregateCellText } from './ocrLayout.js'
|
|
||||||
|
|
||||||
// Minimal word factory: only the fields the layout helpers read.
|
|
||||||
function word(text, x0, y0, x1 = x0 + 50, y1 = y0 + 20) {
|
|
||||||
return { text, bbox: { x0, y0, x1, y1 }, confidence: 90 }
|
|
||||||
}
|
|
||||||
|
|
||||||
test('detectColumns splits at whitespace gutters, ignoring how many columns there are', () => {
|
|
||||||
// Three generation columns with wide gutters between them; the parents and
|
|
||||||
// grandparents columns each have multiple vertically-stacked words at the
|
|
||||||
// same x — those must stay in one column, not split.
|
|
||||||
const words = [
|
|
||||||
word('Subject', 40, 500), // col A ~40–90
|
|
||||||
word('Sire', 400, 250), // col B ~400–450
|
|
||||||
word('Dam', 400, 750),
|
|
||||||
word('GrandA', 800, 120), // col C ~800–850
|
|
||||||
word('GrandB', 800, 380),
|
|
||||||
word('GrandC', 800, 640),
|
|
||||||
word('GrandD', 800, 900),
|
|
||||||
]
|
|
||||||
const cols = detectColumns(words, 1600)
|
|
||||||
assert.equal(cols.length, 3)
|
|
||||||
assert.deepEqual(cols[0].map((w) => w.text), ['Subject'])
|
|
||||||
assert.deepEqual(cols[1].map((w) => w.text).sort(), ['Dam', 'Sire'])
|
|
||||||
assert.deepEqual(cols[2].map((w) => w.text).sort(), ['GrandA', 'GrandB', 'GrandC', 'GrandD'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('detectColumns keeps words of one wide box together (no false split within a column)', () => {
|
|
||||||
// A single cell spanning x 40–300 across several lines: small inter-word gaps
|
|
||||||
// must not be mistaken for a column gutter.
|
|
||||||
const words = [
|
|
||||||
word("BASTET'S", 40, 100, 140),
|
|
||||||
word('SHANGHEI', 150, 100, 300),
|
|
||||||
word('Sable', 40, 130, 110),
|
|
||||||
word('Point', 120, 130, 200),
|
|
||||||
]
|
|
||||||
const cols = detectColumns(words, 1600)
|
|
||||||
assert.equal(cols.length, 1)
|
|
||||||
assert.equal(cols[0].length, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('splitIntoCells cuts a grandparents column into 4 by the largest vertical gaps', () => {
|
|
||||||
const col = [
|
|
||||||
word('SS', 800, 100),
|
|
||||||
word('SS2', 800, 122),
|
|
||||||
word('SD', 800, 400),
|
|
||||||
word('DS', 800, 700),
|
|
||||||
word('DD', 800, 1000),
|
|
||||||
]
|
|
||||||
const cells = splitIntoCells(col, 4)
|
|
||||||
assert.equal(cells.length, 4)
|
|
||||||
assert.deepEqual(cells[0].map((w) => w.text), ['SS', 'SS2'])
|
|
||||||
assert.deepEqual(cells[1].map((w) => w.text), ['SD'])
|
|
||||||
assert.deepEqual(cells[2].map((w) => w.text), ['DS'])
|
|
||||||
assert.deepEqual(cells[3].map((w) => w.text), ['DD'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('splitIntoCells pads with empty cells when there are too few words', () => {
|
|
||||||
const cells = splitIntoCells([word('only', 0, 0)], 4)
|
|
||||||
assert.equal(cells.length, 4)
|
|
||||||
assert.deepEqual(cells[0].map((w) => w.text), ['only'])
|
|
||||||
assert.deepEqual(cells[1], [])
|
|
||||||
assert.deepEqual(cells[3], [])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('splitSireDam cuts at the largest vertical gap', () => {
|
|
||||||
const column = [
|
|
||||||
word('Sire', 350, 40),
|
|
||||||
word('line2', 350, 62),
|
|
||||||
word('Dam', 350, 400),
|
|
||||||
word('line2', 350, 422),
|
|
||||||
]
|
|
||||||
const { sire, dam } = splitSireDam(column)
|
|
||||||
assert.deepEqual(sire.map((w) => w.text), ['Sire', 'line2'])
|
|
||||||
assert.deepEqual(dam.map((w) => w.text), ['Dam', 'line2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('aggregateCellText groups lines by y0 and orders words left-to-right', () => {
|
|
||||||
const words = [
|
|
||||||
word('Thumper', 120, 50),
|
|
||||||
word("Belowland's", 40, 52),
|
|
||||||
word('AB12', 60, 130),
|
|
||||||
word('Tattoo:', 40, 128),
|
|
||||||
]
|
|
||||||
assert.equal(aggregateCellText(words), "Belowland's Thumper\nTattoo: AB12")
|
|
||||||
})
|
|
||||||
|
|
||||||
test('aggregateCellText returns empty string for no words', () => {
|
|
||||||
assert.equal(aggregateCellText([]), '')
|
|
||||||
})
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
// Pedigree cell field extraction (OCR Pedigree Scanning, Phase 3, Step 3.5).
|
|
||||||
//
|
|
||||||
// Turns one cell's reassembled multiline text (from `aggregateCellText`) into
|
|
||||||
// the five structured fields the Add Rabbit wizard consumes. Everything here is
|
|
||||||
// pure string parsing with no OCR involved, so it is fully unit-testable
|
|
||||||
// (ocrParse.test.js). The OCR-side field names (`farmName`/`rabbitName`/
|
|
||||||
// `earNumber`/`weight`) are remapped to the serializer's shape later, in the
|
|
||||||
// wizard integration (plan Phase 5) — never here.
|
|
||||||
|
|
||||||
// "BASTET'S SHANGHEI" → farm "BASTET'S", name "SHANGHEI". Non-greedy first group
|
|
||||||
// so only the leading possessive token is split off as the farm. Case-insensitive
|
|
||||||
// so an ALL-CAPS possessive ("BASTET'S") matches its `'S` — real pedigrees are
|
|
||||||
// frequently printed entirely in caps.
|
|
||||||
const POSSESSIVE_RE = /^(.+?'s?)\s+(.+)$/i
|
|
||||||
// A line that is *only* a possessive farm token, with the rabbit's name printed
|
|
||||||
// on the following line ("BASTET'S" / "SHANGHEI").
|
|
||||||
const BARE_POSSESSIVE_RE = /^.+'s?$/i
|
|
||||||
|
|
||||||
// Ear-number / tattoo label, matched case-insensitively up to (and including)
|
|
||||||
// the separator; the value after it is matched case-sensitively as uppercase +
|
|
||||||
// digits so trailing lowercase letters from a following word aren't absorbed.
|
|
||||||
const EAR_LABEL_RE = /(?:tattoo|ear(?:\s*(?:#|no\.?|number))?)\s*[:#]?\s*/i
|
|
||||||
const EAR_VALUE_RE = /([A-Z0-9]{2,8})\b/
|
|
||||||
// A *bare* (unlabelled) ear token must contain at least one digit. Pedigrees are
|
|
||||||
// often all-caps, so an unlabelled all-letter token like "SHANGHEI"/"SHAMWOW" is
|
|
||||||
// a rabbit name, not an ear number — requiring a digit keeps names out of the
|
|
||||||
// ear field. (Labelled values, via EAR_VALUE_RE above, don't need a digit.)
|
|
||||||
const BARE_EAR_RE = /^(?=[A-Z0-9]*\d)([A-Z0-9]{2,8})$/
|
|
||||||
|
|
||||||
// MM/DD/YYYY, MM-DD-YYYY, MM/DD/YY, M/D/YY — separators / or -, 2- or 4-digit year.
|
|
||||||
const DATE_RE = /\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b/
|
|
||||||
|
|
||||||
// "8 lbs 4 oz" (also "8# 4oz") and "8.5 lbs" / "8.5 lb". A bare number with no
|
|
||||||
// unit is intentionally not treated as a weight.
|
|
||||||
const WEIGHT_LBS_OZ_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\s*(\d+(?:\.\d+)?)\s*oz\b/i
|
|
||||||
const WEIGHT_LBS_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\b/i
|
|
||||||
|
|
||||||
// Lines beginning with one of these are field labels, not the ancestor's name,
|
|
||||||
// so they're skipped when picking the name line.
|
|
||||||
const LABEL_PREFIXES = [
|
|
||||||
'tattoo', 'ear', 'dob', 'd.o.b', 'date', 'born', 'weight', 'wt',
|
|
||||||
'color', 'colour', 'variety', 'sex', 'breed', 'reg', 'grand',
|
|
||||||
]
|
|
||||||
|
|
||||||
function round2(n) {
|
|
||||||
return Math.round(n * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// A line is a "label line" if it leads with a known field label, or is itself a
|
|
||||||
// detectable ear number / date / weight value — anything that clearly isn't the
|
|
||||||
// printed name of the ancestor.
|
|
||||||
function isLabelLine(line) {
|
|
||||||
const lower = line.toLowerCase()
|
|
||||||
if (LABEL_PREFIXES.some((p) => lower.startsWith(p))) return true
|
|
||||||
if (BARE_EAR_RE.test(line)) return true
|
|
||||||
if (DATE_RE.test(line)) return true
|
|
||||||
if (WEIGHT_LBS_RE.test(line)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseName(lines) {
|
|
||||||
const idx = lines.findIndex((line) => !isLabelLine(line))
|
|
||||||
if (idx === -1) return { farmName: null, rabbitName: null }
|
|
||||||
const nameLine = lines[idx]
|
|
||||||
|
|
||||||
// Farm and name on one line: "BASTET'S SHANGHEI".
|
|
||||||
const match = nameLine.match(POSSESSIVE_RE)
|
|
||||||
if (match) {
|
|
||||||
return { farmName: match[1].trim(), rabbitName: match[2].trim() }
|
|
||||||
}
|
|
||||||
// Farm alone on its line, rabbit name on the next non-label line.
|
|
||||||
if (BARE_POSSESSIVE_RE.test(nameLine)) {
|
|
||||||
const next = lines.slice(idx + 1).find((line) => !isLabelLine(line))
|
|
||||||
if (next) return { farmName: nameLine.trim(), rabbitName: next.trim() }
|
|
||||||
}
|
|
||||||
return { farmName: null, rabbitName: nameLine }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEarNumber(lines) {
|
|
||||||
// Labelled value takes precedence ("Tattoo: AB12").
|
|
||||||
for (const line of lines) {
|
|
||||||
const label = line.match(EAR_LABEL_RE)
|
|
||||||
if (!label) continue
|
|
||||||
const after = line.slice(label.index + label[0].length)
|
|
||||||
const value = after.match(EAR_VALUE_RE)
|
|
||||||
if (value) return value[1].toUpperCase()
|
|
||||||
}
|
|
||||||
// Otherwise a bare alphanumeric token alone on its own line.
|
|
||||||
for (const line of lines) {
|
|
||||||
const bare = line.match(BARE_EAR_RE)
|
|
||||||
if (bare) return bare[1].toUpperCase()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDob(lines) {
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(DATE_RE)
|
|
||||||
if (!match) continue
|
|
||||||
|
|
||||||
const month = parseInt(match[1], 10)
|
|
||||||
const day = parseInt(match[2], 10)
|
|
||||||
let year = parseInt(match[3], 10)
|
|
||||||
// Two-digit years pivot on the current year: a DOB is never in the future
|
|
||||||
// (+1 slop for a slip printed around New Year), and older slips genuinely
|
|
||||||
// carry 1990s ancestors — so "24" → 2024 but "98" → 1998.
|
|
||||||
if (match[3].length === 2) {
|
|
||||||
const pivot = (new Date().getFullYear() + 1) % 100
|
|
||||||
year += year <= pivot ? 2000 : 1900
|
|
||||||
}
|
|
||||||
|
|
||||||
// new Date(y, m, 0) is the last day of month m, so this rejects impossible
|
|
||||||
// dates like 2/31 instead of emitting an invalid "YYYY-02-31" string.
|
|
||||||
const daysInMonth = new Date(year, month, 0).getDate()
|
|
||||||
if (month < 1 || month > 12 || day < 1 || day > daysInMonth) continue
|
|
||||||
|
|
||||||
const mm = String(month).padStart(2, '0')
|
|
||||||
const dd = String(day).padStart(2, '0')
|
|
||||||
return `${year}-${mm}-${dd}`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWeight(lines) {
|
|
||||||
// Prefer the lbs+oz form (it also partially matches the lbs-only regex).
|
|
||||||
for (const line of lines) {
|
|
||||||
const lo = line.match(WEIGHT_LBS_OZ_RE)
|
|
||||||
if (lo) return round2(parseFloat(lo[1]) + parseFloat(lo[2]) / 16)
|
|
||||||
}
|
|
||||||
for (const line of lines) {
|
|
||||||
const l = line.match(WEIGHT_LBS_RE)
|
|
||||||
if (l) return parseFloat(l[1])
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the five pedigree fields from one cell's multiline OCR text.
|
|
||||||
*
|
|
||||||
* @param {string} cellText output of `aggregateCellText`
|
|
||||||
* @returns {{ farmName: string|null, rabbitName: string|null,
|
|
||||||
* earNumber: string|null, dob: string|null, weight: number|null }}
|
|
||||||
* `dob` is `YYYY-MM-DD` or null; `weight` is decimal lbs or null. Missing
|
|
||||||
* fields are null — the "Unknown"/0 sentinels are only applied later, at save.
|
|
||||||
*/
|
|
||||||
export function parseCellFields(cellText) {
|
|
||||||
const lines = (cellText || '')
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...parseName(lines),
|
|
||||||
earNumber: parseEarNumber(lines),
|
|
||||||
dob: parseDob(lines),
|
|
||||||
weight: parseWeight(lines),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structural labels every printed ancestor cell carries (even when the value is
|
|
||||||
// blank): Ear #, Weight, DOB, Sex, Color, Legs, Reg #, GC #, Variety, Tattoo.
|
|
||||||
const CELL_LABEL_RE =
|
|
||||||
/\b(?:ear|tattoo|weight|wt|d\.?o\.?b|dob|sex|colou?r|legs?|reg|gc|variety)\b/i
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heuristic: does this cell text read like a real ancestor cell rather than the
|
|
||||||
* form's title/header or stray margin text? A genuine cell always has at least
|
|
||||||
* one printed field label; the title ("Bastet's Bunnies Holland Lop Pedigree")
|
|
||||||
* has none. The pipeline uses this to drop header text that lands in a cell slot.
|
|
||||||
*
|
|
||||||
* @param {string} cellText
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function looksLikeAncestorCell(cellText) {
|
|
||||||
return CELL_LABEL_RE.test(cellText || '')
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import { test } from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js'
|
|
||||||
|
|
||||||
// --- The three cases required by plan Step 3.5 ---
|
|
||||||
|
|
||||||
test('case 1: all five fields present and labelled', () => {
|
|
||||||
const cell = [
|
|
||||||
"Belowland's Thumper",
|
|
||||||
'Tattoo: AB12',
|
|
||||||
'DOB: 03/15/2024',
|
|
||||||
'Weight: 8 lbs 4 oz',
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
assert.deepEqual(parseCellFields(cell), {
|
|
||||||
farmName: "Belowland's",
|
|
||||||
rabbitName: 'Thumper',
|
|
||||||
earNumber: 'AB12',
|
|
||||||
dob: '2024-03-15',
|
|
||||||
weight: 8.25,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('case 2: only name and ear number (DOB and weight absent)', () => {
|
|
||||||
const cell = ['Clover', 'RZ7'].join('\n')
|
|
||||||
|
|
||||||
assert.deepEqual(parseCellFields(cell), {
|
|
||||||
farmName: null,
|
|
||||||
rabbitName: 'Clover',
|
|
||||||
earNumber: 'RZ7',
|
|
||||||
dob: null,
|
|
||||||
weight: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('case 3: name has no possessive (rabbit name only, no farm)', () => {
|
|
||||||
const cell = ['Midnight', 'Ear #: XY99'].join('\n')
|
|
||||||
|
|
||||||
const result = parseCellFields(cell)
|
|
||||||
assert.equal(result.farmName, null)
|
|
||||||
assert.equal(result.rabbitName, 'Midnight')
|
|
||||||
assert.equal(result.earNumber, 'XY99')
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Per-field rule coverage ---
|
|
||||||
|
|
||||||
test('possessive split handles a plain "s without apostrophe-s', () => {
|
|
||||||
// "Lund Lops' Seabreeze" → farm "Lund Lops'", name "Seabreeze"
|
|
||||||
const result = parseCellFields("Lund Lops' Seabreeze")
|
|
||||||
assert.equal(result.farmName, "Lund Lops'")
|
|
||||||
assert.equal(result.rabbitName, 'Seabreeze')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('weight: decimal lbs form', () => {
|
|
||||||
assert.equal(parseCellFields('Bunny\nWeight 8.5 lbs').weight, 8.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('weight: bare number with no unit is not a weight', () => {
|
|
||||||
assert.equal(parseCellFields('Bunny\n8.5').weight, null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('dob: 2-digit year maps to 20xx and slashes/dashes both parse', () => {
|
|
||||||
assert.equal(parseCellFields('Bunny\nborn 5/9/24').dob, '2024-05-09')
|
|
||||||
assert.equal(parseCellFields('Bunny\n12-25-2023').dob, '2023-12-25')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('dob: 2-digit year beyond the current-year pivot maps to 19xx', () => {
|
|
||||||
// Older slips carry 1990s ancestors; "98" must not become a future 2098 DOB.
|
|
||||||
assert.equal(parseCellFields('Bunny\nDOB: 3/15/98').dob, '1998-03-15')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('dob: invalid month is rejected', () => {
|
|
||||||
assert.equal(parseCellFields('Bunny\n13/40/2024').dob, null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('dob: impossible day for the month is rejected', () => {
|
|
||||||
assert.equal(parseCellFields('Bunny\n2/31/2024').dob, null)
|
|
||||||
// 2024 is a leap year — 2/29 is real.
|
|
||||||
assert.equal(parseCellFields('Bunny\n2/29/2024').dob, '2024-02-29')
|
|
||||||
assert.equal(parseCellFields('Bunny\n2/29/2023').dob, null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ear number: labelled value wins over a later bare token', () => {
|
|
||||||
const result = parseCellFields('Rocky\nTattoo: GH42\nREW')
|
|
||||||
assert.equal(result.earNumber, 'GH42')
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- ALL-CAPS pedigrees (real-world: BASTET'S SHANGHEI etc.) ---
|
|
||||||
|
|
||||||
test('all-caps possessive splits farm from name on one line', () => {
|
|
||||||
const result = parseCellFields('BASTET’S SHANGHEI'.replace('’', "'"))
|
|
||||||
assert.equal(result.farmName, "BASTET'S")
|
|
||||||
assert.equal(result.rabbitName, 'SHANGHEI')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('farm on its own line, rabbit name on the next line', () => {
|
|
||||||
const cell = ["BASTET'S", 'SHAMWOW', 'Ear #: BSHAMW', 'Weight: 3 # 15 oz'].join('\n')
|
|
||||||
const result = parseCellFields(cell)
|
|
||||||
assert.equal(result.farmName, "BASTET'S")
|
|
||||||
assert.equal(result.rabbitName, 'SHAMWOW')
|
|
||||||
assert.equal(result.earNumber, 'BSHAMW')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('an all-caps name is not mistaken for a bare ear number', () => {
|
|
||||||
// "SHANGHEI" is all letters → a name, not an ear. The labelled ear wins.
|
|
||||||
const cell = ['MIDNIGHT', 'Ear #: XY99'].join('\n')
|
|
||||||
const result = parseCellFields(cell)
|
|
||||||
assert.equal(result.rabbitName, 'MIDNIGHT')
|
|
||||||
assert.equal(result.earNumber, 'XY99')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a bare all-letter token is not treated as an ear number', () => {
|
|
||||||
// No label, no digit → not an ear; it becomes the name instead.
|
|
||||||
const result = parseCellFields('SHAMWOW')
|
|
||||||
assert.equal(result.earNumber, null)
|
|
||||||
assert.equal(result.rabbitName, 'SHAMWOW')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('looksLikeAncestorCell distinguishes a real cell from the form title', () => {
|
|
||||||
assert.equal(looksLikeAncestorCell("BASTET'S SHAMWOW\nEar #: BSHAMW\nWeight: 3 lbs"), true)
|
|
||||||
assert.equal(looksLikeAncestorCell("Bastet's Bunnies\nHolland Lop"), false)
|
|
||||||
assert.equal(looksLikeAncestorCell(''), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('empty / whitespace cell yields all nulls', () => {
|
|
||||||
assert.deepEqual(parseCellFields(' \n \n'), {
|
|
||||||
farmName: null,
|
|
||||||
rabbitName: null,
|
|
||||||
earNumber: null,
|
|
||||||
dob: null,
|
|
||||||
weight: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// Full pedigree OCR pipeline (OCR Pedigree Scanning, Phase 3, Step 3.6).
|
|
||||||
//
|
|
||||||
// Orchestrates the Phase 3 pieces end to end: OCR a preprocessed pedigree image,
|
|
||||||
// figure out which columns are the subject / parents / grandparents (real
|
|
||||||
// pedigrees carry 4–5 generations plus margin text, so this is done adaptively
|
|
||||||
// and anchored on the subject rather than by fixed bands), and parse each cell
|
|
||||||
// into structured fields. The returned object is what the Add Rabbit wizard
|
|
||||||
// turns into the pedigree tree's initial state (plan Phase 5).
|
|
||||||
|
|
||||||
import { runOCR } from './ocrRun.js'
|
|
||||||
import { detectColumns, splitIntoCells, aggregateCellText } from './ocrLayout.js'
|
|
||||||
import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js'
|
|
||||||
|
|
||||||
const EMPTY_CELL = { farmName: null, rabbitName: null, earNumber: null, dob: null, weight: null }
|
|
||||||
|
|
||||||
// Words from the certification/signature block and the printed form title sit
|
|
||||||
// outside the ancestor tree and would otherwise pollute the subject/sire cells.
|
|
||||||
// Matched case-insensitively as whole words. Kept deliberately small and
|
|
||||||
// pedigree-boilerplate-specific so it can't eat a real rabbit/farm name (note:
|
|
||||||
// "pedigree" is safe — no rabbit is named that — but breed words like "lop" are
|
|
||||||
// intentionally NOT here, since farms are often named "<Something> Lops").
|
|
||||||
const BOILERPLATE_RE =
|
|
||||||
/\b(?:certify|certifies|certified|hereby|signed|knowledge|belief|pedigree|printed|software|registrar|phone)\b/i
|
|
||||||
|
|
||||||
// Positive signal that a column's text is an actual rabbit cell (vs. the cert
|
|
||||||
// block or stray margin text): a labelled ear/tattoo/weight/DOB, or a possessive
|
|
||||||
// farm token. Used to find the subject column and skip the left margin.
|
|
||||||
const RABBIT_CELL_RE = /(?:ear\s*#|tattoo|weight|d\.?o\.?b|\bsex\b|'s?\b)/i
|
|
||||||
|
|
||||||
function isBoilerplateWord(text) {
|
|
||||||
return BOILERPLATE_RE.test(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse a cell, but reject text that doesn't read like a real ancestor cell
|
|
||||||
// (the form title/header has no field labels) so it can't masquerade as a rabbit.
|
|
||||||
function parseCell(words) {
|
|
||||||
const text = aggregateCellText(words)
|
|
||||||
if (!looksLikeAncestorCell(text)) return { ...EMPTY_CELL }
|
|
||||||
return parseCellFields(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The subject is the leftmost column that reads like a rabbit cell — anything to
|
|
||||||
// its left (a certification/signature margin) is skipped. Falls back to column 0
|
|
||||||
// if nothing matches, so a poorly-OCR'd subject still yields a best effort.
|
|
||||||
function findSubjectColumnIndex(columns) {
|
|
||||||
for (let i = 0; i < columns.length; i += 1) {
|
|
||||||
if (RABBIT_CELL_RE.test(aggregateCellText(columns[i]))) return i
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous core of the pipeline: from a flat word list + image width, produce
|
|
||||||
* the seven-cell pedigree. Split out from `extractPedigree` so it can be unit-
|
|
||||||
* tested and reused by the dev harness without re-running OCR.
|
|
||||||
*
|
|
||||||
* Real pedigrees stack the four grandparents vertically in a single column
|
|
||||||
* (top→bottom: sire_sire, sire_dam, dam_sire, dam_dam), so they're split by the
|
|
||||||
* three largest vertical gaps — not into a 2×2 quadrant grid.
|
|
||||||
*
|
|
||||||
* @param {object[]} words flat words from `runOCR`
|
|
||||||
* @param {number} imageWidth full image width in px
|
|
||||||
* @returns {object} seven keys, each a `parseCellFields` result
|
|
||||||
*/
|
|
||||||
export function extractFromWords(words, imageWidth) {
|
|
||||||
const cleaned = words.filter((w) => !isBoilerplateWord(w.text))
|
|
||||||
const columns = detectColumns(cleaned, imageWidth)
|
|
||||||
|
|
||||||
const subjectIdx = findSubjectColumnIndex(columns)
|
|
||||||
const subjectWords = columns[subjectIdx] ?? []
|
|
||||||
const parentWords = columns[subjectIdx + 1] ?? []
|
|
||||||
const grandparentWords = columns[subjectIdx + 2] ?? []
|
|
||||||
|
|
||||||
const [sireWords, damWords] = splitIntoCells(parentWords, 2)
|
|
||||||
const [ssWords, sdWords, dsWords, ddWords] = splitIntoCells(grandparentWords, 4)
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject: parseCell(subjectWords),
|
|
||||||
sire: parseCell(sireWords),
|
|
||||||
dam: parseCell(damWords),
|
|
||||||
sire_sire: parseCell(ssWords),
|
|
||||||
sire_dam: parseCell(sdWords),
|
|
||||||
dam_sire: parseCell(dsWords),
|
|
||||||
dam_dam: parseCell(ddWords),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode a blob just to read its pixel width — `detectColumns` needs it and the
|
|
||||||
// Tesseract `Page` doesn't expose it. createImageBitmap is available in every
|
|
||||||
// browser this app targets.
|
|
||||||
async function imageWidthOf(blob) {
|
|
||||||
const bitmap = await createImageBitmap(blob)
|
|
||||||
const { width } = bitmap
|
|
||||||
bitmap.close?.()
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a full pedigree from a preprocessed image blob.
|
|
||||||
*
|
|
||||||
* @param {Blob} imageBlob preprocessed (binarized, <=1600px) pedigree image
|
|
||||||
* @returns {Promise<object>} seven keys — `subject`, `sire`, `dam`,
|
|
||||||
* `sire_sire`, `sire_dam`, `dam_sire`, `dam_dam` — each the
|
|
||||||
* `{ farmName, rabbitName, earNumber, dob, weight }` shape from
|
|
||||||
* `parseCellFields`. Sparse cells yield null fields rather than throwing.
|
|
||||||
*/
|
|
||||||
export async function extractPedigree(imageBlob) {
|
|
||||||
const [{ words }, imageWidth] = await Promise.all([
|
|
||||||
runOCR(imageBlob),
|
|
||||||
imageWidthOf(imageBlob),
|
|
||||||
])
|
|
||||||
return extractFromWords(words, imageWidth)
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
// Client-side image preprocessing for OCR (OCR Pedigree Scanning, Phase 2).
|
|
||||||
//
|
|
||||||
// Tesseract reads a printed pedigree far more reliably from a downscaled,
|
|
||||||
// high-contrast black-and-white image than from a raw phone photo. This module
|
|
||||||
// does that normalization entirely with the browser canvas API — no image
|
|
||||||
// library (jimp was deliberately not added) — and returns a PNG Blob ready to
|
|
||||||
// hand to the OCR pipeline (Phase 3).
|
|
||||||
|
|
||||||
// Tesseract gains nothing from images wider than this and slows down on them,
|
|
||||||
// so anything wider is scaled down proportionally. Narrower photos are left at
|
|
||||||
// their native size (never upscaled).
|
|
||||||
export const MAX_WIDTH = 1600
|
|
||||||
|
|
||||||
// Luminance cutoff for the black/white binarization pass: pixels at or above it
|
|
||||||
// become white (255), below become black (0). Tuned down from the 128 starting
|
|
||||||
// point to 103 during OCR Phase 2 Step 2.2 validation — at 128, a real photographed
|
|
||||||
// pedigree's uneven lighting / shadowed paper edges binarized into large black
|
|
||||||
// blobs that swallowed the bottom-row cells; 103 keeps those cells legible.
|
|
||||||
// Lower it further if blobs reappear, raise it if faint text drops out.
|
|
||||||
export const BINARIZE_THRESHOLD = 103
|
|
||||||
|
|
||||||
// Decode a File/Blob into a loaded HTMLImageElement, cleaning up the object URL.
|
|
||||||
function decodeImage(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
resolve(img)
|
|
||||||
}
|
|
||||||
img.onerror = () => {
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
reject(new Error('Could not decode the selected image.'))
|
|
||||||
}
|
|
||||||
img.src = url
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Promisified canvas.toBlob (PNG).
|
|
||||||
function canvasToBlob(canvas) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) resolve(blob)
|
|
||||||
else reject(new Error('Failed to encode the processed image.'))
|
|
||||||
}, 'image/png')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preprocess an image for OCR: downscale to <= MAX_WIDTH, convert to grayscale,
|
|
||||||
* then binarize to pure black/white. Accepts a File or Blob; resolves to a PNG
|
|
||||||
* Blob. The original is not retained — only this processed blob is used by the
|
|
||||||
* scanner and the zone picker (their bounding boxes live in this coordinate space).
|
|
||||||
*/
|
|
||||||
export async function preprocessImage(file) {
|
|
||||||
const img = await decodeImage(file)
|
|
||||||
|
|
||||||
// Scale down proportionally only when wider than MAX_WIDTH; never upscale.
|
|
||||||
const scale = img.width > MAX_WIDTH ? MAX_WIDTH / img.width : 1
|
|
||||||
const width = Math.round(img.width * scale)
|
|
||||||
const height = Math.round(img.height * scale)
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
|
||||||
|
|
||||||
// One pass over the pixels: grayscale (standard luminance) then threshold.
|
|
||||||
const imageData = ctx.getImageData(0, 0, width, height)
|
|
||||||
const px = imageData.data
|
|
||||||
for (let i = 0; i < px.length; i += 4) {
|
|
||||||
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]
|
|
||||||
const bw = luma >= BINARIZE_THRESHOLD ? 255 : 0
|
|
||||||
px[i] = bw
|
|
||||||
px[i + 1] = bw
|
|
||||||
px[i + 2] = bw
|
|
||||||
// alpha (px[i + 3]) is left untouched
|
|
||||||
}
|
|
||||||
ctx.putImageData(imageData, 0, 0)
|
|
||||||
|
|
||||||
return canvasToBlob(canvas)
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
// Tesseract OCR runner (OCR Pedigree Scanning, Phase 3, Step 3.1).
|
|
||||||
//
|
|
||||||
// Wraps tesseract.js with the locally-vendored runtime assets (OCR Phase 1) so
|
|
||||||
// nothing is fetched from a CDN at runtime — the app is self-hosted and must
|
|
||||||
// work offline. Accepts a preprocessed image blob (OCR Phase 2) and returns a
|
|
||||||
// flat list of recognized words, each with its bounding box and confidence, for
|
|
||||||
// the layout/parse stages (Steps 3.2–3.6) to slice into pedigree cells.
|
|
||||||
|
|
||||||
import { createWorker } from 'tesseract.js'
|
|
||||||
|
|
||||||
// Vendored under client/public/tesseract/ — served from the site root in both
|
|
||||||
// Vite dev and the nginx/dist production build. corePath is a *directory* so
|
|
||||||
// tesseract.js v7 auto-selects the right LSTM core variant for the browser
|
|
||||||
// (plain / simd / relaxedsimd); langPath + gzip:false are required because the
|
|
||||||
// vendored eng.traineddata is uncompressed (default gzip:true 404s on .gz).
|
|
||||||
const TESSERACT_BASE = '/tesseract/'
|
|
||||||
|
|
||||||
// Words below this Tesseract confidence are dropped as OCR noise before any
|
|
||||||
// layout analysis runs, so a stray speck never lands in a pedigree cell.
|
|
||||||
export const MIN_WORD_CONFIDENCE = 60
|
|
||||||
|
|
||||||
// A single worker is reused across scans (loading the WASM core + traineddata
|
|
||||||
// is expensive, ~9MB). Created lazily on the first runOCR call.
|
|
||||||
let workerPromise = null
|
|
||||||
|
|
||||||
function getWorker() {
|
|
||||||
if (!workerPromise) {
|
|
||||||
workerPromise = createWorker('eng', undefined, {
|
|
||||||
workerPath: `${TESSERACT_BASE}worker.min.js`,
|
|
||||||
corePath: TESSERACT_BASE,
|
|
||||||
langPath: TESSERACT_BASE,
|
|
||||||
gzip: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return workerPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Tesseract on a preprocessed image blob.
|
|
||||||
*
|
|
||||||
* tesseract.js v7 nests recognized text as blocks → paragraphs → lines → words
|
|
||||||
* and only populates that structure when `blocks: true` is requested (the
|
|
||||||
* default output is text-only). We flatten it to plain `words`/`lines` arrays —
|
|
||||||
* the shape the layout stage expects — and drop low-confidence words.
|
|
||||||
*
|
|
||||||
* @param {Blob} imageBlob preprocessed (binarized, <=1600px) image
|
|
||||||
* @returns {Promise<{ words: object[], lines: object[] }>} flattened result;
|
|
||||||
* each word has `text`, `bbox` ({x0,y0,x1,y1}), and `confidence`.
|
|
||||||
*/
|
|
||||||
export async function runOCR(imageBlob) {
|
|
||||||
const worker = await getWorker()
|
|
||||||
const { data } = await worker.recognize(imageBlob, {}, { blocks: true })
|
|
||||||
|
|
||||||
const words = []
|
|
||||||
const lines = []
|
|
||||||
for (const block of data.blocks ?? []) {
|
|
||||||
for (const paragraph of block.paragraphs ?? []) {
|
|
||||||
for (const line of paragraph.lines ?? []) {
|
|
||||||
lines.push(line)
|
|
||||||
for (const word of line.words ?? []) {
|
|
||||||
if (word.confidence >= MIN_WORD_CONFIDENCE) {
|
|
||||||
words.push(word)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { words, lines }
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
@ -53,7 +53,7 @@ function AddRabbit() {
|
||||||
const [photoFile, setPhotoFile] = useState(null)
|
const [photoFile, setPhotoFile] = useState(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [detailsDefaulted, setDetailsDefaulted] = useState(false)
|
const detailsDefaulted = useRef(false)
|
||||||
|
|
||||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||||
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
||||||
|
|
@ -67,10 +67,8 @@ function AddRabbit() {
|
||||||
enabled: Boolean(damId),
|
enabled: Boolean(damId),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge settings-derived defaults into the form exactly once (render-phase
|
if (settings && !detailsDefaulted.current) {
|
||||||
// state adjustment), so a later settings refetch can't overwrite user edits.
|
detailsDefaulted.current = true
|
||||||
if (settings && !detailsDefaulted) {
|
|
||||||
setDetailsDefaulted(true)
|
|
||||||
setDetails((prev) => ({
|
setDetails((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
farm: prev.farm || settings.rabbitry_name || prev.farm,
|
farm: prev.farm || settings.rabbitry_name || prev.farm,
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
// TEMPORARY dev harness for validating OCR Pedigree Scanning, Phase 3.
|
|
||||||
// Not part of the app — wired to a throwaway /ocr-dev route. Delete this file
|
|
||||||
// and its route in App.jsx once Phase 3 is validated (same convention as the
|
|
||||||
// Phase 2 harness).
|
|
||||||
//
|
|
||||||
// Drops a real pedigree photo through the full Phase 3 pipeline in ONE OCR pass
|
|
||||||
// and shows every intermediate stage so you can eyeball where extraction works
|
|
||||||
// or breaks. The cells are reconstructed here from the exported layout/parse
|
|
||||||
// helpers in exactly the order extractPedigree() calls them, so the "Parsed
|
|
||||||
// cells" grid is byte-for-byte what extractPedigree() returns.
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { preprocessImage } from '../lib/ocrPreprocess'
|
|
||||||
import { runOCR } from '../lib/ocrRun'
|
|
||||||
import { detectColumns, aggregateCellText } from '../lib/ocrLayout'
|
|
||||||
import { extractFromWords } from '../lib/ocrPipeline'
|
|
||||||
|
|
||||||
const CELL_ORDER = ['subject', 'sire', 'dam', 'sire_sire', 'sire_dam', 'dam_sire', 'dam_dam']
|
|
||||||
|
|
||||||
async function imageWidthOf(blob) {
|
|
||||||
const bitmap = await createImageBitmap(blob)
|
|
||||||
const { width, height } = bitmap
|
|
||||||
bitmap.close?.()
|
|
||||||
return { width, height }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OcrDev() {
|
|
||||||
const [status, setStatus] = useState('idle')
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [origUrl, setOrigUrl] = useState(null)
|
|
||||||
const [procUrl, setProcUrl] = useState(null)
|
|
||||||
const [dims, setDims] = useState(null)
|
|
||||||
const [words, setWords] = useState([])
|
|
||||||
const [columns, setColumns] = useState(null)
|
|
||||||
const [cells, setCells] = useState(null)
|
|
||||||
const [elapsed, setElapsed] = useState(null)
|
|
||||||
|
|
||||||
async function handleFile(e) {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
setWords([])
|
|
||||||
setCells(null)
|
|
||||||
setColumns(null)
|
|
||||||
setOrigUrl(URL.createObjectURL(file))
|
|
||||||
const start = performance.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus('preprocessing')
|
|
||||||
const processed = await preprocessImage(file)
|
|
||||||
setProcUrl(URL.createObjectURL(processed))
|
|
||||||
const size = await imageWidthOf(processed)
|
|
||||||
setDims(size)
|
|
||||||
|
|
||||||
setStatus('scanning')
|
|
||||||
const { words: w } = await runOCR(processed)
|
|
||||||
setWords(w)
|
|
||||||
|
|
||||||
// Debug view: the raw adaptive columns (before subject-anchoring), so we
|
|
||||||
// can see where the gutters fell. The "Parsed cells" grid below uses the
|
|
||||||
// real pipeline core (extractFromWords) — same code extractPedigree runs.
|
|
||||||
setColumns(detectColumns(w, size.width).map((col) => aggregateCellText(col)))
|
|
||||||
setCells(extractFromWords(w, size.width))
|
|
||||||
|
|
||||||
setElapsed(Math.round(performance.now() - start))
|
|
||||||
setStatus('done')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err?.message || String(err))
|
|
||||||
setStatus('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl p-6 font-body text-text-primary">
|
|
||||||
<h1 className="mb-1 font-display text-2xl">OCR Phase 3 — dev harness</h1>
|
|
||||||
<p className="mb-4 text-sm text-text-secondary">
|
|
||||||
Temporary. Pick a pedigree photo; it runs preprocess → runOCR →
|
|
||||||
layout/parse in one OCR pass. Open DevTools → Network and confirm{' '}
|
|
||||||
<strong>no requests to any CDN</strong> (jsdelivr/unpkg) — all Tesseract
|
|
||||||
assets load from <code>/tesseract/</code>. For a true offline check, go
|
|
||||||
offline after the first load and re-run.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input type="file" accept="image/*" onChange={handleFile} className="mb-4 block" />
|
|
||||||
|
|
||||||
<p className="mb-4 text-sm">
|
|
||||||
Status: <strong>{status}</strong>
|
|
||||||
{status === 'scanning' && ' (first scan loads ~9MB of WASM — can take 10–30s)'}
|
|
||||||
{dims && ` · preprocessed ${dims.width}×${dims.height}`}
|
|
||||||
{elapsed != null && status === 'done' && ` · ${elapsed}ms total`}
|
|
||||||
{words.length > 0 && ` · ${words.length} words (conf ≥ 60)`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger">
|
|
||||||
Error: {error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(origUrl || procUrl) && (
|
|
||||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
{origUrl && (
|
|
||||||
<figure>
|
|
||||||
<figcaption className="mb-1 text-xs text-text-secondary">Original</figcaption>
|
|
||||||
<img src={origUrl} alt="original" className="w-full border border-border" />
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
{procUrl && (
|
|
||||||
<figure>
|
|
||||||
<figcaption className="mb-1 text-xs text-text-secondary">
|
|
||||||
Preprocessed (binarized, ≤1600px) — this is what OCR sees
|
|
||||||
</figcaption>
|
|
||||||
<img src={procUrl} alt="preprocessed" className="w-full border border-border" />
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cells && (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-2 font-display text-lg">
|
|
||||||
Parsed cells (== extractPedigree() output)
|
|
||||||
</h2>
|
|
||||||
<div className="mb-6 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{CELL_ORDER.map((key) => (
|
|
||||||
<div key={key} className="rounded border border-border bg-surface-raised p-3">
|
|
||||||
<div className="mb-1 font-display text-sm">{key}</div>
|
|
||||||
<pre className="whitespace-pre-wrap text-xs text-text-secondary">
|
|
||||||
{JSON.stringify(cells[key], null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{columns && (
|
|
||||||
<details className="mb-6" open>
|
|
||||||
<summary className="cursor-pointer font-display text-lg">
|
|
||||||
Detected columns ({columns.length}) — adaptive gutter split, left→right
|
|
||||||
</summary>
|
|
||||||
<p className="mb-2 mt-1 text-xs text-text-secondary">
|
|
||||||
Each box is one detected generation column (before boilerplate filtering
|
|
||||||
/ subject-anchoring). If these don't line up with subject / parents /
|
|
||||||
grandparents, the gutter thresholds need tuning.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
||||||
{columns.map((text, i) => (
|
|
||||||
<div key={i} className="rounded border border-border bg-surface-raised p-3">
|
|
||||||
<div className="mb-1 font-display text-sm">column {i}</div>
|
|
||||||
<pre className="whitespace-pre-wrap text-[11px] text-text-secondary">
|
|
||||||
{text || '(empty)'}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{words.length > 0 && (
|
|
||||||
<details className="mb-6">
|
|
||||||
<summary className="cursor-pointer font-display text-lg">
|
|
||||||
Raw word list ({words.length})
|
|
||||||
</summary>
|
|
||||||
<table className="mt-2 w-full text-left text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-text-secondary">
|
|
||||||
<th className="py-1 pr-2">text</th>
|
|
||||||
<th className="py-1 pr-2">conf</th>
|
|
||||||
<th className="py-1 pr-2">x0</th>
|
|
||||||
<th className="py-1 pr-2">y0</th>
|
|
||||||
<th className="py-1 pr-2">x1</th>
|
|
||||||
<th className="py-1 pr-2">y1</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{words.map((w, i) => (
|
|
||||||
<tr key={i} className="border-b border-border/50">
|
|
||||||
<td className="py-1 pr-2">{w.text}</td>
|
|
||||||
<td className="py-1 pr-2">{Math.round(w.confidence)}</td>
|
|
||||||
<td className="py-1 pr-2">{w.bbox.x0}</td>
|
|
||||||
<td className="py-1 pr-2">{w.bbox.y0}</td>
|
|
||||||
<td className="py-1 pr-2">{w.bbox.x1}</td>
|
|
||||||
<td className="py-1 pr-2">{w.bbox.y1}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -247,7 +247,7 @@ function RabbitDetail() {
|
||||||
<div className="min-w-0 flex-1 space-y-6">
|
<div className="min-w-0 flex-1 space-y-6">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
{ancestors ? (
|
{ancestors ? (
|
||||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} generations={2} />
|
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} />
|
||||||
) : (
|
) : (
|
||||||
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,15 @@ function Settings() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||||
const [form, setForm] = useState(null)
|
const [form, setForm] = useState(null)
|
||||||
|
const seeded = useRef(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const importInputRef = useRef(null)
|
const importInputRef = useRef(null)
|
||||||
const [pendingFile, setPendingFile] = useState(null)
|
const [pendingFile, setPendingFile] = useState(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
// Seed the form from settings exactly once (render-phase state adjustment):
|
if (settings && !seeded.current) {
|
||||||
// form is null only until settings first load, so a later settings refetch
|
seeded.current = true
|
||||||
// can't clobber the user's in-progress edits.
|
|
||||||
if (settings && form === null) {
|
|
||||||
setForm({
|
setForm({
|
||||||
rabbitry_name: settings.rabbitry_name ?? '',
|
rabbitry_name: settings.rabbitry_name ?? '',
|
||||||
weight_unit: settings.weight_unit ?? 'decimal_lbs',
|
weight_unit: settings.weight_unit ?? 'decimal_lbs',
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/app/data
|
- server-data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# busybox wget ships in node:20-alpine, so no extra package is needed.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 3s
|
|
||||||
start_period: 15s
|
|
||||||
retries: 3
|
|
||||||
# No published port: the server is only reached through the client's nginx
|
# No published port: the server is only reached through the client's nginx
|
||||||
# reverse proxy on the internal compose network. Add a `ports` mapping here
|
# reverse proxy on the internal compose network. Add a `ports` mapping here
|
||||||
# if you want to hit the API directly for debugging.
|
# if you want to hit the API directly for debugging.
|
||||||
|
|
@ -25,12 +18,8 @@ services:
|
||||||
build: ./client
|
build: ./client
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
# Wait for the API to answer /health before starting nginx, so the first
|
|
||||||
# page load after `docker compose up` can't hit a 502 from a still-booting
|
|
||||||
# server.
|
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
- server
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,2 @@
|
||||||
node_modules
|
node_modules
|
||||||
data
|
data
|
||||||
dist
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue