# 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" ```