diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index f831c5c..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "laravel", - "runtimeExecutable": "composer", - "runtimeArgs": ["run", "dev"], - "port": 8000 - } - ] -} diff --git a/.env.example b/.env.example index 40bb13b..bb2bfc2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Laravel +APP_NAME="Mini Farm Manager" APP_ENV=local APP_KEY= APP_DEBUG=true diff --git a/.gitignore b/.gitignore index 74e10e4..17a502d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ yarn-error.log /.nova /.vscode /.zed +/.claude/launch.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..68a9220 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Mini Farm Manager + +A small farm-management application built for the Laravel + Vue developer exercise. A farmer can register animals, keep them in paddocks, move them around without ever overfilling a paddock, and record each animal's health history. A dashboard shows the state of the farm at a glance, and paddocks that are nearly full are flagged before they become a problem. + +- **Stack:** Laravel 13 · PHP 8.4 · Inertia 3 · Vue 3.5 · TypeScript · Tailwind 4 · SQLite · PHPUnit 12 +- **Design document:** [`docs/design.md`](docs/design.md) — written before implementation; the domain model, business rules and testing strategy live there +- **Exercise brief:** [`docs/requirements.md`](docs/requirements.md) + +--- + +## How to run + +Requirements: PHP 8.4+, Composer, Node 20+ / npm. + +```bash +git clone https://github.com/wangxiaoxiang5599/MiniFarmManager.git +cd MiniFarmManager + +composer install +npm install + +cp .env.example .env +php artisan key:generate + +touch database/database.sqlite # on Windows: type nul > database\database.sqlite +php artisan migrate --seed # seeds a demo farm (see below) + +composer run dev # serves at http://localhost:8000 (app + Vite + queue) +``` + +No login is needed — open http://localhost:8000 and you land on the dashboard. + +Run the test suite with: + +```bash +php artisan test +``` + +### Demo data + +`php artisan migrate --seed` (or `php artisan db:seed`) builds a small farm through the same code paths the UI uses, so movement history and paddock occupancy are internally consistent: + +- 5 paddocks — one **full** (Home Paddock), two **nearly full** (North Paddock, Orchard Paddock), two with room +- 36 animals across cattle, sheep, goats and chickens, including one unplaced animal, two sold and one deceased +- a few weeks of movements and ~20 health records + +Run `php artisan migrate:fresh --seed` to reset it. + +--- + +## What's included + +| Area | Where | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Animals — list (search + filters), add, edit, detail | `AnimalController`, `resources/js/pages/animals/` | +| Paddocks — list, add, edit, detail with current animals | `PaddockController`, `resources/js/pages/paddocks/` | +| Movements — move dialog, full history, current paddock, capacity enforcement | `App\Actions\MoveAnimal`, `AnimalMovementController` | +| Health records — add from the animal page, history newest-first | `HealthRecordController` | +| Dashboard — counts, species breakdown, occupancy, warnings, recent moves | `DashboardController`, `resources/js/pages/Dashboard.vue` | +| **Additional feature: capacity warnings** | `App\Enums\OccupancyState`, `Paddock::occupancyState()`, `config/farm.php` | +| Tests — 82 feature tests | `tests/Feature/` | + +--- + +## Additional feature: paddock capacity warnings + +**What it does.** Every paddock has a derived state — `ok`, `warning` (≥ 80 % occupied) or `full`. The state drives an amber/red highlight on paddock cards, the progress bars, the animal-move dialog (full paddocks are shown but disabled), and a "Capacity warnings" panel on the dashboard that lists the paddocks needing attention, most-full first. + +**Why this one.** The brief already requires a hard stop at capacity. In practice a farmer wants to know _before_ that point — when they go to move a mob and find the destination has one place left, it's already too late to plan. A threshold warning is the smallest feature that turns the capacity rule from a blocker into a planning aid, and it reuses the occupancy calculation the capacity check already needs. + +**Assumptions.** 80 % is a sensible default for "start thinking about it"; it lives in `config/farm.php` (`FARM_CAPACITY_WARNING_THRESHOLD`) rather than per-paddock, because per-paddock thresholds would add UI without a clear payoff at this scale. The state is calculated server-side in one place so every page agrees. + +**Trade-offs.** It is a visual signal only — no notifications, no history of when a paddock crossed the line. Occupancy counts heads, not stocking units, so a paddock of chickens and a paddock of cattle are treated alike. + +--- + +## Assumptions + +- **Only active animals occupy capacity.** Animals have a status (`active`, `sold`, `deceased`). Changing an animal away from active removes it from its paddock and records that departure in the movement history, so sold and deceased animals never count against a paddock. Reactivating an animal leaves it unplaced; the user places it explicitly. +- **A paddock is never over capacity.** This is enforced when moving an animal in _and_ when editing a paddock — capacity cannot be set below the current occupancy. +- **An animal can be unplaced.** New animals may be created without a paddock (they show as "Unplaced"), and the dashboard counts them. +- **Single farm, no users.** Authentication is not required by the brief, so the farm pages are public. The starter kit's login/registration remains installed but unused. +- **Times are stored and displayed in UTC.** A real deployment would set `APP_TIMEZONE`. +- **Tag numbers are unique** across the whole farm and are the primary way a farmer identifies an animal; names are optional. + +--- + +## Important technical decisions + +Full reasoning is in [`docs/design.md`](docs/design.md); the short version: + +- **Inertia instead of a separate JSON API.** Server-rendered Vue pages keep validation, redirects and flash messages in Laravel and remove a layer of glue code. Wayfinder generates typed route helpers so there are no hard-coded URLs in the frontend. +- **`animals.current_paddock_id` is stored, not derived.** Every list, the dashboard and the capacity check need "where is this animal now"; deriving it from the latest movement would put a correlated subquery on every read. The column is written in exactly one place (`MoveAnimal`) inside the same transaction as the movement row, and a test asserts it always matches the latest movement. +- **One action class for the one non-trivial write.** `MoveAnimal` opens a transaction, locks the animal and the target paddock rows (`lockForUpdate`), checks the rules, inserts history and updates the pointer. It throws a `ValidationException` keyed to the form field, so the UI shows "North Paddock is full (12 of 12)" inline rather than a generic error. `ChangeAnimalStatus` delegates to it for departures. +- **Form Requests own input validation; actions own invariants that depend on database state.** Uniqueness, enums and dates are request rules. "Is there room?" is checked under a lock. +- **PHP backed enums** for species, sex, status and health-record type, exposed to Vue as `{ value, label }` option lists so the frontend never hard-codes them. +- **Thin controllers, JSON resources for Inertia props**, factories with intent-revealing states (`inPaddock()`, `sold()`, `withCapacity()`), and a seeder that goes through the actions rather than inserting rows. + +--- + +## Deliberately left out + +- Deleting animals, paddocks or records (the brief asks for add / edit / view; deletion raises history-integrity questions that deserve more than the time budget) +- Recording _who_ moved an animal — there is no authenticated user in scope +- Weight, medication schedules, photos, documents +- Bulk moves ("move these 12 sheep to Top Hill") +- Editing or deleting movements and health records +- Anything beyond simple pagination on the animal list + +## What I would improve before production + +- Verify the capacity lock under real concurrency on MySQL/Postgres (SQLite ignores `FOR UPDATE`; the transaction still serialises writes, but the behaviour should be tested against the target database) +- Authentication and authorisation, then per-user audit on movements and health records +- Soft deletes and an audit log; `nullOnDelete` on the movement foreign keys is a safety net, not a design +- Stocking-unit-aware capacity rather than head counts +- Reports: CSV export, movement and health history filtered by date range +- Accessibility and mobile pass on the forms; the app is responsive but has not been audited +- Set `APP_TIMEZONE`, add a cache for dashboard aggregates once the herd is large enough to matter + +--- + +## How AI tools were used + +This project was built with Claude Code (Anthropic) as a pair, with me directing and reviewing. The workflow, which is visible in the commit history: + +1. **Requirements → design first.** I had Claude read the brief and produce a design document. I made the decisions it surfaced (status model, extra fields, which additional feature, how to treat the starter kit's auth), and the agreed design was committed before any code. +2. **Staged implementation, reviewed per stage.** The build was split into six vertical slices (schema → paddocks → animals + movement engine → move dialog + health → dashboard + seeder → wrap-up). Each stage ended with tests, a type check, a walk-through in the browser, and my review before it was committed. Nothing was committed unreviewed. +3. **Verification caught a real defect.** In stage 5 the seeder — which deliberately goes through `MoveAnimal` and `ChangeAnimalStatus` rather than inserting rows — produced sold animals that were still counted in a paddock. The cause was a stale model instance: `MoveAnimal` re-fetched a locked copy and updated that, leaving the caller's instance out of date, so `ChangeAnimalStatus` saw "not in a paddock" and skipped the departure. The controller path had passed all tests because route-bound models are always fresh. The fix (sync the caller's instance with the locked row; refresh before deciding) landed with two regression tests. This is exactly the kind of bug that reads fine in review and only shows up when the code is exercised from a second call site. +4. **Conventions from the framework, not from memory.** Laravel Boost's guidelines and skills were loaded so generated code follows the installed versions' idioms (Inertia 3 forms, Wayfinder, PHP 8.4 attributes, Pint formatting). + +What I did not do: accept the first design without questions, or ship code I had not seen run. diff --git a/app/Actions/ChangeAnimalStatus.php b/app/Actions/ChangeAnimalStatus.php new file mode 100644 index 0000000..f8bd631 --- /dev/null +++ b/app/Actions/ChangeAnimalStatus.php @@ -0,0 +1,40 @@ +refresh(); + + if ($animal->status === $status) { + return $animal; + } + + if (! $status->occupiesPaddock() && $animal->current_paddock_id !== null) { + $this->moveAnimal->handle( + $animal, + to: null, + notes: "Left paddock: marked as {$status->label()}.", + ); + } + + $animal->forceFill(['status' => $status])->save(); + + return $animal->refresh(); + }); + } +} diff --git a/app/Actions/MoveAnimal.php b/app/Actions/MoveAnimal.php new file mode 100644 index 0000000..6c4c582 --- /dev/null +++ b/app/Actions/MoveAnimal.php @@ -0,0 +1,83 @@ +lockForUpdate()->findOrFail($animal->id); + $animal->setRawAttributes($locked->getAttributes(), true); + + if ($to !== null) { + $this->ensureAnimalCanBePlaced($animal, $to); + } + + $movement = $animal->movements()->create([ + 'from_paddock_id' => $animal->current_paddock_id, + 'to_paddock_id' => $to?->id, + 'moved_at' => $movedAt ?? now(), + 'notes' => $notes, + ]); + + $animal->forceFill(['current_paddock_id' => $to?->id])->save(); + + return $movement; + }); + } + + /** + * Rules for entering a paddock. The target row is locked for the rest of + * the transaction so that two concurrent moves cannot both pass the + * capacity check. + * + * @throws ValidationException + */ + private function ensureAnimalCanBePlaced(Animal $animal, Paddock $to): void + { + if (! $animal->isActive()) { + throw ValidationException::withMessages([ + 'to_paddock_id' => "Only active animals can be moved. {$animal->tag_number} is {$animal->status->label()}.", + ]); + } + + if ($animal->current_paddock_id === $to->id) { + throw ValidationException::withMessages([ + 'to_paddock_id' => "{$animal->tag_number} is already in {$to->name}.", + ]); + } + + $to = Paddock::query()->lockForUpdate()->withOccupancy()->findOrFail($to->id); + + if (! $to->hasRoom()) { + throw ValidationException::withMessages([ + 'to_paddock_id' => "{$to->name} is full ({$to->occupancy} of {$to->capacity}). Move an animal out first.", + ]); + } + } +} diff --git a/app/Enums/AnimalStatus.php b/app/Enums/AnimalStatus.php new file mode 100644 index 0000000..8f8f2e7 --- /dev/null +++ b/app/Enums/AnimalStatus.php @@ -0,0 +1,20 @@ +value); + } + + /** + * @return array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $case): array => ['value' => $case->value, 'label' => $case->label()], + self::cases(), + ); + } +} diff --git a/app/Enums/HealthRecordType.php b/app/Enums/HealthRecordType.php new file mode 100644 index 0000000..e346bbb --- /dev/null +++ b/app/Enums/HealthRecordType.php @@ -0,0 +1,14 @@ += 1.0 => self::Full, + $ratio >= $threshold => self::Warning, + default => self::Ok, + }; + } +} diff --git a/app/Enums/Sex.php b/app/Enums/Sex.php new file mode 100644 index 0000000..5c535a9 --- /dev/null +++ b/app/Enums/Sex.php @@ -0,0 +1,11 @@ +validate([ + 'search' => ['nullable', 'string', 'max:100'], + 'species' => ['nullable', 'string'], + 'status' => ['nullable', 'string'], + 'paddock' => ['nullable', 'integer'], + ]); + + $animals = Animal::query() + ->with('currentPaddock') + ->when($filters['search'] ?? null, fn (Builder $query, string $search) => $query->where( + fn (Builder $q) => $q + ->where('tag_number', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"), + )) + ->when($filters['species'] ?? null, fn (Builder $query, string $species) => $query->where('species', $species)) + ->when($filters['status'] ?? null, fn (Builder $query, string $status) => $query->where('status', $status)) + ->when($filters['paddock'] ?? null, fn (Builder $query, int $paddock) => $query->where('current_paddock_id', $paddock)) + ->orderBy('tag_number') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('animals/Index', [ + 'animals' => AnimalSummaryResource::collection($animals), + 'filters' => [ + 'search' => $filters['search'] ?? '', + 'species' => $filters['species'] ?? '', + 'status' => $filters['status'] ?? '', + 'paddock' => $filters['paddock'] ?? '', + ], + 'options' => $this->options(), + ]); + } + + public function create(): Response + { + return Inertia::render('animals/Create', [ + 'options' => $this->options(), + ]); + } + + public function store(StoreAnimalRequest $request, MoveAnimal $moveAnimal): RedirectResponse + { + $validated = $request->validated(); + $paddock = isset($validated['paddock_id']) ? Paddock::query()->findOrFail((int) $validated['paddock_id']) : null; + + try { + $animal = DB::transaction(function () use ($validated, $paddock, $moveAnimal): Animal { + $animal = Animal::create(collect($validated)->except('paddock_id')->all()); + + if ($paddock !== null) { + $moveAnimal->handle($animal, $paddock, notes: 'Initial placement.'); + } + + return $animal; + }); + } catch (ValidationException $exception) { + // MoveAnimal reports on to_paddock_id; this form calls the field paddock_id. + throw ValidationException::withMessages([ + 'paddock_id' => $exception->errors()['to_paddock_id'] ?? $exception->getMessage(), + ]); + } + + Inertia::flash('toast', ['type' => 'success', 'message' => __('Animal added.')]); + + return to_route('animals.show', $animal); + } + + public function show(Animal $animal): Response + { + $animal->load('currentPaddock'); + + $movements = $animal->movements() + ->with(['fromPaddock', 'toPaddock']) + ->latest('moved_at') + ->latest('id') + ->get(); + + $healthRecords = $animal->healthRecords() + ->latest('recorded_on') + ->latest('id') + ->get(); + + $paddocks = Paddock::query()->withOccupancy()->orderBy('name')->get(); + + return Inertia::render('animals/Show', [ + 'animal' => AnimalResource::make($animal)->resolve(), + 'movements' => AnimalMovementResource::collection($movements)->resolve(), + 'healthRecords' => HealthRecordResource::collection($healthRecords)->resolve(), + 'paddocks' => PaddockResource::collection($paddocks)->resolve(), + 'healthRecordTypes' => HealthRecordType::options(), + ]); + } + + public function edit(Animal $animal): Response + { + $animal->load('currentPaddock'); + + return Inertia::render('animals/Edit', [ + 'animal' => AnimalResource::make($animal)->resolve(), + 'options' => $this->options(), + ]); + } + + public function update(UpdateAnimalRequest $request, Animal $animal, ChangeAnimalStatus $changeStatus): RedirectResponse + { + $validated = $request->validated(); + $status = AnimalStatus::from($validated['status']); + + DB::transaction(function () use ($animal, $validated, $status, $changeStatus): void { + $animal->update(collect($validated)->except('status')->all()); + $changeStatus->handle($animal, $status); + }); + + Inertia::flash('toast', ['type' => 'success', 'message' => __('Animal updated.')]); + + return to_route('animals.show', $animal); + } + + /** + * Select options shared by the animal forms and filters. + * + * @return array{species: array, sexes: array, statuses: array, paddocks: array} + */ + private function options(): array + { + $paddocks = Paddock::query()->withOccupancy()->orderBy('name')->get(); + + return [ + 'species' => Species::options(), + 'sexes' => Sex::options(), + 'statuses' => AnimalStatus::options(), + 'paddocks' => PaddockResource::collection($paddocks)->resolve(), + ]; + } +} diff --git a/app/Http/Controllers/AnimalMovementController.php b/app/Http/Controllers/AnimalMovementController.php new file mode 100644 index 0000000..5610812 --- /dev/null +++ b/app/Http/Controllers/AnimalMovementController.php @@ -0,0 +1,37 @@ +validated(); + $paddock = Paddock::query()->findOrFail((int) $validated['to_paddock_id']); + + $moveAnimal->handle( + $animal, + $paddock, + isset($validated['moved_at']) ? Carbon::parse($validated['moved_at']) : null, + $validated['notes'] ?? null, + ); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => __(':animal moved to :paddock.', [ + 'animal' => $animal->tag_number, + 'paddock' => $paddock->name, + ]), + ]); + + return to_route('animals.show', $animal); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..9892281 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,96 @@ +withOccupancy()->orderBy('name')->get(); + + $needingAttention = $paddocks->filter( + fn (Paddock $paddock) => $paddock->occupancyState() !== OccupancyState::Ok, + )->sortByDesc(fn (Paddock $paddock) => $paddock->occupancyRatio()); + + $recentMovements = AnimalMovement::query() + ->with(['animal', 'fromPaddock', 'toPaddock']) + ->latest('moved_at') + ->latest('id') + ->limit(self::RECENT_MOVEMENTS) + ->get(); + + return Inertia::render('Dashboard', [ + 'stats' => [ + 'active_animals' => Animal::query()->active()->count(), + 'unplaced_animals' => Animal::query()->active()->whereNull('current_paddock_id')->count(), + 'paddocks' => $paddocks->count(), + 'total_capacity' => $paddocks->sum('capacity'), + 'total_occupancy' => $paddocks->sum('occupancy'), + 'paddocks_needing_attention' => $needingAttention->count(), + ], + 'animalsBySpecies' => $this->animalsBySpecies(), + 'animalsByStatus' => $this->animalsByStatus(), + 'paddocks' => PaddockResource::collection($paddocks)->resolve(), + 'attention' => PaddockResource::collection($needingAttention->values())->resolve(), + 'recentMovements' => AnimalMovementResource::collection($recentMovements)->resolve(), + ]); + } + + /** + * Active animals grouped by species, largest group first. + * + * @return array + */ + private function animalsBySpecies(): array + { + $counts = Animal::query() + ->active() + ->selectRaw('species, count(*) as aggregate') + ->groupBy('species') + ->pluck('aggregate', 'species'); + + return Collection::make(Species::cases()) + ->map(fn (Species $species) => [ + 'species' => $species->value, + 'label' => $species->label(), + 'count' => (int) ($counts[$species->value] ?? 0), + ]) + ->filter(fn (array $row) => $row['count'] > 0) + ->sortByDesc('count') + ->values() + ->all(); + } + + /** + * @return array + */ + private function animalsByStatus(): array + { + $counts = Animal::query() + ->selectRaw('status, count(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + return Collection::make(AnimalStatus::cases()) + ->map(fn (AnimalStatus $status) => [ + 'status' => $status->value, + 'label' => $status->label(), + 'count' => (int) ($counts[$status->value] ?? 0), + ]) + ->all(); + } +} diff --git a/app/Http/Controllers/HealthRecordController.php b/app/Http/Controllers/HealthRecordController.php new file mode 100644 index 0000000..39d6aaa --- /dev/null +++ b/app/Http/Controllers/HealthRecordController.php @@ -0,0 +1,20 @@ +healthRecords()->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => __('Health record added.')]); + + return to_route('animals.show', $animal); + } +} diff --git a/app/Http/Controllers/PaddockController.php b/app/Http/Controllers/PaddockController.php new file mode 100644 index 0000000..a3c7658 --- /dev/null +++ b/app/Http/Controllers/PaddockController.php @@ -0,0 +1,73 @@ +withOccupancy() + ->orderBy('name') + ->get(); + + return Inertia::render('paddocks/Index', [ + 'paddocks' => PaddockResource::collection($paddocks)->resolve(), + ]); + } + + public function create(): Response + { + return Inertia::render('paddocks/Create'); + } + + public function store(StorePaddockRequest $request): RedirectResponse + { + $paddock = Paddock::create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => __('Paddock created.')]); + + return to_route('paddocks.show', $paddock); + } + + public function show(Paddock $paddock): Response + { + $paddock->loadCount(['activeAnimals as occupancy']); + + $animals = $paddock->activeAnimals() + ->orderBy('tag_number') + ->get(); + + return Inertia::render('paddocks/Show', [ + 'paddock' => PaddockResource::make($paddock)->resolve(), + 'animals' => AnimalSummaryResource::collection($animals)->resolve(), + ]); + } + + public function edit(Paddock $paddock): Response + { + $paddock->loadCount(['activeAnimals as occupancy']); + + return Inertia::render('paddocks/Edit', [ + 'paddock' => PaddockResource::make($paddock)->resolve(), + ]); + } + + public function update(UpdatePaddockRequest $request, Paddock $paddock): RedirectResponse + { + $paddock->update($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => __('Paddock updated.')]); + + return to_route('paddocks.show', $paddock); + } +} diff --git a/app/Http/Requests/StoreAnimalMovementRequest.php b/app/Http/Requests/StoreAnimalMovementRequest.php new file mode 100644 index 0000000..7b3b19f --- /dev/null +++ b/app/Http/Requests/StoreAnimalMovementRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'to_paddock_id' => ['required', 'integer', Rule::exists('paddocks', 'id')], + 'moved_at' => ['nullable', 'date', 'before_or_equal:now'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'to_paddock_id' => 'paddock', + 'moved_at' => 'date moved', + ]; + } +} diff --git a/app/Http/Requests/StoreAnimalRequest.php b/app/Http/Requests/StoreAnimalRequest.php new file mode 100644 index 0000000..00b79e7 --- /dev/null +++ b/app/Http/Requests/StoreAnimalRequest.php @@ -0,0 +1,44 @@ +|string> + */ + public function rules(): array + { + return [ + 'tag_number' => ['required', 'string', 'max:50', Rule::unique('animals', 'tag_number')], + 'name' => ['nullable', 'string', 'max:100'], + 'species' => ['required', Rule::enum(Species::class)], + 'sex' => ['required', Rule::enum(Sex::class)], + 'date_of_birth' => ['required', 'date', 'before_or_equal:today'], + 'breed' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'paddock_id' => ['nullable', 'integer', Rule::exists('paddocks', 'id')], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'paddock_id' => 'paddock', + ]; + } +} diff --git a/app/Http/Requests/StoreHealthRecordRequest.php b/app/Http/Requests/StoreHealthRecordRequest.php new file mode 100644 index 0000000..c67ea52 --- /dev/null +++ b/app/Http/Requests/StoreHealthRecordRequest.php @@ -0,0 +1,39 @@ +|string> + */ + public function rules(): array + { + return [ + 'recorded_on' => ['required', 'date', 'before_or_equal:today'], + 'type' => ['required', Rule::enum(HealthRecordType::class)], + 'description' => ['required', 'string', 'max:200'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'recorded_on' => 'date', + ]; + } +} diff --git a/app/Http/Requests/StorePaddockRequest.php b/app/Http/Requests/StorePaddockRequest.php new file mode 100644 index 0000000..50cf150 --- /dev/null +++ b/app/Http/Requests/StorePaddockRequest.php @@ -0,0 +1,27 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100', Rule::unique('paddocks', 'name')], + 'capacity' => ['required', 'integer', 'min:1', 'max:10000'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/UpdateAnimalRequest.php b/app/Http/Requests/UpdateAnimalRequest.php new file mode 100644 index 0000000..6f6e1ac --- /dev/null +++ b/app/Http/Requests/UpdateAnimalRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'tag_number' => ['required', 'string', 'max:50', Rule::unique('animals', 'tag_number')->ignore($this->route('animal'))], + 'name' => ['nullable', 'string', 'max:100'], + 'species' => ['required', Rule::enum(Species::class)], + 'sex' => ['required', Rule::enum(Sex::class)], + 'date_of_birth' => ['required', 'date', 'before_or_equal:today'], + 'breed' => ['nullable', 'string', 'max:100'], + 'status' => ['required', Rule::enum(AnimalStatus::class)], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/UpdatePaddockRequest.php b/app/Http/Requests/UpdatePaddockRequest.php new file mode 100644 index 0000000..d863a28 --- /dev/null +++ b/app/Http/Requests/UpdatePaddockRequest.php @@ -0,0 +1,67 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100', Rule::unique('paddocks', 'name')->ignore($this->paddock())], + 'capacity' => ['required', 'integer', 'min:1', 'max:10000'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } + + /** + * Capacity may never drop below the number of animals already inside, + * otherwise the "never over capacity" invariant would silently break. + * + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->has('capacity')) { + return; + } + + $occupancy = $this->paddock()->occupancy; + + if ((int) $this->input('capacity') < $occupancy) { + $validator->errors()->add( + 'capacity', + "Capacity cannot be lower than the {$occupancy} animals currently in this paddock.", + ); + } + }, + ]; + } + + private function paddock(): Paddock + { + $paddock = $this->route('paddock'); + + if (! $paddock instanceof Paddock) { + throw new LogicException('UpdatePaddockRequest requires a bound Paddock route parameter.'); + } + + return $paddock; + } +} diff --git a/app/Http/Resources/AnimalMovementResource.php b/app/Http/Resources/AnimalMovementResource.php new file mode 100644 index 0000000..51f66cc --- /dev/null +++ b/app/Http/Resources/AnimalMovementResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'moved_at' => $this->moved_at->toIso8601String(), + 'moved_at_label' => $this->moved_at->format('j M Y, H:i'), + 'from_paddock' => $this->paddockSummary($this->fromPaddock), + 'to_paddock' => $this->paddockSummary($this->toPaddock), + 'notes' => $this->notes, + 'animal' => $this->whenLoaded('animal', fn () => [ + 'id' => $this->animal->id, + 'tag_number' => $this->animal->tag_number, + 'display_name' => $this->animal->displayName(), + ]), + ]; + } + + /** + * @return array{id: int, name: string}|null + */ + private function paddockSummary(?Paddock $paddock): ?array + { + return $paddock ? ['id' => $paddock->id, 'name' => $paddock->name] : null; + } +} diff --git a/app/Http/Resources/AnimalResource.php b/app/Http/Resources/AnimalResource.php new file mode 100644 index 0000000..f0c1e87 --- /dev/null +++ b/app/Http/Resources/AnimalResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + ...parent::toArray($request), + 'notes' => $this->notes, + 'is_active' => $this->isActive(), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/app/Http/Resources/AnimalSummaryResource.php b/app/Http/Resources/AnimalSummaryResource.php new file mode 100644 index 0000000..298e89d --- /dev/null +++ b/app/Http/Resources/AnimalSummaryResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'tag_number' => $this->tag_number, + 'name' => $this->name, + 'display_name' => $this->displayName(), + 'species' => $this->species->value, + 'species_label' => $this->species->label(), + 'sex' => $this->sex->value, + 'sex_label' => $this->sex->label(), + 'breed' => $this->breed, + 'date_of_birth' => $this->date_of_birth->toDateString(), + 'age' => $this->date_of_birth->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 2), + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'current_paddock' => $this->whenLoaded('currentPaddock', fn () => $this->currentPaddock ? [ + 'id' => $this->currentPaddock->id, + 'name' => $this->currentPaddock->name, + ] : null), + ]; + } +} diff --git a/app/Http/Resources/HealthRecordResource.php b/app/Http/Resources/HealthRecordResource.php new file mode 100644 index 0000000..0b21240 --- /dev/null +++ b/app/Http/Resources/HealthRecordResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'recorded_on' => $this->recorded_on->toDateString(), + 'recorded_on_label' => $this->recorded_on->format('j M Y'), + 'type' => $this->type->value, + 'type_label' => $this->type->label(), + 'description' => $this->description, + 'notes' => $this->notes, + ]; + } +} diff --git a/app/Http/Resources/PaddockResource.php b/app/Http/Resources/PaddockResource.php new file mode 100644 index 0000000..a7be79c --- /dev/null +++ b/app/Http/Resources/PaddockResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'capacity' => $this->capacity, + 'notes' => $this->notes, + 'occupancy' => $this->occupancy, + 'remaining_capacity' => $this->remainingCapacity(), + 'occupancy_ratio' => round($this->occupancyRatio(), 3), + 'occupancy_state' => $this->occupancyState()->value, + 'has_room' => $this->hasRoom(), + ]; + } +} diff --git a/app/Models/Animal.php b/app/Models/Animal.php new file mode 100644 index 0000000..a6ba63d --- /dev/null +++ b/app/Models/Animal.php @@ -0,0 +1,97 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'species' => Species::class, + 'sex' => Sex::class, + 'status' => AnimalStatus::class, + 'date_of_birth' => 'date', + ]; + } + + /** + * @return BelongsTo + */ + public function currentPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'current_paddock_id'); + } + + /** + * @return HasMany + */ + public function movements(): HasMany + { + return $this->hasMany(AnimalMovement::class); + } + + /** + * @return HasMany + */ + public function healthRecords(): HasMany + { + return $this->hasMany(HealthRecord::class); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function active(Builder $query): Builder + { + return $query->where('status', AnimalStatus::Active); + } + + public function isActive(): bool + { + return $this->status === AnimalStatus::Active; + } + + /** + * Tag number, with the name appended when the animal has one. + */ + public function displayName(): string + { + return $this->name ? "{$this->tag_number} ({$this->name})" : $this->tag_number; + } +} diff --git a/app/Models/AnimalMovement.php b/app/Models/AnimalMovement.php new file mode 100644 index 0000000..64eb300 --- /dev/null +++ b/app/Models/AnimalMovement.php @@ -0,0 +1,64 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'moved_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function animal(): BelongsTo + { + return $this->belongsTo(Animal::class); + } + + /** + * @return BelongsTo + */ + public function fromPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'from_paddock_id'); + } + + /** + * @return BelongsTo + */ + public function toPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'to_paddock_id'); + } +} diff --git a/app/Models/HealthRecord.php b/app/Models/HealthRecord.php new file mode 100644 index 0000000..05a7afa --- /dev/null +++ b/app/Models/HealthRecord.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'recorded_on' => 'date', + 'type' => HealthRecordType::class, + ]; + } + + /** + * @return BelongsTo + */ + public function animal(): BelongsTo + { + return $this->belongsTo(Animal::class); + } +} diff --git a/app/Models/Paddock.php b/app/Models/Paddock.php new file mode 100644 index 0000000..246aacc --- /dev/null +++ b/app/Models/Paddock.php @@ -0,0 +1,95 @@ + */ + use HasFactory; + + /** + * Animals whose current paddock is this one (any status). + * + * @return HasMany + */ + public function animals(): HasMany + { + return $this->hasMany(Animal::class, 'current_paddock_id'); + } + + /** + * Animals that currently occupy capacity in this paddock. + * + * @return HasMany + */ + public function activeAnimals(): HasMany + { + return $this->animals()->active(); + } + + /** + * Eager-load the occupancy count so list pages avoid N+1 queries. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function withOccupancy(Builder $query): Builder + { + return $query->withCount(['activeAnimals as occupancy']); + } + + /** + * Number of active animals in the paddock. Uses the eager-loaded + * count when present, otherwise falls back to a query. + * + * @return Attribute + */ + protected function occupancy(): Attribute + { + return Attribute::get( + fn (?int $value): int => $value ?? $this->activeAnimals()->count(), + ); + } + + public function occupancyRatio(): float + { + return $this->capacity > 0 ? $this->occupancy / $this->capacity : 1.0; + } + + public function occupancyState(): OccupancyState + { + return OccupancyState::fromRatio($this->occupancyRatio()); + } + + public function hasRoom(): bool + { + return $this->occupancy < $this->capacity; + } + + public function remainingCapacity(): int + { + return max(0, $this->capacity - $this->occupancy); + } +} diff --git a/composer.json b/composer.json index fa8e298..302b252 100644 --- a/composer.json +++ b/composer.json @@ -67,7 +67,7 @@ "@test" ], "types:check": [ - "phpstan analyse" + "phpstan analyse --memory-limit=512M" ], "test": [ "@php artisan config:clear --ansi", diff --git a/config/farm.php b/config/farm.php new file mode 100644 index 0000000..5e0ef0e --- /dev/null +++ b/config/farm.php @@ -0,0 +1,17 @@ + (float) env('FARM_CAPACITY_WARNING_THRESHOLD', 0.8), + +]; diff --git a/database/factories/AnimalFactory.php b/database/factories/AnimalFactory.php new file mode 100644 index 0000000..de935b9 --- /dev/null +++ b/database/factories/AnimalFactory.php @@ -0,0 +1,81 @@ + + */ +class AnimalFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'tag_number' => strtoupper(fake()->unique()->bothify('??-####')), + 'name' => fake()->optional(0.6)->firstName(), + 'species' => fake()->randomElement(Species::cases()), + 'sex' => fake()->randomElement(Sex::cases()), + 'date_of_birth' => fake()->dateTimeBetween('-8 years', '-3 months'), + 'breed' => fake()->optional(0.7)->word(), + 'status' => AnimalStatus::Active, + 'notes' => null, + 'current_paddock_id' => null, + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Active, + ]); + } + + /** + * Sold animals never occupy a paddock. + */ + public function sold(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Sold, + 'current_paddock_id' => null, + ]); + } + + /** + * Deceased animals never occupy a paddock. + */ + public function deceased(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Deceased, + 'current_paddock_id' => null, + ]); + } + + /** + * Place the animal directly in a paddock, bypassing movement history. + * Use MoveAnimal in application code; this is for test setup only. + */ + public function inPaddock(Paddock $paddock): static + { + return $this->state(fn (array $attributes): array => [ + 'current_paddock_id' => $paddock->id, + ]); + } + + public function species(Species $species): static + { + return $this->state(fn (array $attributes): array => [ + 'species' => $species, + ]); + } +} diff --git a/database/factories/AnimalMovementFactory.php b/database/factories/AnimalMovementFactory.php new file mode 100644 index 0000000..6cf299a --- /dev/null +++ b/database/factories/AnimalMovementFactory.php @@ -0,0 +1,36 @@ + + */ +class AnimalMovementFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'animal_id' => Animal::factory(), + 'from_paddock_id' => null, + 'to_paddock_id' => Paddock::factory(), + 'moved_at' => fake()->dateTimeBetween('-1 year', 'now'), + 'notes' => fake()->optional(0.3)->sentence(), + ]; + } + + public function between(?Paddock $from, ?Paddock $to): static + { + return $this->state(fn (array $attributes): array => [ + 'from_paddock_id' => $from?->id, + 'to_paddock_id' => $to?->id, + ]); + } +} diff --git a/database/factories/HealthRecordFactory.php b/database/factories/HealthRecordFactory.php new file mode 100644 index 0000000..d2b1d8b --- /dev/null +++ b/database/factories/HealthRecordFactory.php @@ -0,0 +1,36 @@ + + */ +class HealthRecordFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $type = HealthRecordType::from(fake()->randomElement(HealthRecordType::values())); + + return [ + 'animal_id' => Animal::factory(), + 'recorded_on' => fake()->dateTimeBetween('-1 year', 'now'), + 'type' => $type, + 'description' => match ($type) { + HealthRecordType::Vaccination => fake()->randomElement(['5-in-1 vaccine', 'Clostridial booster', 'Leptospirosis vaccine']), + HealthRecordType::Treatment => fake()->randomElement(['Drenched for worms', 'Antibiotic course', 'Foot bath']), + HealthRecordType::Injury => fake()->randomElement(['Cut on hind leg', 'Lame front left', 'Horn injury']), + HealthRecordType::Checkup => fake()->randomElement(['Routine condition score', 'Pregnancy test', 'Weight check']), + HealthRecordType::Other => fake()->sentence(3), + }, + 'notes' => fake()->optional(0.4)->sentence(), + ]; + } +} diff --git a/database/factories/PaddockFactory.php b/database/factories/PaddockFactory.php new file mode 100644 index 0000000..0798b6b --- /dev/null +++ b/database/factories/PaddockFactory.php @@ -0,0 +1,33 @@ + + */ +class PaddockFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->randomElement([ + 'North', 'South', 'East', 'West', 'River', 'Hill', 'Home', 'Creek', 'Ridge', 'Orchard', + ]).' Paddock '.fake()->unique()->numberBetween(1, 9999), + 'capacity' => fake()->numberBetween(5, 40), + 'notes' => null, + ]; + } + + public function withCapacity(int $capacity): static + { + return $this->state(fn (array $attributes): array => [ + 'capacity' => $capacity, + ]); + } +} diff --git a/database/migrations/2026_09_12_031624_create_paddocks_table.php b/database/migrations/2026_09_12_031624_create_paddocks_table.php new file mode 100644 index 0000000..7169f37 --- /dev/null +++ b/database/migrations/2026_09_12_031624_create_paddocks_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name')->unique(); + $table->unsignedInteger('capacity'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('paddocks'); + } +}; diff --git a/database/migrations/2026_09_12_031625_create_animals_table.php b/database/migrations/2026_09_12_031625_create_animals_table.php new file mode 100644 index 0000000..36408c3 --- /dev/null +++ b/database/migrations/2026_09_12_031625_create_animals_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('tag_number')->unique(); + $table->string('name')->nullable(); + $table->string('species'); + $table->string('sex'); + $table->date('date_of_birth'); + $table->string('breed')->nullable(); + $table->string('status')->default(AnimalStatus::Active->value); + $table->text('notes')->nullable(); + $table->foreignId('current_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->timestamps(); + + $table->index(['status', 'current_paddock_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('animals'); + } +}; diff --git a/database/migrations/2026_09_12_031626_create_animal_movements_table.php b/database/migrations/2026_09_12_031626_create_animal_movements_table.php new file mode 100644 index 0000000..de928df --- /dev/null +++ b/database/migrations/2026_09_12_031626_create_animal_movements_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('animal_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->foreignId('to_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->dateTime('moved_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['animal_id', 'moved_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('animal_movements'); + } +}; diff --git a/database/migrations/2026_09_12_031627_create_health_records_table.php b/database/migrations/2026_09_12_031627_create_health_records_table.php new file mode 100644 index 0000000..1241395 --- /dev/null +++ b/database/migrations/2026_09_12_031627_create_health_records_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('animal_id')->constrained()->cascadeOnDelete(); + $table->date('recorded_on'); + $table->string('type'); + $table->string('description'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['animal_id', 'recorded_on']); + }); + } + + public function down(): void + { + Schema::dropIfExists('health_records'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..4ebcced 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,7 @@ public function run(): void 'name' => 'Test User', 'email' => 'test@example.com', ]); + + $this->call(FarmSeeder::class); } } diff --git a/database/seeders/FarmSeeder.php b/database/seeders/FarmSeeder.php new file mode 100644 index 0000000..9253a65 --- /dev/null +++ b/database/seeders/FarmSeeder.php @@ -0,0 +1,192 @@ +createPaddocks(); + $animals = $this->createAnimals(); + + $this->placeAnimals($animals, $paddocks); + $this->shuffleSomeAnimals($animals, $paddocks); + $this->retireSomeAnimals($animals); + $this->recordHealthEvents($animals); + } + + /** + * @return array + */ + private function createPaddocks(): array + { + $definitions = [ + 'north' => ['North Paddock', 12, 'Good shade along the tree line.'], + 'river' => ['River Flat', 20, 'Floods after heavy rain — move stock to Top Hill.'], + 'home' => ['Home Paddock', 5, 'Closest to the yards; used for animals under treatment.'], + 'top' => ['Top Hill', 15, null], + 'orchard' => ['Orchard Paddock', 6, 'Chickens only.'], + ]; + + $paddocks = []; + + foreach ($definitions as $key => [$name, $capacity, $notes]) { + $paddocks[$key] = Paddock::factory()->create([ + 'name' => $name, + 'capacity' => $capacity, + 'notes' => $notes, + ]); + } + + return $paddocks; + } + + /** + * @return array> + */ + private function createAnimals(): array + { + return [ + 'cattle' => Animal::factory()->count(14)->species(Species::Cattle)->sequence( + fn ($sequence) => ['tag_number' => sprintf('CT-%03d', $sequence->index + 1), 'breed' => 'Angus'], + )->create(), + 'sheep' => Animal::factory()->count(12)->species(Species::Sheep)->sequence( + fn ($sequence) => ['tag_number' => sprintf('SH-%03d', $sequence->index + 1), 'breed' => 'Romney'], + )->create(), + 'goats' => Animal::factory()->count(4)->species(Species::Goat)->sequence( + fn ($sequence) => ['tag_number' => sprintf('GT-%03d', $sequence->index + 1), 'breed' => 'Boer'], + )->create(), + 'chickens' => Animal::factory()->count(6)->species(Species::Chicken)->sequence( + fn ($sequence) => ['tag_number' => sprintf('CH-%03d', $sequence->index + 1), 'breed' => null], + )->create(), + ]; + } + + /** + * Initial placements, spread over the last few months. + * + * @param array> $animals + * @param array $paddocks + */ + private function placeAnimals(array $animals, array $paddocks): void + { + $started = Carbon::now()->subMonths(4)->startOfDay(); + + // North Paddock: 10 of 12 cattle -> nearly full (warning). + foreach ($animals['cattle']->take(10) as $offset => $animal) { + $this->moveAnimal->handle($animal, $paddocks['north'], $started->copy()->addDays($offset), 'Initial placement.'); + } + + // Remaining cattle start on River Flat with the sheep. + foreach ($animals['cattle']->skip(10) as $offset => $animal) { + $this->moveAnimal->handle($animal, $paddocks['river'], $started->copy()->addDays($offset), 'Initial placement.'); + } + + foreach ($animals['sheep'] as $offset => $animal) { + $this->moveAnimal->handle($animal, $paddocks['river'], $started->copy()->addDays(3 + $offset), 'Initial placement.'); + } + + // Home Paddock: all 4 goats plus one sheep -> full. + foreach ($animals['goats'] as $offset => $animal) { + $this->moveAnimal->handle($animal, $paddocks['home'], $started->copy()->addWeeks(1)->addDays($offset), 'Initial placement.'); + } + + // Orchard: chickens, leaving one unplaced so the dashboard shows it. + foreach ($animals['chickens']->take(5) as $offset => $animal) { + $this->moveAnimal->handle($animal, $paddocks['orchard'], $started->copy()->addWeeks(2)->addDays($offset), 'Initial placement.'); + } + } + + /** + * A handful of later moves so the history and "recent movements" list + * have something to show. + * + * @param array> $animals + * @param array $paddocks + */ + private function shuffleSomeAnimals(array $animals, array $paddocks): void + { + $lastMonth = Carbon::now()->subMonth(); + + foreach ($animals['sheep']->take(6) as $offset => $sheep) { + $this->moveAnimal->handle($sheep, $paddocks['top'], $lastMonth->copy()->addDays($offset), 'Rotating onto fresh pasture.'); + } + + $lameSheep = $animals['sheep']->get(6); + $this->moveAnimal->handle($lameSheep, $paddocks['home'], Carbon::now()->subDays(9)->setTime(8, 15), 'Lame — keeping close to the yards for treatment.'); + + $this->moveAnimal->handle($animals['cattle']->get(10), $paddocks['top'], Carbon::now()->subDays(5)->setTime(14, 0), null); + $this->moveAnimal->handle($animals['cattle']->get(11), $paddocks['top'], Carbon::now()->subDays(5)->setTime(14, 5), null); + } + + /** + * @param array> $animals + */ + private function retireSomeAnimals(array $animals): void + { + $this->changeStatus->handle($animals['cattle']->get(12), AnimalStatus::Sold); + $this->changeStatus->handle($animals['cattle']->get(13), AnimalStatus::Sold); + $this->changeStatus->handle($animals['sheep']->get(11), AnimalStatus::Deceased); + } + + /** + * @param array> $animals + */ + private function recordHealthEvents(array $animals): void + { + foreach ($animals['cattle']->take(10) as $animal) { + HealthRecord::factory()->for($animal)->create([ + 'recorded_on' => Carbon::now()->subMonths(3)->toDateString(), + 'type' => 'vaccination', + 'description' => '7-in-1 clostridial vaccine', + 'notes' => null, + ]); + } + + HealthRecord::factory()->for($animals['sheep']->get(6))->create([ + 'recorded_on' => Carbon::now()->subDays(9)->toDateString(), + 'type' => 'injury', + 'description' => 'Lame on front left', + 'notes' => 'Foot trimmed and sprayed. Re-check in a week.', + ]); + + HealthRecord::factory()->for($animals['sheep']->get(6))->create([ + 'recorded_on' => Carbon::now()->subDays(2)->toDateString(), + 'type' => 'checkup', + 'description' => 'Follow-up on lameness', + 'notes' => 'Improving, still slightly favouring the leg.', + ]); + + foreach ($animals['goats'] as $goat) { + HealthRecord::factory()->for($goat)->create([ + 'recorded_on' => Carbon::now()->subWeeks(3)->toDateString(), + 'type' => 'treatment', + 'description' => 'Drenched for worms', + ]); + } + + HealthRecord::factory()->count(6)->create([ + 'animal_id' => fn () => $animals['sheep']->random()->id, + ]); + } +} diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..ead97a1 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,268 @@ +# Mini Farm Manager — Design Document + +This document records the domain model, business rules, application structure and testing strategy for the Mini Farm Manager exercise. It was written _before_ implementation so that decisions are made once, deliberately, and can be referenced from the README. + +Requirements: see [`requirements.md`](requirements.md). + +--- + +## 1. Overview and stack + +| Layer | Choice | Why | +| ------------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Backend | Laravel 13 / PHP 8.4 | Required by the brief | +| Frontend | Inertia 3 + Vue 3.5 + TypeScript | Server-driven pages without a separate JSON API: less glue code, validation errors flow straight into `useForm` | +| UI | Tailwind 4 + shadcn-vue components (`resources/js/components/ui`) | Already shipped with the starter kit; consistent look with minimal effort | +| Routing glue | Laravel Wayfinder | Typed route/action helpers in TypeScript, no hard-coded URLs | +| Database | SQLite | Zero setup for reviewers; schema is plain enough to run on MySQL/Postgres unchanged | +| Tests | PHPUnit 12 (feature tests) | Starter kit default | + +**Authentication**: the brief says authentication is not required. The starter kit ships with Fortify (login, registration, 2FA, passkeys). We keep it installed — removing it costs time and buys nothing — but all farm routes are registered **outside** the `auth` middleware group so a reviewer can use the app without logging in. + +--- + +## 2. Domain model + +``` +paddocks 1 ──< animals (animals.current_paddock_id, nullable) +animals 1 ──< animal_movements (from_paddock_id?, to_paddock_id?) +animals 1 ──< health_records +``` + +### `animals` + +| Column | Type | Constraints / notes | +| -------------------- | ---------------------------- | ------------------------------------------------------------- | +| `id` | bigint PK | | +| `tag_number` | string | required, **unique** | +| `name` | string | nullable ("if applicable") | +| `species` | string (enum `Species`) | `cattle`, `sheep`, `goat`, `pig`, `horse`, `chicken`, `other` | +| `sex` | string (enum `Sex`) | `male`, `female` | +| `date_of_birth` | date | required, must not be in the future | +| `breed` | string | nullable | +| `status` | string (enum `AnimalStatus`) | `active` (default), `sold`, `deceased` | +| `notes` | text | nullable | +| `current_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| timestamps | | | + +### `paddocks` + +| Column | Type | Constraints / notes | +| ---------- | ------------ | -------------------- | +| `id` | bigint PK | | +| `name` | string | required, **unique** | +| `capacity` | unsigned int | required, `>= 1` | +| `notes` | text | nullable | +| timestamps | | | + +### `animal_movements` + +| Column | Type | Constraints / notes | +| ----------------- | --------------- | ------------------------ | +| `id` | bigint PK | | +| `animal_id` | FK → `animals` | `cascadeOnDelete` | +| `from_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| `to_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| `moved_at` | datetime | required | +| `notes` | text | nullable | +| timestamps | | | + +Index on `(animal_id, moved_at)`. + +Semantics of the nullable ends: + +- `from = null, to = X` — initial placement (animal enters the farm / first assignment). +- `from = X, to = null` — animal leaves its paddock (status changed to `sold` or `deceased`). +- `from = X, to = Y` — ordinary move. + +### `health_records` + +| Column | Type | Constraints / notes | +| ------------- | -------------------------------- | -------------------------------------------------------- | +| `id` | bigint PK | | +| `animal_id` | FK → `animals` | `cascadeOnDelete` | +| `recorded_on` | date | required, not in the future | +| `type` | string (enum `HealthRecordType`) | `vaccination`, `treatment`, `injury`, `checkup`, `other` | +| `description` | string | required, short summary | +| `notes` | text | nullable | +| timestamps | | | + +Index on `(animal_id, recorded_on)`. + +### Enums + +PHP backed enums in `app/Enums/` (`Species`, `Sex`, `AnimalStatus`, `HealthRecordType`), each with a `label()` method. Controllers pass them to Vue as `{ value, label }[]` option lists so the frontend never hard-codes the values. + +### Design decision: store `current_paddock_id` or derive it? + +**Stored.** Every read path — animal list, paddock page, dashboard, and the capacity check itself — needs "where is this animal now". Deriving it from the latest movement means a correlated subquery on every one of those queries. + +The trade-off is a denormalised column that can drift from the movement history. Mitigations: + +- `current_paddock_id` is **only ever written inside the same database transaction** that inserts the corresponding `animal_movements` row (see `MoveAnimal` below). No other code path touches it. +- A feature test asserts that after a sequence of moves, `current_paddock_id` equals the `to_paddock_id` of the animal's latest movement. + +The movement table remains the source of truth for _history_; the column is a cache of its most recent row. + +--- + +## 3. Business rules + +1. **Occupancy.** `occupancy(paddock) = count(animals where current_paddock_id = paddock.id AND status = active)`. Non-active animals never occupy a paddock (rule 3 guarantees they have no `current_paddock_id` anyway). + +2. **Moving an animal** into paddock _T_ is allowed only when: + - the animal's status is `active`; + - _T_ differs from the animal's current paddock; + - `occupancy(T) < T.capacity`. + + The move runs inside `DB::transaction()` and takes `lockForUpdate()` on the target paddock row before counting, so two concurrent moves cannot both pass the check and overfill the paddock. + +3. **Status change away from `active`** (`sold` or `deceased`) removes the animal from its paddock: a movement `(from = current, to = null)` is recorded and `current_paddock_id` is set to null, in one transaction. Changing status back to `active` leaves the animal unplaced; the user assigns a paddock through the normal move action (the edit form shows a hint explaining this). + +4. **Initial placement.** The "add animal" form has an optional paddock select. If provided, the animal is created and immediately placed via the same move logic, so the capacity rule applies from the very first record. + +5. **Editing a paddock's capacity** below its current occupancy is rejected with a validation error. This keeps "a paddock is never over capacity" as a hard invariant rather than a soft warning. + +6. **Capacity warning** _(the additional feature)_. Each paddock has a derived `occupancy_state`: + + | Condition | State | UI | + | ---------------------------------- | --------- | ----------------------------------- | + | `occupancy / capacity < threshold` | `ok` | neutral | + | `threshold <= ratio < 1` | `warning` | amber highlight | + | `ratio >= 1` | `full` | red highlight, move target disabled | + + `threshold` lives in `config/farm.php` (`capacity_warning_threshold`, default `0.8`). The state is computed server-side in one place (`Paddock::occupancyState()`, using `withCount` for lists) so every page agrees. + +7. **Deletion** is out of scope: the brief asks only for add / edit / view. No destroy routes are exposed. This also sidesteps "what happens to history when a paddock is deleted" — the `nullOnDelete` FKs are there only as a safety net. + +--- + +## 4. Application layer + +### Actions (single-purpose classes in `app/Actions/`) + +- **`MoveAnimal`** — the one non-trivial write in the system. + `handle(Animal $animal, ?Paddock $to, CarbonInterface $movedAt, ?string $notes = null): AnimalMovement` + Opens a transaction, locks the target paddock, checks rules 2/3, inserts the movement, updates `current_paddock_id`. Violations throw `ValidationException` keyed to the form field (`to_paddock_id`) so Inertia renders them inline. + +- **`ChangeAnimalStatus`** — applies a status change; when leaving `active` it delegates to `MoveAnimal` with `to = null`. + +### Form Requests (`app/Http/Requests/`) + +`StoreAnimalRequest`, `UpdateAnimalRequest`, `StorePaddockRequest`, `UpdatePaddockRequest` (includes the "capacity >= occupancy" rule), `StoreAnimalMovementRequest`, `StoreHealthRecordRequest`. + +Validation lives here; the Actions enforce the invariants that depend on database state. + +### Controllers (`app/Http/Controllers/`) + +| Controller | Methods | +| -------------------------- | ---------------------------------------------------- | +| `DashboardController` | `__invoke` | +| `AnimalController` | `index`, `create`, `store`, `show`, `edit`, `update` | +| `PaddockController` | `index`, `create`, `store`, `show`, `edit`, `update` | +| `AnimalMovementController` | `store` | +| `HealthRecordController` | `store` | + +Controllers stay thin: validate via Form Request, call an Action or a simple Eloquent write, redirect with a flash message. + +### Models, factories, seeder + +- Models: `Animal`, `Paddock`, `AnimalMovement`, `HealthRecord` with enum casts and relationships. +- Factories with states: `AnimalFactory::active() / sold() / deceased() / inPaddock(Paddock)`, `PaddockFactory::withCapacity(int)`. +- `FarmSeeder` builds a realistic demo farm: a handful of paddocks (including one at ~85 % and one full), ~20 animals across species, a movement history and a few health records — so the dashboard and warnings are visible immediately after `php artisan migrate --seed`. + +--- + +## 5. Routes (`routes/web.php`, all public) + +``` +GET / → redirect to dashboard +GET /dashboard dashboard + +GET /animals animals.index filters: species, status, paddock, search (tag/name) +GET /animals/create animals.create +POST /animals animals.store +GET /animals/{animal} animals.show movement history, health history, move dialog, add health record +GET /animals/{animal}/edit animals.edit +PUT /animals/{animal} animals.update +POST /animals/{animal}/movements animals.movements.store +POST /animals/{animal}/health-records animals.health-records.store + +GET /paddocks paddocks.index occupancy + warning badge per paddock +GET /paddocks/create paddocks.create +POST /paddocks paddocks.store +GET /paddocks/{paddock} paddocks.show animals currently inside +GET /paddocks/{paddock}/edit paddocks.edit +PUT /paddocks/{paddock} paddocks.update +``` + +Fortify and settings routes remain as generated by the starter kit. The sidebar's `NavUser` component must tolerate a guest (`auth.user === null`) and show a "Log in" link instead of the user menu. + +--- + +## 6. Frontend + +Pages under `resources/js/pages/`: + +| Page | Content | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Dashboard.vue` | Stat cards (active animals, paddocks, paddocks needing attention); animals by species; paddock occupancy list with progress bars (amber ≥ 80 %, red when full); recent movements; capacity-warning panel | +| `animals/Index.vue` | Filterable table: tag, name, species, sex, age, status badge, current paddock | +| `animals/Create.vue`, `animals/Edit.vue` | Shared `AnimalForm`; create offers optional initial paddock | +| `animals/Show.vue` | Details, current paddock, "Move" dialog, movement history, health history + "Add record" form | +| `paddocks/Index.vue` | Cards/table with occupancy bar and state badge | +| `paddocks/Create.vue`, `paddocks/Edit.vue` | Shared `PaddockForm` | +| `paddocks/Show.vue` | Capacity summary and the list of animals currently inside | + +Shared components in `resources/js/components/farm/`: `AnimalForm`, `PaddockForm`, `MoveAnimalDialog`, `HealthRecordForm`, `OccupancyBar`, `AnimalStatusBadge`, `EmptyState`. + +Conventions: + +- Forms use Inertia `useForm` and Wayfinder action helpers (`@/actions/App/Http/Controllers/...`). +- Server flashes `success` through `HandleInertiaRequests`; the layout shows it with the existing `sonner` toaster. +- Sidebar navigation: Dashboard, Animals, Paddocks. +- Shared TypeScript types in `resources/js/types/farm.d.ts`. +- Empty states everywhere a list can be empty (new install without seeding). + +--- + +## 7. Testing strategy + +Feature tests (PHPUnit, `RefreshDatabase`, factories), covering behaviour the farmer would notice if it broke: + +| Test class | Cases | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AnimalTest` | create with valid data; duplicate tag rejected; DOB in the future rejected; create with initial paddock records a `null → paddock` movement; update basic fields; status → `sold` removes from paddock and writes a `paddock → null` movement | +| `PaddockTest` | create / update validation; capacity below current occupancy rejected; show lists only active animals currently inside | +| `AnimalMovementTest` | successful move updates `current_paddock_id` and appends history; rejected when target is full; rejected when target equals current; rejected when animal is not active; `current_paddock_id` matches latest movement after several moves | +| `HealthRecordTest` | store with valid data; validation failures; animal page lists records newest first | +| `DashboardTest` | animal counts and species breakdown; warnings panel includes a paddock at ≥ 80 % and excludes one below | +| `PaddockOccupancyTest` | `occupancyState()` boundaries: 79 % → ok, 80 % → warning, 100 % → full; sold animals do not count | + +Not tested: Fortify auth (already covered by starter kit tests), pure layout. + +--- + +## 8. Deliberately left out / production improvements + +Left out to respect the 3–4 hour budget: + +- Deleting animals, paddocks or records +- Recording _who_ performed a movement (no auth in scope) +- Weight / medication history, photos, documents +- Bulk moves (move a whole mob between paddocks) +- Pagination beyond a simple paginator on the animals list + +Before production: + +- Real concurrency test of the capacity lock against MySQL/Postgres +- Authorisation (multiple farms / users) +- Soft deletes and an audit log +- Reporting exports (CSV) and date-range filters on history +- Accessibility and mobile review of the forms + +--- + +## 9. AI-assisted development + +Claude Code (Anthropic) was used to scaffold the project via `laravel new … --boost`, to draft this document from a guided discussion, and to generate boilerplate, pages and tests. Every business rule in §3 was decided in conversation, and all generated code is reviewed and verified by running the test suite and the app locally. Details go in the README's "How AI tools were used" section. diff --git a/resources/js/app.ts b/resources/js/app.ts index 5872d1e..ed52afd 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -11,8 +11,6 @@ void createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { switch (true) { - case name === 'Welcome': - return null; case name.startsWith('auth/'): return AuthLayout; case name.startsWith('settings/'): diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index d58773b..2a7154c 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -238,7 +238,7 @@ const rightNavItems: NavItem[] = [ - + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a65f9d0..d941585 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +