diff --git a/.env.example b/.env.example index bb2bfc2..de53fac 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/README.md b/README.md index 3420170..a7c34e7 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ Run `php artisan migrate:fresh --seed` to reset it. - **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`. +- **All dates and times are in the farm's timezone** (`APP_TIMEZONE`, default `UTC`). "Today" for date pickers and the timestamp on a move are interpreted in that timezone, not the browser's, so a farmer and a remote vet entering data see the same dates. +- **Movement history is chronological.** A movement may be back-dated, but not to before the animal's most recent movement — otherwise the stored current paddock and the history would disagree. - **Tag numbers are unique** across the whole farm and are the primary way a farmer identifies an animal; names are optional. --- @@ -116,7 +117,7 @@ Full reasoning is in [`docs/design.md`](docs/design.md); the short version: - 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 +- Add a cache for dashboard aggregates once the herd is large enough to matter --- diff --git a/app/Actions/MoveAnimal.php b/app/Actions/MoveAnimal.php index 6c4c582..11ba8f2 100644 --- a/app/Actions/MoveAnimal.php +++ b/app/Actions/MoveAnimal.php @@ -6,6 +6,7 @@ use App\Models\AnimalMovement; use App\Models\Paddock; use Carbon\CarbonInterface; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -34,6 +35,10 @@ public function handle( $locked = Animal::query()->lockForUpdate()->findOrFail($animal->id); $animal->setRawAttributes($locked->getAttributes(), true); + $movedAt ??= now(); + + $this->ensureMovementIsChronological($animal, $movedAt); + if ($to !== null) { $this->ensureAnimalCanBePlaced($animal, $to); } @@ -41,7 +46,7 @@ public function handle( $movement = $animal->movements()->create([ 'from_paddock_id' => $animal->current_paddock_id, 'to_paddock_id' => $to?->id, - 'moved_at' => $movedAt ?? now(), + 'moved_at' => $movedAt, 'notes' => $notes, ]); @@ -51,6 +56,26 @@ public function handle( }); } + /** + * `current_paddock_id` mirrors the latest movement, so a movement dated + * before an existing one would make the two disagree. Back-dating is + * therefore only allowed up to the animal's most recent movement. + * + * @throws ValidationException + */ + private function ensureMovementIsChronological(Animal $animal, CarbonInterface $movedAt): void + { + $latest = $animal->movements()->max('moved_at'); + + if ($latest !== null && $movedAt->lt(Carbon::parse($latest))) { + $formatted = Carbon::parse($latest)->format('j M Y, H:i'); + + throw ValidationException::withMessages([ + 'moved_at' => "This animal already has a movement on {$formatted}. New movements must not be earlier than that.", + ]); + } + } + /** * 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 diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..2969be7 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,6 +42,11 @@ public function share(Request $request): array 'user' => $request->user(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'farm' => [ + 'timezone' => config('app.timezone'), + 'today' => now()->toDateString(), + 'capacity_warning_percent' => (int) round(config('farm.capacity_warning_threshold') * 100), + ], ]; } } diff --git a/app/Http/Resources/AnimalSummaryResource.php b/app/Http/Resources/AnimalSummaryResource.php index 298e89d..aa885ac 100644 --- a/app/Http/Resources/AnimalSummaryResource.php +++ b/app/Http/Resources/AnimalSummaryResource.php @@ -3,7 +3,6 @@ namespace App\Http\Resources; use App\Models\Animal; -use Carbon\CarbonInterface; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -30,7 +29,7 @@ public function toArray(Request $request): array '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), + 'age' => $this->ageLabel(), 'status' => $this->status->value, 'status_label' => $this->status->label(), 'current_paddock' => $this->whenLoaded('currentPaddock', fn () => $this->currentPaddock ? [ diff --git a/app/Models/Animal.php b/app/Models/Animal.php index a6ba63d..97e1042 100644 --- a/app/Models/Animal.php +++ b/app/Models/Animal.php @@ -87,6 +87,23 @@ public function isActive(): bool return $this->status === AnimalStatus::Active; } + /** + * Age in whole years, months and days — the date of birth has no time + * component, so anything finer than a day would be noise. + */ + public function ageLabel(): string + { + $diff = $this->date_of_birth->startOfDay()->diff(Carbon::today()); + + $parts = array_filter([ + $diff->y > 0 ? $diff->y.'y' : null, + $diff->m > 0 ? $diff->m.'m' : null, + $diff->y === 0 && $diff->d > 0 ? $diff->d.'d' : null, + ]); + + return $parts === [] ? '0d' : implode(' ', array_slice($parts, 0, 2)); + } + /** * Tag number, with the name appended when the animal has one. */ diff --git a/config/app.php b/config/app.php index 1f8dd27..e7df443 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'UTC'), /* |-------------------------------------------------------------------------- diff --git a/package.json b/package.json index a0accff..e375ddb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "vp dev", "check": "vp check", "check:fix": "vp check --fix", - "types:check": "vue-tsc --noEmit" + "types:check": "php artisan wayfinder:generate --with-form && vue-tsc --noEmit" }, "dependencies": { "@inertiajs/vite": "^3.0.0", diff --git a/resources/js/components/farm/AnimalForm.vue b/resources/js/components/farm/AnimalForm.vue index 0898cde..d50d956 100644 --- a/resources/js/components/farm/AnimalForm.vue +++ b/resources/js/components/farm/AnimalForm.vue @@ -1,5 +1,5 @@