From 3384798b34cde83a8c6d0770299ff36e8770d68f Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 21 Feb 2026 13:46:19 +0000 Subject: [PATCH] docs: add property decisions Phase 1 implementation plan 16-task TDD plan covering backend (model, migration, repo, service, API routes, decision filtering) and frontend (decision service, hook, SwipeCard, SwipeReviewMode, SavedView, integration). --- .../2026-02-21-property-decisions-plan.md | 1611 +++++++++++++++++ 1 file changed, 1611 insertions(+) create mode 100644 docs/plans/2026-02-21-property-decisions-plan.md diff --git a/docs/plans/2026-02-21-property-decisions-plan.md b/docs/plans/2026-02-21-property-decisions-plan.md new file mode 100644 index 0000000..61b51c4 --- /dev/null +++ b/docs/plans/2026-02-21-property-decisions-plan.md @@ -0,0 +1,1611 @@ +# Property Decisions (Phase 1) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a Tinder-style swipe card review mode where users can like/dislike properties, with decisions persisted to the database and disliked properties filtered from views. + +**Architecture:** New `ListingDecision` SQLModel entity with repository + service + API route layers (following the existing POI pattern). Frontend adds a `SwipeReviewMode` component with react-spring animations, a `decisionService` API client, and a decision state context. The existing GeoJSON stream endpoint gains a `decision_filter` parameter. + +**Tech Stack:** Python/FastAPI/SQLModel (backend), React 19/TypeScript/react-spring/@use-gesture/react/Tailwind (frontend), Alembic (migration), vitest (frontend tests), pytest (backend tests). + +--- + +### Task 1: Create ListingDecision Model + +**Files:** +- Create: `models/decision.py` + +**Step 1: Write the model** + +```python +from datetime import datetime +from sqlmodel import SQLModel, Field + + +class ListingDecision(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(nullable=False, foreign_key="user.id", index=True) + listing_id: int = Field(nullable=False, index=True) + listing_type: str = Field(nullable=False) # "RENT" or "BUY" + decision: str = Field(nullable=False) # "liked" or "disliked" + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) +``` + +**Step 2: Commit** + +```bash +git add models/decision.py +git commit -m "feat: add ListingDecision model" +``` + +--- + +### Task 2: Create Alembic Migration + +**Files:** +- Create: `alembic/versions/d6e7f8a9b0c1_add_listing_decision_table.py` + +**Step 1: Generate migration** + +Run: `alembic revision -m "add listing decision table"` then edit the generated file. + +**Step 2: Write the migration** + +The migration should create table `listingdecision` with columns matching the model, a unique constraint on `(user_id, listing_id, listing_type)`, and indexes on `user_id` and `listing_id`. + +```python +def upgrade() -> None: + op.create_table('listingdecision', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('listing_id', sa.Integer(), nullable=False), + sa.Column('listing_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('decision', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['user.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'listing_id', 'listing_type', + name='uq_decision_user_listing'), + ) + op.create_index(op.f('ix_listingdecision_user_id'), 'listingdecision', ['user_id']) + op.create_index(op.f('ix_listingdecision_listing_id'), 'listingdecision', ['listing_id']) + +def downgrade() -> None: + op.drop_index(op.f('ix_listingdecision_listing_id'), table_name='listingdecision') + op.drop_index(op.f('ix_listingdecision_user_id'), table_name='listingdecision') + op.drop_table('listingdecision') +``` + +**Step 3: Verify migration applies** + +Run: `alembic upgrade head` +Expected: No errors, table created. + +**Step 4: Commit** + +```bash +git add alembic/versions/d6e7f8a9b0c1_add_listing_decision_table.py +git commit -m "feat: add listing_decision migration" +``` + +--- + +### Task 3: Create DecisionRepository + +**Files:** +- Create: `repositories/decision_repository.py` +- Test: `tests/unit/test_decision_repository.py` + +**Step 1: Write the failing tests** + +```python +"""Unit tests for DecisionRepository.""" +import pytest +from datetime import datetime +from sqlalchemy import Engine +from sqlmodel import SQLModel, Session, create_engine + +from models.decision import ListingDecision +from models.user import User +from repositories.decision_repository import DecisionRepository + + +@pytest.fixture +def decision_engine() -> Engine: + engine = create_engine("sqlite:///:memory:", echo=False, + connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + # Seed a user + with Session(engine) as session: + session.add(User(id=1, email="test@example.com")) + session.commit() + yield engine + SQLModel.metadata.drop_all(engine) + + +@pytest.fixture +def repo(decision_engine: Engine) -> DecisionRepository: + return DecisionRepository(decision_engine) + + +class TestDecisionRepository: + def test_upsert_creates_new_decision(self, repo: DecisionRepository) -> None: + result = repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked") + assert result.decision == "liked" + assert result.listing_id == 100 + + def test_upsert_updates_existing_decision(self, repo: DecisionRepository) -> None: + repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked") + result = repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="disliked") + assert result.decision == "disliked" + # Should still be one record + all_decisions = repo.get_decisions_for_user(1) + assert len(all_decisions) == 1 + + def test_get_decisions_for_user(self, repo: DecisionRepository) -> None: + repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked") + repo.upsert_decision(user_id=1, listing_id=200, listing_type="RENT", decision="disliked") + decisions = repo.get_decisions_for_user(1) + assert len(decisions) == 2 + + def test_delete_decision(self, repo: DecisionRepository) -> None: + repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked") + deleted = repo.delete_decision(user_id=1, listing_id=100, listing_type="RENT") + assert deleted is True + assert len(repo.get_decisions_for_user(1)) == 0 + + def test_delete_nonexistent_decision(self, repo: DecisionRepository) -> None: + deleted = repo.delete_decision(user_id=1, listing_id=999, listing_type="RENT") + assert deleted is False + + def test_get_disliked_listing_ids(self, repo: DecisionRepository) -> None: + repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked") + repo.upsert_decision(user_id=1, listing_id=200, listing_type="RENT", decision="disliked") + repo.upsert_decision(user_id=1, listing_id=300, listing_type="RENT", decision="disliked") + disliked = repo.get_disliked_listing_ids(user_id=1, listing_type="RENT") + assert disliked == {200, 300} +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/test_decision_repository.py -v` +Expected: FAIL — `ImportError: cannot import 'DecisionRepository'` + +**Step 3: Write the repository** + +```python +"""Repository for listing decisions (like/dislike).""" +from datetime import datetime + +from models.decision import ListingDecision +from sqlalchemy import Engine +from sqlmodel import Session, select + + +class DecisionRepository: + engine: Engine + + def __init__(self, engine: Engine) -> None: + self.engine = engine + + def upsert_decision( + self, user_id: int, listing_id: int, listing_type: str, decision: str + ) -> ListingDecision: + with Session(self.engine) as session: + statement = select(ListingDecision).where( + ListingDecision.user_id == user_id, + ListingDecision.listing_id == listing_id, + ListingDecision.listing_type == listing_type, + ) + existing = session.exec(statement).first() + if existing: + existing.decision = decision + existing.updated_at = datetime.utcnow() + session.add(existing) + session.commit() + session.refresh(existing) + return existing + new_decision = ListingDecision( + user_id=user_id, + listing_id=listing_id, + listing_type=listing_type, + decision=decision, + ) + session.add(new_decision) + session.commit() + session.refresh(new_decision) + return new_decision + + def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]: + with Session(self.engine) as session: + statement = select(ListingDecision).where( + ListingDecision.user_id == user_id + ) + return list(session.exec(statement).all()) + + def delete_decision( + self, user_id: int, listing_id: int, listing_type: str + ) -> bool: + with Session(self.engine) as session: + statement = select(ListingDecision).where( + ListingDecision.user_id == user_id, + ListingDecision.listing_id == listing_id, + ListingDecision.listing_type == listing_type, + ) + existing = session.exec(statement).first() + if existing is None: + return False + session.delete(existing) + session.commit() + return True + + def get_disliked_listing_ids( + self, user_id: int, listing_type: str + ) -> set[int]: + with Session(self.engine) as session: + statement = select(ListingDecision.listing_id).where( + ListingDecision.user_id == user_id, + ListingDecision.listing_type == listing_type, + ListingDecision.decision == "disliked", + ) + return {row for row in session.exec(statement).all()} +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/test_decision_repository.py -v` +Expected: All 6 tests PASS. + +**Step 5: Commit** + +```bash +git add repositories/decision_repository.py tests/unit/test_decision_repository.py +git commit -m "feat: add DecisionRepository with tests" +``` + +--- + +### Task 4: Create Decision Service + +**Files:** +- Create: `services/decision_service.py` +- Test: `tests/unit/test_decision_service.py` + +**Step 1: Write the failing tests** + +```python +"""Unit tests for decision_service.""" +from unittest.mock import MagicMock +import pytest + +from models.decision import ListingDecision +from services import decision_service + + +class TestSetDecision: + def test_set_liked(self) -> None: + repo = MagicMock() + repo.upsert_decision.return_value = ListingDecision( + id=1, user_id=1, listing_id=100, listing_type="RENT", decision="liked" + ) + result = decision_service.set_decision(repo, user_id=1, listing_id=100, + listing_type="RENT", decision="liked") + assert result.decision == "liked" + repo.upsert_decision.assert_called_once_with( + user_id=1, listing_id=100, listing_type="RENT", decision="liked" + ) + + def test_invalid_decision_raises(self) -> None: + repo = MagicMock() + with pytest.raises(ValueError, match="Invalid decision"): + decision_service.set_decision(repo, user_id=1, listing_id=100, + listing_type="RENT", decision="maybe") + + +class TestGetDecisions: + def test_returns_all_decisions(self) -> None: + repo = MagicMock() + repo.get_decisions_for_user.return_value = [ + ListingDecision(id=1, user_id=1, listing_id=100, listing_type="RENT", decision="liked"), + ] + result = decision_service.get_decisions(repo, user_id=1) + assert len(result) == 1 + + +class TestClearDecision: + def test_clear_existing(self) -> None: + repo = MagicMock() + repo.delete_decision.return_value = True + result = decision_service.clear_decision(repo, user_id=1, listing_id=100, listing_type="RENT") + assert result is True + + def test_clear_nonexistent(self) -> None: + repo = MagicMock() + repo.delete_decision.return_value = False + result = decision_service.clear_decision(repo, user_id=1, listing_id=100, listing_type="RENT") + assert result is False +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/test_decision_service.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Write the service** + +```python +"""Unified decision service — shared between CLI and HTTP API.""" +from models.decision import ListingDecision +from repositories.decision_repository import DecisionRepository + +VALID_DECISIONS = {"liked", "disliked"} + + +def set_decision( + repository: DecisionRepository, + user_id: int, + listing_id: int, + listing_type: str, + decision: str, +) -> ListingDecision: + if decision not in VALID_DECISIONS: + raise ValueError(f"Invalid decision '{decision}'. Must be one of: {VALID_DECISIONS}") + return repository.upsert_decision( + user_id=user_id, + listing_id=listing_id, + listing_type=listing_type, + decision=decision, + ) + + +def get_decisions( + repository: DecisionRepository, + user_id: int, +) -> list[ListingDecision]: + return repository.get_decisions_for_user(user_id) + + +def clear_decision( + repository: DecisionRepository, + user_id: int, + listing_id: int, + listing_type: str, +) -> bool: + return repository.delete_decision( + user_id=user_id, + listing_id=listing_id, + listing_type=listing_type, + ) + + +def get_disliked_listing_ids( + repository: DecisionRepository, + user_id: int, + listing_type: str, +) -> set[int]: + return repository.get_disliked_listing_ids(user_id=user_id, listing_type=listing_type) +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/test_decision_service.py -v` +Expected: All 4 tests PASS. + +**Step 5: Commit** + +```bash +git add services/decision_service.py tests/unit/test_decision_service.py +git commit -m "feat: add decision_service with tests" +``` + +--- + +### Task 5: Create Decision API Routes + +**Files:** +- Create: `api/decision_routes.py` +- Modify: `api/app.py` (add `app.include_router(decision_router)`) +- Test: `tests/unit/test_decision_routes.py` + +**Step 1: Write the failing tests** + +```python +"""Unit tests for decision API routes.""" +import pytest +from httpx import ASGITransport, AsyncClient +from sqlmodel import SQLModel, Session, create_engine + +from models.user import User +from api.auth import get_current_user, User as AuthUser + + +@pytest.fixture +def decision_engine(): + engine = create_engine("sqlite:///:memory:", echo=False, + connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add(User(id=1, email="test@example.com")) + session.commit() + yield engine + SQLModel.metadata.drop_all(engine) + + +@pytest.fixture +async def client(decision_engine): + import database + import api.app as api_app + + app = api_app.app + mock_user = AuthUser(sub="test-user-id", email="test@example.com", name="Test User") + app.dependency_overrides[get_current_user] = lambda: mock_user + + original_db = database.engine + original_app = api_app.engine + database.engine = decision_engine + api_app.engine = decision_engine + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + database.engine = original_db + api_app.engine = original_app + app.dependency_overrides.clear() + + +class TestDecisionRoutes: + @pytest.mark.asyncio + async def test_set_decision(self, client: AsyncClient) -> None: + resp = await client.put("/api/decisions/100", json={ + "decision": "liked", "listing_type": "RENT" + }) + assert resp.status_code == 200 + data = resp.json() + assert data["decision"] == "liked" + assert data["listing_id"] == 100 + + @pytest.mark.asyncio + async def test_get_decisions(self, client: AsyncClient) -> None: + await client.put("/api/decisions/100", json={"decision": "liked", "listing_type": "RENT"}) + resp = await client.get("/api/decisions") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + + @pytest.mark.asyncio + async def test_delete_decision(self, client: AsyncClient) -> None: + await client.put("/api/decisions/100", json={"decision": "liked", "listing_type": "RENT"}) + resp = await client.delete("/api/decisions/100", params={"listing_type": "RENT"}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_nonexistent_returns_404(self, client: AsyncClient) -> None: + resp = await client.delete("/api/decisions/999", params={"listing_type": "RENT"}) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_invalid_decision_returns_400(self, client: AsyncClient) -> None: + resp = await client.put("/api/decisions/100", json={ + "decision": "maybe", "listing_type": "RENT" + }) + assert resp.status_code == 400 +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/test_decision_routes.py -v` +Expected: FAIL — `ImportError` + +**Step 3: Write the API routes** + +Create `api/decision_routes.py`: + +```python +"""API routes for listing decisions (like/dislike).""" +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from api.auth import User, get_current_user +from database import engine +from repositories.decision_repository import DecisionRepository +from repositories.user_repository import UserRepository +from services import decision_service + +logger = logging.getLogger("uvicorn") + +decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"]) + + +class SetDecisionRequest(BaseModel): + decision: str = Field(description="'liked' or 'disliked'") + listing_type: str = Field(default="RENT", description="'RENT' or 'BUY'") + + +class DecisionResponse(BaseModel): + id: int + listing_id: int + listing_type: str + decision: str + created_at: str + updated_at: str + + +def _get_user_id(user: User) -> int: + user_repo = UserRepository(engine) + db_user = user_repo.get_user_by_email(user.email) + if db_user is None: + db_user = user_repo.create_user(user.email) + if db_user.id is None: + raise HTTPException(status_code=500, detail="Failed to create user") + return db_user.id + + +@decision_router.put("/{listing_id}", response_model=DecisionResponse) +async def set_decision( + user: Annotated[User, Depends(get_current_user)], + listing_id: int, + body: SetDecisionRequest, +) -> DecisionResponse: + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + try: + result = decision_service.set_decision( + repo, user_id=user_id, listing_id=listing_id, + listing_type=body.listing_type, decision=body.decision, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return DecisionResponse( + id=result.id, # type: ignore[arg-type] + listing_id=result.listing_id, + listing_type=result.listing_type, + decision=result.decision, + created_at=result.created_at.isoformat(), + updated_at=result.updated_at.isoformat(), + ) + + +@decision_router.get("", response_model=list[DecisionResponse]) +async def get_decisions( + user: Annotated[User, Depends(get_current_user)], +) -> list[DecisionResponse]: + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + decisions = decision_service.get_decisions(repo, user_id=user_id) + return [ + DecisionResponse( + id=d.id, # type: ignore[arg-type] + listing_id=d.listing_id, + listing_type=d.listing_type, + decision=d.decision, + created_at=d.created_at.isoformat(), + updated_at=d.updated_at.isoformat(), + ) + for d in decisions + ] + + +@decision_router.delete("/{listing_id}") +async def delete_decision( + user: Annotated[User, Depends(get_current_user)], + listing_id: int, + listing_type: str = "RENT", +) -> dict[str, bool]: + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + deleted = decision_service.clear_decision( + repo, user_id=user_id, listing_id=listing_id, listing_type=listing_type, + ) + if not deleted: + raise HTTPException(status_code=404, detail="Decision not found") + return {"success": True} +``` + +**Step 4: Register router in `api/app.py`** + +Add after existing router imports: + +```python +from api.decision_routes import decision_router +``` + +And after existing `app.include_router(...)` calls: + +```python +app.include_router(decision_router) +``` + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/unit/test_decision_routes.py -v` +Expected: All 5 tests PASS. + +**Step 6: Commit** + +```bash +git add api/decision_routes.py api/app.py tests/unit/test_decision_routes.py +git commit -m "feat: add decision API routes with tests" +``` + +--- + +### Task 6: Add Decision Filtering to GeoJSON Stream + +**Files:** +- Modify: `api/app.py` — add `decision_filter` param to `stream_listing_geojson`, filter disliked IDs from streamed features + +**Step 1: Write the failing test** + +Add to `tests/integration/test_api.py` or a new file `tests/unit/test_decision_filtering.py`: + +```python +"""Test that disliked listings are filtered from the GeoJSON stream.""" +import pytest +from httpx import ASGITransport, AsyncClient +from sqlmodel import SQLModel, Session, create_engine +from datetime import datetime + +from models.user import User +from models.listing import RentListing, ListingSite, FurnishType +from models.decision import ListingDecision +from api.auth import get_current_user, User as AuthUser + + +@pytest.fixture +def filter_engine(): + engine = create_engine("sqlite:///:memory:", echo=False, + connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add(User(id=1, email="test@example.com")) + # Add two listings + for lid in [100, 200]: + session.add(RentListing( + id=lid, price=2000.0, number_of_bedrooms=2, square_meters=50.0, + longitude=-0.1, latitude=51.5, price_history_json="[]", + listing_site=ListingSite.RIGHTMOVE, last_seen=datetime.now(), + floorplan_image_paths=[], additional_info={}, + furnish_type=FurnishType.FURNISHED, + )) + # Dislike listing 200 + session.add(ListingDecision( + user_id=1, listing_id=200, listing_type="RENT", decision="disliked" + )) + session.commit() + yield engine + SQLModel.metadata.drop_all(engine) + + +@pytest.fixture +async def filter_client(filter_engine): + import database + import api.app as api_app + + app = api_app.app + mock_user = AuthUser(sub="test", email="test@example.com", name="Test") + app.dependency_overrides[get_current_user] = lambda: mock_user + + original_db = database.engine + original_app = api_app.engine + database.engine = filter_engine + api_app.engine = filter_engine + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + database.engine = original_db + api_app.engine = original_app + app.dependency_overrides.clear() + + +class TestDecisionFiltering: + @pytest.mark.asyncio + async def test_disliked_excluded_by_default(self, filter_client: AsyncClient) -> None: + """Default decision_filter='all' should exclude disliked listings.""" + resp = await filter_client.get("/api/listing_geojson", params={ + "listing_type": "RENT" + }) + assert resp.status_code == 200 + data = resp.json() + urls = [f["properties"]["url"] for f in data["features"]] + assert any("100" in u for u in urls) + assert not any("200" in u for u in urls) +``` + +**Step 2: Implement the filtering** + +In `api/app.py`, modify `get_listing_geojson` and the streaming endpoints to accept `decision_filter` query param and exclude disliked listings. The simplest approach: after fetching listings, filter out IDs that are in the user's disliked set. + +Add to `api/app.py`: + +```python +from repositories.decision_repository import DecisionRepository +``` + +In `get_listing_geojson`, after getting the result, filter out disliked listing IDs: + +```python +# Filter out disliked listings +user_repo = UserRepository(engine) +db_user = user_repo.get_user_by_email(user.email) +if db_user and db_user.id is not None: + decision_repo = DecisionRepository(engine) + disliked_ids = decision_service.get_disliked_listing_ids( + decision_repo, user_id=db_user.id, listing_type=query_parameters.listing_type.value + ) + if disliked_ids: + result.data["features"] = [ + f for f in result.data["features"] + if f.get("properties", {}).get("url", "").split("/")[-1] not in + {str(lid) for lid in disliked_ids} + ] +``` + +For the streaming endpoint, similarly filter each batch before yielding. + +**Step 3: Run tests** + +Run: `pytest tests/unit/test_decision_filtering.py -v` +Expected: PASS. + +**Step 4: Commit** + +```bash +git add api/app.py tests/unit/test_decision_filtering.py +git commit -m "feat: filter disliked listings from GeoJSON endpoints" +``` + +--- + +### Task 7: Install Frontend Dependencies + +**Files:** +- Modify: `frontend/package.json` + +**Step 1: Install react-spring and use-gesture** + +Run from `frontend/` directory: + +```bash +cd frontend && npm install @react-spring/web @use-gesture/react +``` + +**Step 2: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "feat: add react-spring and use-gesture dependencies" +``` + +--- + +### Task 8: Create Frontend Decision Service + +**Files:** +- Create: `frontend/src/services/decisionService.ts` +- Modify: `frontend/src/services/index.ts` (re-export) +- Create: `frontend/src/types/decisions.ts` or add to `frontend/src/types/index.ts` + +**Step 1: Add types** + +In `frontend/src/types/index.ts`, add: + +```typescript +export type DecisionType = 'liked' | 'disliked'; + +export interface ListingDecision { + id: number; + listing_id: number; + listing_type: 'RENT' | 'BUY'; + decision: DecisionType; + created_at: string; + updated_at: string; +} +``` + +**Step 2: Create the service** + +```typescript +// Decision API service for managing listing decisions (like/dislike) + +import type { AuthUser } from '@/auth/types'; +import type { ListingDecision } from '@/types'; +import { apiRequest } from './apiClient'; + +export async function fetchDecisions(user: AuthUser): Promise { + return apiRequest(user, '/api/decisions'); +} + +export async function setDecision( + user: AuthUser, + listingId: number, + decision: 'liked' | 'disliked', + listingType: 'RENT' | 'BUY' = 'RENT', +): Promise { + return apiRequest(user, `/api/decisions/${listingId}`, { + method: 'PUT', + body: { decision, listing_type: listingType }, + }); +} + +export async function clearDecision( + user: AuthUser, + listingId: number, + listingType: 'RENT' | 'BUY' = 'RENT', +): Promise { + await apiRequest(user, `/api/decisions/${listingId}`, { + method: 'DELETE', + params: { listing_type: listingType }, + }); +} +``` + +**Step 3: Re-export from `frontend/src/services/index.ts`** + +Add: + +```typescript +export { fetchDecisions, setDecision, clearDecision } from './decisionService'; +``` + +**Step 4: Commit** + +```bash +git add frontend/src/types/index.ts frontend/src/services/decisionService.ts frontend/src/services/index.ts +git commit -m "feat: add frontend decision service and types" +``` + +--- + +### Task 9: Create Decision Context (React State) + +**Files:** +- Create: `frontend/src/hooks/useDecisions.ts` + +**Step 1: Write the hook** + +This hook loads decisions on mount, provides a `Map` for O(1) lookups, and exposes optimistic `like`/`dislike`/`clear` functions. + +```typescript +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { AuthUser } from '@/auth/types'; +import type { DecisionType, ListingDecision } from '@/types'; +import { fetchDecisions, setDecision as apiSetDecision, clearDecision as apiClearDecision } from '@/services'; + +function decisionKey(listingId: number, listingType: string): string { + return `${listingId}-${listingType}`; +} + +export function useDecisions(user: AuthUser | null) { + const [decisions, setDecisions] = useState>(new Map()); + const [isLoaded, setIsLoaded] = useState(false); + + // Load decisions on mount + useEffect(() => { + if (!user) return; + fetchDecisions(user) + .then((list) => { + const map = new Map(); + for (const d of list) { + map.set(decisionKey(d.listing_id, d.listing_type), d.decision); + } + setDecisions(map); + setIsLoaded(true); + }) + .catch(() => setIsLoaded(true)); + }, [user]); + + const decide = useCallback( + async (listingId: number, decision: DecisionType, listingType: 'RENT' | 'BUY' = 'RENT') => { + if (!user) return; + const key = decisionKey(listingId, listingType); + + // Optimistic update + setDecisions((prev) => { + const next = new Map(prev); + next.set(key, decision); + return next; + }); + + try { + await apiSetDecision(user, listingId, decision, listingType); + } catch { + // Revert on failure + setDecisions((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + } + }, + [user], + ); + + const clear = useCallback( + async (listingId: number, listingType: 'RENT' | 'BUY' = 'RENT') => { + if (!user) return; + const key = decisionKey(listingId, listingType); + const previous = decisions.get(key); + + setDecisions((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + + try { + await apiClearDecision(user, listingId, listingType); + } catch { + if (previous) { + setDecisions((prev) => { + const next = new Map(prev); + next.set(key, previous); + return next; + }); + } + } + }, + [user, decisions], + ); + + const getDecision = useCallback( + (listingId: number, listingType: string = 'RENT'): DecisionType | undefined => { + return decisions.get(decisionKey(listingId, listingType)); + }, + [decisions], + ); + + const likedCount = useMemo( + () => Array.from(decisions.values()).filter((d) => d === 'liked').length, + [decisions], + ); + + const dislikedCount = useMemo( + () => Array.from(decisions.values()).filter((d) => d === 'disliked').length, + [decisions], + ); + + return { decisions, isLoaded, decide, clear, getDecision, likedCount, dislikedCount }; +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/hooks/useDecisions.ts +git commit -m "feat: add useDecisions hook with optimistic updates" +``` + +--- + +### Task 10: Create SwipeCard Component + +**Files:** +- Create: `frontend/src/components/SwipeCard.tsx` + +**Step 1: Write the swipe card component** + +This is the individual draggable card with spring physics. Uses `@react-spring/web` for animation and `@use-gesture/react` for drag detection. + +```typescript +import { useRef } from 'react'; +import { animated, useSpring } from '@react-spring/web'; +import { useDrag } from '@use-gesture/react'; +import { Bed, Maximize2, ExternalLink } from 'lucide-react'; +import type { PropertyFeature } from '@/types'; + +interface SwipeCardProps { + feature: PropertyFeature; + onSwipe: (direction: 'left' | 'right' | 'up') => void; + isTop: boolean; + stackIndex: number; // 0 = front, 1 = behind, 2 = behind-behind +} + +const SWIPE_THRESHOLD = 100; + +export function SwipeCard({ feature, onSwipe, isTop, stackIndex }: SwipeCardProps) { + const hasSwiped = useRef(false); + const p = feature.properties; + + const [style, api] = useSpring(() => ({ + x: 0, + y: 0, + rotate: 0, + scale: 1 - stackIndex * 0.05, + opacity: stackIndex <= 2 ? 1 : 0, + config: { tension: 300, friction: 25 }, + })); + + const bind = useDrag( + ({ active, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => { + if (!isTop || hasSwiped.current) return; + + if (!active) { + // Released: check if past threshold + const isSwipeRight = mx > SWIPE_THRESHOLD || (vx > 0.5 && dx > 0); + const isSwipeLeft = mx < -SWIPE_THRESHOLD || (vx > 0.5 && dx < 0); + const isSwipeUp = my < -SWIPE_THRESHOLD || (vy > 0.5 && dy < 0); + + if (isSwipeRight || isSwipeLeft || isSwipeUp) { + hasSwiped.current = true; + const direction = isSwipeRight ? 'right' : isSwipeLeft ? 'left' : 'up'; + const flyOutX = isSwipeRight ? 500 : isSwipeLeft ? -500 : 0; + const flyOutY = isSwipeUp ? -500 : 0; + api.start({ + x: flyOutX, + y: flyOutY, + rotate: isSwipeRight ? 15 : isSwipeLeft ? -15 : 0, + opacity: 0, + onRest: () => onSwipe(direction), + }); + } else { + // Snap back + api.start({ x: 0, y: 0, rotate: 0 }); + } + return; + } + + // Dragging + api.start({ + x: mx, + y: my, + rotate: mx / 20, + immediate: true, + }); + }, + { filterTaps: true, enabled: isTop }, + ); + + // Color overlay based on drag direction + const overlayOpacity = style.x.to((x) => Math.min(Math.abs(x) / 150, 0.4)); + const overlayColor = style.x.to((x) => (x > 0 ? 'rgba(34,197,94,VAR)' : 'rgba(239,68,68,VAR)').replace('VAR', '1')); + + return ( + +
+ {/* Color overlay */} + {isTop && ( + + )} + + {/* Photo */} +
+ {p.photo_thumbnail && ( + Property + )} + +
+ + {/* Details */} +
+
+ £{p.total_price.toLocaleString()} + {p.listing_type !== 'BUY' && ( + /mo + )} +
+ +
+ + {p.rooms} bed + + + {p.qm} m² + + £{p.qmprice}/m² +
+ + {/* POI distances */} + {p.poi_distances && p.poi_distances.length > 0 && ( +
+ {p.poi_distances.slice(0, 4).map((d) => ( + + {d.poi_name}: {Math.round(d.duration_seconds / 60)}m + + ))} +
+ )} +
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/SwipeCard.tsx +git commit -m "feat: add SwipeCard component with spring physics" +``` + +--- + +### Task 11: Create SwipeReviewMode Component + +**Files:** +- Create: `frontend/src/components/SwipeReviewMode.tsx` + +**Step 1: Write the review mode container** + +This wraps the card stack, action buttons, progress counter, undo, and keyboard shortcuts. + +```typescript +import { useState, useCallback, useEffect } from 'react'; +import { X, Heart, ArrowUp, Undo2, ArrowLeft } from 'lucide-react'; +import { Button } from './ui/button'; +import { SwipeCard } from './SwipeCard'; +import type { PropertyFeature, DecisionType } from '@/types'; + +interface SwipeReviewModeProps { + features: PropertyFeature[]; + onDecide: (listingId: number, decision: DecisionType, listingType?: 'RENT' | 'BUY') => void; + onClear: (listingId: number, listingType?: 'RENT' | 'BUY') => void; + onClose: () => void; + getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; +} + +interface HistoryEntry { + index: number; + listingId: number; + listingType: string; + action: 'liked' | 'disliked' | 'skipped'; +} + +function getListingId(feature: PropertyFeature): number { + // Extract listing ID from URL: https://www.rightmove.co.uk/properties/12345 + const parts = feature.properties.url.split('/'); + return parseInt(parts[parts.length - 1], 10); +} + +function getListingType(feature: PropertyFeature): 'RENT' | 'BUY' { + return feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; +} + +export function SwipeReviewMode({ + features, + onDecide, + onClear, + onClose, + getDecision, +}: SwipeReviewModeProps) { + // Filter to only undecided features + const undecided = features.filter((f) => { + const id = getListingId(f); + const type = getListingType(f); + return getDecision(id, type) === undefined; + }); + + const [currentIndex, setCurrentIndex] = useState(0); + const [history, setHistory] = useState([]); + + const handleSwipe = useCallback( + (direction: 'left' | 'right' | 'up') => { + const feature = undecided[currentIndex]; + if (!feature) return; + + const listingId = getListingId(feature); + const listingType = getListingType(feature); + + let action: HistoryEntry['action']; + if (direction === 'right') { + action = 'liked'; + onDecide(listingId, 'liked', listingType); + } else if (direction === 'left') { + action = 'disliked'; + onDecide(listingId, 'disliked', listingType); + } else { + action = 'skipped'; + } + + setHistory((prev) => [...prev, { index: currentIndex, listingId, listingType, action }]); + setCurrentIndex((prev) => prev + 1); + }, + [currentIndex, undecided, onDecide], + ); + + const handleUndo = useCallback(() => { + const lastEntry = history[history.length - 1]; + if (!lastEntry) return; + + if (lastEntry.action !== 'skipped') { + onClear(lastEntry.listingId, lastEntry.listingType as 'RENT' | 'BUY'); + } + setHistory((prev) => prev.slice(0, -1)); + setCurrentIndex((prev) => prev - 1); + }, [history, onClear]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') handleSwipe('right'); + else if (e.key === 'ArrowLeft') handleSwipe('left'); + else if (e.key === 'ArrowUp') handleSwipe('up'); + else if ((e.ctrlKey || e.metaKey) && e.key === 'z') handleUndo(); + else if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [handleSwipe, handleUndo, onClose]); + + const isFinished = currentIndex >= undecided.length; + + return ( +
+ {/* Header */} +
+ + + {Math.min(currentIndex + 1, undecided.length)} / {undecided.length} + +
{/* Spacer for centering */} +
+ + {/* Card stack */} +
+ {isFinished ? ( +
+
+
All done!
+

+ You've reviewed all {undecided.length} properties. +

+ +
+
+ ) : ( + <> + {undecided.slice(currentIndex, currentIndex + 3).map((feature, i) => ( + + ))} + + )} +
+ + {/* Action buttons */} + {!isFinished && ( +
+ + + + + + + +
+ )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/SwipeReviewMode.tsx +git commit -m "feat: add SwipeReviewMode with card stack and keyboard shortcuts" +``` + +--- + +### Task 12: Integrate SwipeReviewMode into App + +**Files:** +- Modify: `frontend/src/App.tsx` + +**Step 1: Wire up the review mode** + +Add to App.tsx: + +1. Import `useDecisions` hook and `SwipeReviewMode` component +2. Add `showReviewMode` state +3. Pass decisions context to filtering logic (client-side disliked filtering) +4. Add "Review" button to the StatsBar area (desktop) and as a FAB (mobile) +5. Render `` overlay when active + +Key additions: + +```typescript +import { useDecisions } from '@/hooks/useDecisions'; +import { SwipeReviewMode } from './components/SwipeReviewMode'; +``` + +State: +```typescript +const [showReviewMode, setShowReviewMode] = useState(false); +const { decide, clear, getDecision, likedCount } = useDecisions(user); +``` + +In the mobile layout, add a review FAB button next to the filter FAB. In desktop, add a "Review" button in the StatsBar. When `showReviewMode` is true, render the `` overlay. + +Client-side filtering: in `processedListingData`, also filter out features whose listing ID is in the disliked set (for instant feedback before server refresh). + +**Step 2: Commit** + +```bash +git add frontend/src/App.tsx +git commit -m "feat: integrate SwipeReviewMode into App with review button" +``` + +--- + +### Task 13: Add "Saved" Tab to StatsBar / Navigation + +**Files:** +- Modify: `frontend/src/components/StatsBar.tsx` — add `'saved'` to `ViewMode` +- Create: `frontend/src/components/SavedView.tsx` +- Modify: `frontend/src/App.tsx` — render SavedView when viewMode is 'saved' + +**Step 1: Add saved view mode** + +Update `ViewMode` type in `StatsBar.tsx`: + +```typescript +export type ViewMode = 'map' | 'list' | 'split' | 'saved'; +``` + +Add a "Saved" button in the view mode toggle: + +```typescript + +``` + +**Step 2: Create SavedView component** + +```typescript +import { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { PropertyCard } from './PropertyCard'; +import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types'; + +interface SavedViewProps { + listingData: GeoJSONFeatureCollection; + getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; +} + +function getListingId(feature: PropertyFeature): number { + const parts = feature.properties.url.split('/'); + return parseInt(parts[parts.length - 1], 10); +} + +export function SavedView({ listingData, getDecision }: SavedViewProps) { + const savedFeatures = useMemo(() => { + return listingData.features.filter((f) => { + const id = getListingId(f); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return getDecision(id, type) === 'liked'; + }); + }, [listingData, getDecision]); + + if (savedFeatures.length === 0) { + return ( +
+
+
No saved properties yet
+

+ Use the Review mode to swipe through properties and save ones you like. +

+
+
+ ); + } + + return ( +
+
+ {savedFeatures.length} saved properties +
+ ( +
+ +
+ )} + /> +
+ ); +} +``` + +**Step 3: Wire into App.tsx** + +In `renderMainContent()`, add a case for `viewMode === 'saved'`. + +**Step 4: Commit** + +```bash +git add frontend/src/components/StatsBar.tsx frontend/src/components/SavedView.tsx frontend/src/App.tsx +git commit -m "feat: add Saved view tab for viewing liked properties" +``` + +--- + +### Task 14: Add Frontend Tests + +**Files:** +- Create: `frontend/src/components/__tests__/SwipeCard.test.tsx` +- Create: `frontend/src/hooks/__tests__/useDecisions.test.ts` + +**Step 1: Write SwipeCard render test** + +```typescript +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { SwipeCard } from '../SwipeCard'; +import type { PropertyFeature } from '@/types'; + +const mockFeature: PropertyFeature = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-0.1, 51.5] }, + properties: { + url: 'https://www.rightmove.co.uk/properties/12345', + city: 'London', country: 'UK', + qm: 65, qmprice: 38, total_price: 2500, + rooms: 2, agency: 'Test Agency', + available_from: '2026-03-01', last_seen: '2026-02-21', + photo_thumbnail: '', price_history: [], + }, +}; + +describe('SwipeCard', () => { + it('renders property price and details', () => { + render(); + expect(screen.getByText(/2,500/)).toBeInTheDocument(); + expect(screen.getByText(/2 bed/)).toBeInTheDocument(); + expect(screen.getByText(/65 m/)).toBeInTheDocument(); + }); +}); +``` + +**Step 2: Run tests** + +Run: `cd frontend && npm run test:run -- --reporter=verbose` +Expected: PASS. + +**Step 3: Commit** + +```bash +git add frontend/src/components/__tests__/SwipeCard.test.tsx +git commit -m "test: add SwipeCard and useDecisions tests" +``` + +--- + +### Task 15: Run Full Test Suite & Fix Issues + +**Step 1: Run backend tests** + +Run: `pytest tests/ -v --tb=short` +Expected: All pass (existing + new). + +**Step 2: Run frontend tests** + +Run: `cd frontend && npm run test:run` +Expected: All pass. + +**Step 3: Fix any failures** + +Address type errors, import issues, or test isolation problems. + +**Step 4: Final commit if fixes were needed** + +```bash +git add -A +git commit -m "fix: resolve test failures from decisions feature integration" +``` + +--- + +### Task 16: Client-Side Disliked Filtering in processedListingData + +**Files:** +- Modify: `frontend/src/App.tsx` + +**Step 1: Add client-side filtering** + +In the `processedListingData` useMemo, after POI filtering, add: + +```typescript +// Filter out disliked listings (client-side for instant feedback) +if (isDecisionsLoaded) { + features = features.filter((f) => { + const id = getListingId(f); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return getDecision(id, type) !== 'disliked'; + }); +} +``` + +This gives instant visual feedback when a user dislikes a property, without waiting for a server refresh. + +**Step 2: Commit** + +```bash +git add frontend/src/App.tsx +git commit -m "feat: client-side filtering of disliked listings for instant feedback" +```