Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Remove watchdog (unused) and tqdm from pyproject.toml dependencies
- Replace tqdm.gather() with asyncio.gather() + logger.info() in
image_fetcher, floorplan_detector, and route_calculator services
- Replace tqdm progress bar with logger.info() in listing_repository
- Remove tqdm from mypy ignore_missing_imports overrides
Backend: include first 5 photo URLs from additional_info in GeoJSON
streaming response, with fallback to photo_thumbnail.
Frontend: replace single thumbnail with swipeable embla-carousel on
compact cards. Remove window.open on card tap so clicking opens the
detail bottom sheet instead of navigating to Rightmove.
- ListingDecision model with unique constraint on (user_id, listing_id, listing_type)
- Alembic migration for listingdecision table
- DecisionRepository with dialect-aware upsert (MySQL/SQLite)
- DecisionService with input validation
- Decision API routes: PUT/GET/DELETE on /api/decisions
- GET /api/listing/{id}/detail endpoint extracting full property info from additional_info
- Add listing ID to GeoJSON feature properties
- Decision filtering on GeoJSON stream endpoint (decision_filter param)
- Replace deprecated datetime.utcnow() with datetime.now(UTC) in model
and repository
- Add listing_type validation to decision_service (RENT/BUY only)
- Fix decision filtering tests failing due to rate limiting by patching
_match_endpoint
- Add SwipeCard component test suite (11 tests covering rendering,
interactions, and POI distances)
- Add test for invalid listing_type validation
Add last_updated timestamp to /api/status endpoint by querying
MAX(last_seen) across both listing tables. Display it in the
HealthIndicator as relative time (e.g. "2h ago") with full
date/time in the tooltip on hover.
- Fix silent log loss: replace hardcoded "uvicorn.error" logger with __name__
in osrm_client, otp_client, poi_distance_calculator, and poi_tasks (uvicorn
logger has no handlers in Celery worker, so all errors were silently dropped)
- Add Celery retry: autoretry_for=(Exception,), max_retries=3, retry_backoff
- Add top-level exception handling in task with full traceback logging
- Fix upsert_distances: replace session.merge() (PK-based) with proper
dialect-aware INSERT ON DUPLICATE KEY UPDATE / ON CONFLICT DO UPDATE
- Filter out listings with null/zero coordinates before routing
- Raise OSError when all routing engines fail with 0 results computed,
distinguishing "nothing to compute" from "all engines unreachable"
The distance calculator always queried the rentlisting table regardless of
listing type because get_listings() defaulted to RentListing when called
without query_parameters. Added a listing_type parameter to get_listings()
and _get_model_for_query() so callers can select the correct table directly.
Simplify the filter UI to show only essential filters (type toggle, price/bedroom
range sliders, min size) by default, with advanced filters collapsed. Extract
visualization controls (color-by metric, POI travel mode) into a separate
VisualizationCard component. Wire up previously ignored backend filters: max_sqm,
min/max_price_per_sqm, and district_names now work end-to-end.
POIRepository handles all database operations for POIs and distances
including upsert, cascading delete, and skip-on-recompute via
get_existing_distance_keys(). POI service provides unified high-level
functions shared by both CLI and API.
The listing processor was hardcoded to create RentListing objects and
query only the rentlisting table. Buy listings fetched from Rightmove
were stored in the wrong table with missing fields. This threads
ListingType through ListingProcessor and all Step subclasses so the
correct model (RentListing/BuyListing) is created, the correct table
is queried, and buy-specific fields (service_charge, lease_left) are
parsed from the API response and included in GeoJSON streaming output.
The crawler subdirectory was the only active project. Moving it to the
repo root simplifies paths and removes the unnecessary nesting. The
vqa/ and immoweb/ directories were legacy/unused and have been removed.
Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect
the new flat structure.