TidyLinker.com
TypeScript
Pull Request Opened
PR #132 opened: feat: server-side cleaner search + simplified cleaner job feed
Summary
Two pieces of stakeholder-driven work landed together because they share infrastructure (HasLocation::rankByDistance):
- Cleaner search overhaul + visibility backfill. Search was returning a single cleaner — confirmed via prod tinker that 16 of 18 onboarded cleaners had
visibility=false(historical default). Backfill migration flips them on; onboarding completion now defaultsvisibility=truegoing forward. While in here: filtering and sorting moved server-side with proper pagination, ratings now reflect real published reviews instead of hardcoded 0, and cleaners with no coordinates are no longer silently dropped when a location is supplied. - Cleaner-side job feed simplification. Dropped the
quote_statusdropdown (low signal). Kept the service filter. Jobs are now ranked by distance instead of filtered by it — same fix as the cleaner search side. Added a banner prompting cleaners with no postcode to set one.
Also bundled: a stakeholder-facing proposal doc (docs/plans/job-sharing-proposal.md) for the owner-controlled job sharing feature we discussed in lieu of the full removal in #131.
Why now
- Search was effectively broken on prod (1 cleaner visible).
- Filters were a mix of fake (always-true
available), redundant (verification toggles whensearchable()already gates on verification), and noisy. - The
withDistanceFromscope'swhereNotNullmade a class of bugs invisible — cleaners and jobs without coordinates just disappeared from search results.
Key technical notes
HasLocation::rankByDistanceorders by distance ASC with NULLs last instead of filtering them out. The existingwithDistanceFromis kept for genuine radius-cutoff cases (e.g. job-match notifications).CleanerProfile::withRatingStatsuses Eloquent's nativewithAvg/withCountrather than a customselectRawsubquery. Why:selectRawsubqueries with bindings break underpaginate()'s count-clone — bindings get misaligned.withAvg/withCountsurvive paginate correctly.- SQLite
cast(? as real)on the min_rating filter — without explicit casting on both sides, SQLite's PDO type coercion silently fails the>=comparison when the bound value arrives as a PHP float. Caught by the test suite, documented inline. - DTO drops
available— was always-true, used to disable a "Book Now" button that was never disabled in practice. Cleaner-card simplified accordingly.
Test plan
-
php artisan testagainstCleaner/,Listeners/,Admin/,Client/suites — 370 passing - New tests cover: pagination shape, min_rating filter, max_rate filter, sort, NULL-coordinate handling, visibility default on onboarding completion
-
vendor/bin/pint --dirtyclean -
npm run format && npm run lintclean - TypeScript check clean (only existing tsconfig deprecation warning)
- Manual QA on staging: confirm search now returns multiple cleaners, filters update via URL, pagination works, cleaner job feed surfaces postcode banner when missing
Migration
The visibility backfill (2026_04_28_132636_backfill_cleaner_profile_visibility) flips visibility to true for any cleaner with onboarding_completed_at IS NOT NULL and visibility = false. On prod that's 16 records. The migration has no down — flipping back would also stomp legitimately visibility=true rows.
If you know of any cleaner who is intentionally hidden (paused, in dispute, etc.), flag now so I can add an exclusion before this lands.
Out of scope
- The job-sharing proposal doc is review/discussion material only — no code changes for that.
- Real-rating sort behaviour with mostly-zero-review cleaners may want a tiebreaker tweak once reviews start coming in.
+836
additions
-483
deletions
17
files changed