Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .claude/launch.json

This file was deleted.

2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
APP_NAME=Laravel
APP_NAME="Mini Farm Manager"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
/.claude/launch.json
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions app/Actions/ChangeAnimalStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Actions;

use App\Enums\AnimalStatus;
use App\Models\Animal;
use Illuminate\Support\Facades\DB;

/**
* Changes an animal's status. Leaving the active state also takes the animal
* out of its paddock so it stops occupying capacity, with the departure
* recorded in the movement history.
*/
class ChangeAnimalStatus
{
public function __construct(private MoveAnimal $moveAnimal) {}

public function handle(Animal $animal, AnimalStatus $status): Animal
{
return DB::transaction(function () use ($animal, $status): Animal {
$animal->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();
});
}
}
83 changes: 83 additions & 0 deletions app/Actions/MoveAnimal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace App\Actions;

use App\Models\Animal;
use App\Models\AnimalMovement;
use App\Models\Paddock;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

/**
* Relocates an animal and records the movement.
*
* This is the only code path that writes `animals.current_paddock_id`, which
* keeps that denormalised column in step with the movement history.
*/
class MoveAnimal
{
/**
* @param Paddock|null $to Null means the animal leaves its paddock (e.g. sold).
*
* @throws ValidationException
*/
public function handle(
Animal $animal,
?Paddock $to,
?CarbonInterface $movedAt = null,
?string $notes = null,
): AnimalMovement {
return DB::transaction(function () use ($animal, $to, $movedAt, $notes): AnimalMovement {
// Work from the locked row, but keep the caller's instance in sync
// so it never carries a stale current_paddock_id afterwards.
$locked = Animal::query()->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.",
]);
}
}
}
20 changes: 20 additions & 0 deletions app/Enums/AnimalStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Enums;

enum AnimalStatus: string
{
use HasOptions;

case Active = 'active';
case Sold = 'sold';
case Deceased = 'deceased';

/**
* Only active animals occupy paddock capacity.
*/
public function occupiesPaddock(): bool
{
return $this === self::Active;
}
}
33 changes: 33 additions & 0 deletions app/Enums/HasOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Enums;

/**
* Shared helpers for string-backed enums that are rendered as select options.
*/
trait HasOptions
{
public function label(): string
{
return ucfirst($this->value);
}

/**
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}

/**
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $case): array => ['value' => $case->value, 'label' => $case->label()],
self::cases(),
);
}
}
14 changes: 14 additions & 0 deletions app/Enums/HealthRecordType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Enums;

enum HealthRecordType: string
{
use HasOptions;

case Vaccination = 'vaccination';
case Treatment = 'treatment';
case Injury = 'injury';
case Checkup = 'checkup';
case Other = 'other';
}
21 changes: 21 additions & 0 deletions app/Enums/OccupancyState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Enums;

enum OccupancyState: string
{
case Ok = 'ok';
case Warning = 'warning';
case Full = 'full';

public static function fromRatio(float $ratio): self
{
$threshold = (float) config('farm.capacity_warning_threshold', 0.8);

return match (true) {
$ratio >= 1.0 => self::Full,
$ratio >= $threshold => self::Warning,
default => self::Ok,
};
}
}
11 changes: 11 additions & 0 deletions app/Enums/Sex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Enums;

enum Sex: string
{
use HasOptions;

case Male = 'male';
case Female = 'female';
}
Loading