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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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

---

Expand Down
27 changes: 26 additions & 1 deletion app/Actions/MoveAnimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -34,14 +35,18 @@ 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);
}

$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,
]);

Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
];
}
}
3 changes: 1 addition & 2 deletions app/Http/Resources/AnimalSummaryResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 ? [
Expand Down
17 changes: 17 additions & 0 deletions app/Models/Animal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
|
*/

'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'UTC'),

/*
|--------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/farm/AnimalForm.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import FormSelect from '@/components/farm/FormSelect.vue';
import InputError from '@/components/InputError.vue';
Expand Down Expand Up @@ -32,7 +32,7 @@ const form = useForm({
paddock_id: '',
});

const today = new Date().toISOString().slice(0, 10);
const today = usePage().props.farm.today;

/**
* Paddock choices for the initial placement. Full paddocks are listed but
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/farm/HealthRecordForm.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import HealthRecordController from '@/actions/App/Http/Controllers/HealthRecordController';
import FormSelect from '@/components/farm/FormSelect.vue';
import InputError from '@/components/InputError.vue';
Expand All @@ -14,7 +14,7 @@ const props = defineProps<{
types: SelectOption[];
}>();

const today = new Date().toISOString().slice(0, 10);
const today = usePage().props.farm.today;

const form = useForm({
recorded_on: today,
Expand Down
6 changes: 4 additions & 2 deletions resources/js/components/farm/MoveAnimalDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { ArrowRightLeft } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import AnimalMovementController from '@/actions/App/Http/Controllers/AnimalMovementController';
Expand All @@ -26,6 +26,7 @@ const props = defineProps<{
}>();

const open = ref(false);
const timezone = usePage().props.farm.timezone;

const form = useForm({
to_paddock_id: '',
Expand Down Expand Up @@ -121,7 +122,8 @@ function submit(): void {
<Label for="moved_at">
When
<span class="text-muted-foreground font-normal">
(optional, defaults to now)
(optional, defaults to now — farm time,
{{ timezone }})
</span>
</Label>
<Input
Expand Down
6 changes: 4 additions & 2 deletions resources/js/pages/Dashboard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head, Link, usePage } from '@inertiajs/vue3';
import { AlertTriangle, ArrowRight, Fence, PawPrint, Plus } from '@lucide/vue';
import { computed } from 'vue';
import EmptyState from '@/components/farm/EmptyState.vue';
Expand Down Expand Up @@ -43,6 +43,8 @@ defineOptions({
},
});

const warningPercent = usePage().props.farm.capacity_warning_percent;

const isEmptyFarm = computed(
() =>
props.stats.paddocks === 0 &&
Expand Down Expand Up @@ -149,7 +151,7 @@ const farmUsagePercent = computed(() =>
{{ stats.paddocks_needing_attention }}
</p>
<p class="text-muted-foreground text-xs">
paddocks at 80% capacity or more
paddocks at {{ warningPercent }}% capacity or more
</p>
</CardContent>
</Card>
Expand Down
7 changes: 7 additions & 0 deletions resources/js/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ declare module '@inertiajs/core' {
name: string;
auth: Auth;
sidebarOpen: boolean;
farm: {
/** The farm's timezone; all dates and times are interpreted in it. */
timezone: string;
/** Today's date (YYYY-MM-DD) in the farm's timezone. */
today: string;
capacity_warning_percent: number;
};
[key: string]: unknown;
};
}
Expand Down
47 changes: 47 additions & 0 deletions tests/Feature/AnimalMovementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,53 @@ public function test_current_paddock_always_matches_the_latest_movement(): void
$this->assertSame($c->id, $latest->from_paddock_id);
}

public function test_a_movement_cannot_be_dated_before_the_latest_movement(): void
{
[$a, $b] = Paddock::factory()->count(2)->withCapacity(5)->create();
$animal = Animal::factory()->create();

$this->moveAnimal()->handle($animal, $a, now()->setTime(10, 0));

try {
$this->moveAnimal()->handle($animal, $b, now()->subDay());
$this->fail('Expected a ValidationException.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('moved_at', $exception->errors());
}

$this->assertSame($a->id, $animal->fresh()->current_paddock_id);
$this->assertSame(1, $animal->movements()->count());
}

public function test_a_movement_may_share_the_timestamp_of_the_latest_movement(): void
{
[$a, $b] = Paddock::factory()->count(2)->withCapacity(5)->create();
$animal = Animal::factory()->create();
$at = now()->setTime(10, 0);

$this->moveAnimal()->handle($animal, $a, $at);
$this->moveAnimal()->handle($animal, $b, $at);

$this->assertSame($b->id, $animal->fresh()->current_paddock_id);
}

public function test_the_endpoint_rejects_a_back_dated_movement_on_the_date_field(): void
{
[$a, $b] = Paddock::factory()->count(2)->withCapacity(5)->create();
$animal = Animal::factory()->create();
$this->moveAnimal()->handle($animal, $a, now());

$this->from(route('animals.show', $animal))
->post(route('animals.movements.store', $animal), [
'to_paddock_id' => $b->id,
'moved_at' => now()->subDays(2)->format('Y-m-d\TH:i'),
])
->assertRedirect(route('animals.show', $animal))
->assertSessionHasErrors(['moved_at']);

$this->assertSame($a->id, $animal->fresh()->current_paddock_id);
}

public function test_an_animal_can_be_moved_through_the_endpoint(): void
{
$from = Paddock::factory()->create();
Expand Down
28 changes: 28 additions & 0 deletions tests/Unit/AnimalAgeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Tests\Unit;

use App\Models\Animal;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\TestWith;
use Tests\TestCase;

class AnimalAgeTest extends TestCase
{
#[TestWith(['2026-09-12', '0d'])]
#[TestWith(['2026-09-08', '4d'])]
#[TestWith(['2026-02-08', '7m 4d'])]
#[TestWith(['2026-02-12', '7m'])]
#[TestWith(['2025-01-20', '1y 7m'])]
#[TestWith(['2023-09-12', '3y'])]
public function test_age_is_reported_in_whole_days_months_and_years(string $dateOfBirth, string $expected): void
{
Carbon::setTestNow('2026-09-12 23:30:00');

$animal = new Animal(['date_of_birth' => $dateOfBirth]);

$this->assertSame($expected, $animal->ageLabel());

Carbon::setTestNow();
}
}