From a226be0ca0d3c8af9d9ad95ad4dcc9eb1ef8717f Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Aug 2026 21:35:24 +0100 Subject: [PATCH 01/11] Adding in claude skill file --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100755 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..c20c6ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# Street Listings — Project Context + +## Codebase conventions + +This app has no separate JSON API. Controllers return `Inertia::render(...)` +with props; API Resources (`ListingResource`, `BranchResource`) define the +shape of those props. Follow this shape for any new page or data the +frontend needs — don't introduce a parallel REST/JSON endpoint style unless +there's a good reason (e.g. this alerts feature may justify one — note the +trade-off if so). + +- **Routing**: `routes/web.php`, resolves to `Inertia::render`. +- **Resources**: shape data going to the frontend. One Resource per model + concept, matching `ListingResource` / `BranchResource`. +- **Enums**: domain vocabularies (`PropertyType`, `ListingStatus`) live in + `app/Enums/`. Use PHP enums, not magic strings. +- **Validation**: Form Requests (`app/Http/Requests/`), not inline + controller validation — see `ListingIndexRequest`. +- **Auth**: stubbed via `App\Http\Middleware\ActAsDemoUser`. Always build + against `auth()->user()` / `$request->user()`. +- **Filters/state**: query-string driven where relevant, so state is + shareable and bookmarkable. +- **Frontend**: Vue 3 + Tailwind, pages in `resources/js/pages/`, + reusable pieces in `resources/js/components/`. + +Checks before considering work done: + +php artisan test +vendor/bin/pint --test +vendor/bin/phpstan analyse + + +## Saved-search alerts feature — the brief + +1. A user can **create, view, and delete saved searches** — a set of + criteria (e.g. max price, min bedrooms, property type, region). +2. When a **new listing becomes live** matching a saved search, an + **alert** is generated for that user. +3. A user can **see their alerts**. + +Constraints: no real emails, no queue worker required — a persisted alert +record or log/database-driver notification is enough (describe a queue +design if implied, don't build it). Auth is stubbed — build against +`auth()->user()`. Prioritise a clean, well-tested vertical slice over +breadth — don't gold-plate. + +Open decisions to make explicitly (state the call + one-line reasoning in +code comments or NOTES.md, don't pick silently): +- What counts as a "match"? +- Backfill: alert on already-live matches, or only future listings? +- Duplicate alerts: one per user, or one per matching saved search? +- Criteria set: max price vs. range, which filters to support. +- Anti-spam: Support flagged this explicitly — avoid re-alerting on + unchanged matches. + +Definition of done: tests assert behaviour that matters (matching logic, +alert creation, saved-search CRUD); `NOTES.md` at repo root covers key +decisions, trade-offs, what was left out, what's next, where it'd break at +scale; work on a branch, PR against your own fork. From a48b298b909bd0dadefadf35d533c7119e34683b Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Aug 2026 21:37:33 +0100 Subject: [PATCH 02/11] Adding the create-pr skill --- .claude/skills/create-pr/SKILL.md | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100755 .claude/skills/create-pr/SKILL.md diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md new file mode 100755 index 0000000..6a534fd --- /dev/null +++ b/.claude/skills/create-pr/SKILL.md @@ -0,0 +1,65 @@ +--- +name: create-pr +description: Use when the user asks to create a pull request, open a PR, or ship their current branch for review. Handles PR creation, Jira linking, Copilot review, and stacking large PRs. +--- + +# Create Pull Request + +Follow these steps in order. Do not skip the size check — it comes before +opening the PR, not after. + +## 1. Review the diff and check size + +Run `git diff main...HEAD --stat` (or the relevant base branch) to see +what's changed. + +If the diff is large or touches multiple unrelated concerns, **stop and +propose a stack** instead of one PR: break the branch into a sequence of +smaller branches/PRs, each depending on the previous one, each reviewable +on its own (e.g. "schema + migration" → "backend logic" → "frontend"). +Describe the proposed stack to the user and confirm before restructuring +history. Only proceed to a single PR once the change is a sensible, +reviewable unit. + +## 2. Ask for the Jira ticket link + +Before creating the PR, ask the user for the Jira ticket link if one +hasn't been provided. Don't guess a ticket number or invent one. + +## 3. Write the PR description + +Summarise the actual changes (not a commit-by-commit log) — what changed, +why, and anything a reviewer should pay attention to. Include the Jira +link in the description (e.g. under a "Ticket:" line). + +## 4. Create and mark ready for review + +gh pr create --title "" --body "" --base main +gh pr ready # only if it was opened as a draft + + +Confirm the PR is not left in draft state unless the user asked for that. + +## 5. Request a Copilot review + +gh pr edit --add-reviewer @copilot + + +Wait for Copilot's review to post. + +## 6. Triage Copilot's suggestions + +For each suggestion Copilot makes: + +- **If you agree** — apply the fix, commit, and push. +- **If you disagree** — do not apply it. Instead, reply on the PR (or + summarise to the user) explaining specifically why the suggestion is + wrong or not worth taking, so the user can make the final call. Never + silently ignore a suggestion — every one gets either fixed or explained. + +## 7. Watch for further activity + +After the above, continue monitoring the PR for new commits, new Copilot +review rounds triggered by pushes, or human reviewer comments, and repeat +step 6's triage for anything new that comes in — until the user says +they're done or the PR is merged. From 08d088ac4580f89f8eec7e89fc95f55dc4c1d0f0 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Aug 2026 21:41:23 +0100 Subject: [PATCH 03/11] Added in the grill me skill --- .claude/skills/grill-me/SKILL.md | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100755 .claude/skills/grill-me/SKILL.md diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100755 index 0000000..8b03297 --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,49 @@ +--- +name: grill-me +description: Use before starting any non-trivial coding task — a new feature, a refactor, a bug fix with unclear scope, or any instruction that leaves room for interpretation. Runs a short clarifying interview before writing any code. +--- + +# Grill Me + +Before doing any work on an instruction, clarify it first. Do not start +writing code, running commands, or making changes until this process is +complete and the developer has confirmed alignment. + +## Rules + +- **Never silently assume.** Anywhere the instruction is ambiguous, + underspecified, or has more than one reasonable interpretation, ask. +- **Don't just ask open questions.** For each point of ambiguity, give a + short list of sensible options and **state your recommendation** with a + one-line reason — the developer should be able to just say "go with + your suggestion" rather than having to design the answer themselves. +- **Leave room for more detail.** After presenting the questions/options, + explicitly invite the developer to add context, correct an assumption, + or explain further — don't treat the first answer as necessarily final. +- **Iterate, don't loop forever.** Ask what's still unclear after each + round of answers. Stop once there's nothing left that would change how + you'd implement it — don't manufacture extra questions for their own + sake once genuine ambiguity is resolved. + +## Process + +1. Read the instruction. Identify every point where a reasonable + implementation could go more than one way (scope, edge cases, data + shape, UX behaviour, what "done" means, error handling, naming, + architecture choices that aren't dictated by existing patterns). +2. For each point, present it as: the question, 2–4 concrete options, and + which one you'd pick and why. +3. Ask if there's anything else the developer wants to add, correct, or + detail further before work starts. +4. Once the developer responds, check whether new ambiguity was + introduced or anything is still unresolved. If so, repeat step 2–3 for + just the remaining points — don't re-ask what's already settled. +5. Once aligned, summarise the agreed plan in a few lines and confirm + before starting the actual work. + +## What this is not + +This isn't a stalling tactic or a way to avoid making calls — where the +codebase, the brief, or prior conversation already answers something, +don't re-ask it. This is specifically for the parts a developer would +otherwise have to catch in review because they weren't decided up front. From 5af54a1342607b9623ea37dc1767621e531fc4e4 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 31 Aug 2026 19:31:03 +0100 Subject: [PATCH 04/11] Claude.md file is more specific to the repo --- CLAUDE.md | 118 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 67 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c20c6ae..96c32ef 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,59 +1,75 @@ # Street Listings — Project Context -## Codebase conventions +A property-listings app: Laravel 13 + Inertia + Vue 3 + Tailwind. Lists +properties for sale, organised by branch, with filtering and a detail view. -This app has no separate JSON API. Controllers return `Inertia::render(...)` -with props; API Resources (`ListingResource`, `BranchResource`) define the -shape of those props. Follow this shape for any new page or data the -frontend needs — don't introduce a parallel REST/JSON endpoint style unless -there's a good reason (e.g. this alerts feature may justify one — note the -trade-off if so). +## Stack + +- **Laravel 13** — no separate JSON API. Controllers return + `Inertia::render(...)` with props; API Resources define the shape of + those props. +- **Inertia + Vue 3** — pages in `resources/js/pages/`, shared components + in `resources/js/components/`. +- **Tailwind** for styling. +- **SQLite** locally (bundled with PHP, no separate DB server). + +## Layout + +app/ +Enums/ PropertyType, ListingStatus +Http/ +Controllers/ ListingController +Requests/ ListingIndexRequest (filter validation) +Resources/ ListingResource, BranchResource +Middleware/ ActAsDemoUser (auth stub — see below) +HandleInertiaRequests (shared props) +Models/ Branch, Listing +database/ +factories/ BranchFactory, ListingFactory (with states) +migrations/ branches, listings +seeders/ DatabaseSeeder +resources/js/ +pages/Listings/ Index.vue, Show.vue +components/ AppLayout, ListingCard, ListingFilters, Pagination +app.js Inertia entry point +routes/ web.php +tests/Feature/ ListingPageTest, ListingTest + + +## The domain + +- **Branch** — a name and a region. +- **Listing** — address, price, bedrooms, bathrooms, `property_type`, + `status` (`draft` / `live` / `under_offer` / `sold`), a branch, and a + `listed_at` date. + +## Conventions - **Routing**: `routes/web.php`, resolves to `Inertia::render`. -- **Resources**: shape data going to the frontend. One Resource per model - concept, matching `ListingResource` / `BranchResource`. +- **Resources**: one per model concept (`ListingResource`, + `BranchResource`), define the shape of data sent to the frontend. Don't + introduce a parallel JSON/REST style without good reason. - **Enums**: domain vocabularies (`PropertyType`, `ListingStatus`) live in - `app/Enums/`. Use PHP enums, not magic strings. + `app/Enums/`. Prefer PHP enums over magic strings/constants. - **Validation**: Form Requests (`app/Http/Requests/`), not inline controller validation — see `ListingIndexRequest`. -- **Auth**: stubbed via `App\Http\Middleware\ActAsDemoUser`. Always build - against `auth()->user()` / `$request->user()`. -- **Filters/state**: query-string driven where relevant, so state is - shareable and bookmarkable. -- **Frontend**: Vue 3 + Tailwind, pages in `resources/js/pages/`, - reusable pieces in `resources/js/components/`. - -Checks before considering work done: - -php artisan test -vendor/bin/pint --test -vendor/bin/phpstan analyse - - -## Saved-search alerts feature — the brief - -1. A user can **create, view, and delete saved searches** — a set of - criteria (e.g. max price, min bedrooms, property type, region). -2. When a **new listing becomes live** matching a saved search, an - **alert** is generated for that user. -3. A user can **see their alerts**. - -Constraints: no real emails, no queue worker required — a persisted alert -record or log/database-driver notification is enough (describe a queue -design if implied, don't build it). Auth is stubbed — build against -`auth()->user()`. Prioritise a clean, well-tested vertical slice over -breadth — don't gold-plate. - -Open decisions to make explicitly (state the call + one-line reasoning in -code comments or NOTES.md, don't pick silently): -- What counts as a "match"? -- Backfill: alert on already-live matches, or only future listings? -- Duplicate alerts: one per user, or one per matching saved search? -- Criteria set: max price vs. range, which filters to support. -- Anti-spam: Support flagged this explicitly — avoid re-alerting on - unchanged matches. - -Definition of done: tests assert behaviour that matters (matching logic, -alert creation, saved-search CRUD); `NOTES.md` at repo root covers key -decisions, trade-offs, what was left out, what's next, where it'd break at -scale; work on a branch, PR against your own fork. +- **Filters/state**: query-string driven, so a search is shareable, + bookmarkable, and survives the back button (`ListingFilters.vue` seeds + from the `filters` prop, re-issues `router.get` on submit). + +## Authentication + +Auth is stubbed via `App\Http\Middleware\ActAsDemoUser` — every request +resolves as the seeded demo user (`demo@street.example`). Build +user-scoped work against `$request->user()` / `auth()->user()` as normal; +it returns the demo user. The resolver returns `null` until the database +is seeded (`php artisan migrate --seed`). + +## Checks before considering work done + +php artisan test # PHPUnit feature/unit tests +vendor/bin/pint --test # Code style (Laravel Pint) +vendor/bin/phpstan analyse # Static analysis (Larastan, level 5) + + +All three run in CI (`.github/workflows/ci.yml`) on push and PR. From ca7b1f474e068e35e71ec0e9c81e7939d02e3908 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 31 Aug 2026 19:52:25 +0100 Subject: [PATCH 05/11] Added migration and model for saved search --- app/Models/SavedSearch.php | 33 +++++++++++++++++++ ...31_184941_create_saved_searches_tables.php | 26 +++++++++++++++ 2 files changed, 59 insertions(+) create mode 100755 app/Models/SavedSearch.php create mode 100644 database/migrations/2026_08_31_184941_create_saved_searches_tables.php diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php new file mode 100755 index 0000000..67a2c7f --- /dev/null +++ b/app/Models/SavedSearch.php @@ -0,0 +1,33 @@ + PropertyType::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/migrations/2026_08_31_184941_create_saved_searches_tables.php b/database/migrations/2026_08_31_184941_create_saved_searches_tables.php new file mode 100644 index 0000000..aaed34b --- /dev/null +++ b/database/migrations/2026_08_31_184941_create_saved_searches_tables.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('property_type')->nullable(); + $table->unsignedInteger('max_price')->nullable(); + $table->unsignedInteger('min_bedrooms')->nullable(); + $table->string('region')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_searches'); + } +}; From e805107e92fad00aabe1bf2f5ad61338dbe7affb Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 31 Aug 2026 20:00:38 +0100 Subject: [PATCH 06/11] Added scope to listing --- app/Http/Controllers/ListingController.php | 6 +++++- app/Models/Listing.php | 25 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/ListingController.php b/app/Http/Controllers/ListingController.php index 5f8ee48..bb7f0f9 100644 --- a/app/Http/Controllers/ListingController.php +++ b/app/Http/Controllers/ListingController.php @@ -19,7 +19,11 @@ class ListingController extends Controller */ public function index(ListingIndexRequest $request): Response { - $query = Listing::query()->live(); + // Reuse the same filtering logic as the main listings search (see the + // `filter()` scope on Listing), so saved searches match the same criteria. + $query = Listing::query()->live()->filter( + $request->only('property_type', 'max_price', 'min_bedrooms', 'region') + ); if ($request->filled('property_type')) { $query->where('property_type', $request->string('property_type')); diff --git a/app/Models/Listing.php b/app/Models/Listing.php index 45efad3..01144fa 100644 --- a/app/Models/Listing.php +++ b/app/Models/Listing.php @@ -82,4 +82,29 @@ public function scopeLive(Builder $query): void { $query->where('status', ListingStatus::Live); } + + /** + * @param array $filters + */ + public function scopeFilter(Builder $query, array $filters): Builder + { + if (! empty($filters['property_type'] ?? null)) { + $query->where('property_type', $filters['property_type']); + } + + if (! empty($filters['max_price'] ?? null)) { + $query->where('price', '<=', $filters['max_price']); + } + + if (! empty($filters['min_bedrooms'] ?? null)) { + $query->where('bedrooms', '>=', $filters['min_bedrooms']); + } + + if (! empty($filters['region'] ?? null)) { + $region = $filters['region']; + $query->whereHas('branch', fn ($branchQuery) => $branchQuery->where('region', $region)); + } + + return $query; + } } From c80563384c9ef6a599d750eb14d85987f45155ab Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 31 Aug 2026 20:08:26 +0100 Subject: [PATCH 07/11] Adding in new routes, controller, request and resource --- .../Controllers/SavedSearchController.php | 59 +++++++++++++++++++ app/Http/Requests/SavedSearchStoreRequest.php | 29 +++++++++ app/Http/Resources/SavedSearchResource.php | 24 ++++++++ app/Models/User.php | 7 +++ routes/web.php | 10 ++++ 5 files changed, 129 insertions(+) create mode 100755 app/Http/Controllers/SavedSearchController.php create mode 100755 app/Http/Requests/SavedSearchStoreRequest.php create mode 100755 app/Http/Resources/SavedSearchResource.php diff --git a/app/Http/Controllers/SavedSearchController.php b/app/Http/Controllers/SavedSearchController.php new file mode 100755 index 0000000..e4c363a --- /dev/null +++ b/app/Http/Controllers/SavedSearchController.php @@ -0,0 +1,59 @@ +only('property_type', 'max_price', 'min_bedrooms', 'region'); + + $listings = Listing::query() + ->live() + ->filter($filters) + ->latest('listed_at') + ->orderByDesc('id') + ->paginate($request->integer('per_page', 15)) + ->withQueryString(); + + return Inertia::render('SavedSearches/Index', [ + 'listings' => ListingResource::collection($listings), + 'branches' => BranchResource::collection(Branch::query()->orderBy('name')->get()), + 'propertyTypes' => PropertyType::options(), + 'filters' => $filters, + 'savedSearches' => SavedSearchResource::collection( + $request->user()->savedSearches()->latest()->get() + ), + ]); + } + + public function store(SavedSearchStoreRequest $request): RedirectResponse + { + $request->user()->savedSearches()->create($request->validated()); + + return back(); + } + + public function destroy(SavedSearch $savedSearch): RedirectResponse + { + abort_unless($savedSearch->user_id === request()->user()->id, 403); + + $savedSearch->delete(); + + return back(); + } +} diff --git a/app/Http/Requests/SavedSearchStoreRequest.php b/app/Http/Requests/SavedSearchStoreRequest.php new file mode 100755 index 0000000..eb92e53 --- /dev/null +++ b/app/Http/Requests/SavedSearchStoreRequest.php @@ -0,0 +1,29 @@ + + */ + public function rules(): array + { + return [ + 'property_type' => ['nullable', new Enum(PropertyType::class)], + 'max_price' => ['nullable', 'integer', 'min:0'], + 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], + 'region' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Resources/SavedSearchResource.php b/app/Http/Resources/SavedSearchResource.php new file mode 100755 index 0000000..5284fa4 --- /dev/null +++ b/app/Http/Resources/SavedSearchResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'property_type' => $this->property_type, + 'max_price' => $this->max_price, + 'min_bedrooms' => $this->min_bedrooms, + 'region' => $this->region, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..b43bcda 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,11 +3,13 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\SavedSearch; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; #[Fillable(['name', 'email', 'password'])] @@ -17,6 +19,11 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory, Notifiable; + public function savedSearches(): HasMany + { + return $this->hasMany(SavedSearch::class); + } + /** * Get the attributes that should be cast. * diff --git a/routes/web.php b/routes/web.php index ce04c19..2e75722 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,5 +3,15 @@ use App\Http\Controllers\ListingController; use Illuminate\Support\Facades\Route; +/* + * In the real world, I would split these out into their own files + */ +// listings Route::get('/', [ListingController::class, 'index'])->name('listings.index'); Route::get('/listings/{listing}', [ListingController::class, 'show'])->name('listings.show'); + +// saved searches +Route::get('/saved-searches', [SavedSearchController::class, 'index'])->name('saved-searches.index'); +Route::post('/saved-searches', [SavedSearchController::class, 'store'])->name('saved-searches.store'); +Route::delete('/saved-searches/{savedSearch}', [SavedSearchController::class, 'destroy'])->name('saved-searches.destroy'); + From 345df3a89c7116781459f881cf918d9695239a82 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 31 Aug 2026 21:00:56 +0100 Subject: [PATCH 08/11] Added saved search view --- resources/js/components/AppLayout.vue | 30 +++- resources/js/components/useListingSearch.js | 39 +++++ resources/js/composables/useListingSearch.js | 39 +++++ resources/js/pages/Listings/Index.vue | 54 +++---- resources/js/pages/SavedSearches/Index.vue | 144 +++++++++++++++++++ routes/web.php | 1 + 6 files changed, 270 insertions(+), 37 deletions(-) create mode 100755 resources/js/components/useListingSearch.js create mode 100755 resources/js/composables/useListingSearch.js create mode 100755 resources/js/pages/SavedSearches/Index.vue diff --git a/resources/js/components/AppLayout.vue b/resources/js/components/AppLayout.vue index a0625e7..7199427 100644 --- a/resources/js/components/AppLayout.vue +++ b/resources/js/components/AppLayout.vue @@ -10,12 +10,30 @@ defineProps({