wrongmove/docs/plans/2026-02-21-property-decisions-plan.md
Viktor Barzin 3384798b34
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 13:46:19 +00:00

49 KiB

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

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

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.

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

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

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

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

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

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

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

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

"""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:

"""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:

from api.decision_routes import decision_router

And after existing app.include_router(...) calls:

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

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:

"""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:

from repositories.decision_repository import DecisionRepository

In get_listing_geojson, after getting the result, filter out disliked listing IDs:

# 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

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:

cd frontend && npm install @react-spring/web @use-gesture/react

Step 2: Commit

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:

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

// 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<ListingDecision[]> {
  return apiRequest<ListingDecision[]>(user, '/api/decisions');
}

export async function setDecision(
  user: AuthUser,
  listingId: number,
  decision: 'liked' | 'disliked',
  listingType: 'RENT' | 'BUY' = 'RENT',
): Promise<ListingDecision> {
  return apiRequest<ListingDecision>(user, `/api/decisions/${listingId}`, {
    method: 'PUT',
    body: { decision, listing_type: listingType },
  });
}

export async function clearDecision(
  user: AuthUser,
  listingId: number,
  listingType: 'RENT' | 'BUY' = 'RENT',
): Promise<void> {
  await apiRequest(user, `/api/decisions/${listingId}`, {
    method: 'DELETE',
    params: { listing_type: listingType },
  });
}

Step 3: Re-export from frontend/src/services/index.ts

Add:

export { fetchDecisions, setDecision, clearDecision } from './decisionService';

Step 4: Commit

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<string, DecisionType> for O(1) lookups, and exposes optimistic like/dislike/clear functions.

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<Map<string, DecisionType>>(new Map());
  const [isLoaded, setIsLoaded] = useState(false);

  // Load decisions on mount
  useEffect(() => {
    if (!user) return;
    fetchDecisions(user)
      .then((list) => {
        const map = new Map<string, DecisionType>();
        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

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.

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 (
    <animated.div
      {...(isTop ? bind() : {})}
      style={{
        ...style,
        touchAction: 'none',
        position: 'absolute',
        width: '100%',
        zIndex: 10 - stackIndex,
        top: stackIndex * 8,
      }}
      className="cursor-grab active:cursor-grabbing"
    >
      <div className="bg-background rounded-2xl border shadow-lg overflow-hidden mx-4">
        {/* Color overlay */}
        {isTop && (
          <animated.div
            className="absolute inset-0 z-10 rounded-2xl pointer-events-none"
            style={{ backgroundColor: overlayColor, opacity: overlayOpacity }}
          />
        )}

        {/* Photo */}
        <div className="h-48 bg-muted relative">
          {p.photo_thumbnail && (
            <img src={p.photo_thumbnail} alt="Property" className="w-full h-full object-cover" />
          )}
          <button
            className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"
            onClick={(e) => {
              e.stopPropagation();
              window.open(p.url, '_blank', 'noopener,noreferrer');
            }}
          >
            <ExternalLink className="h-4 w-4" />
          </button>
        </div>

        {/* Details */}
        <div className="p-4">
          <div className="text-xl font-semibold">
            £{p.total_price.toLocaleString()}
            {p.listing_type !== 'BUY' && (
              <span className="text-muted-foreground font-normal text-sm">/mo</span>
            )}
          </div>

          <div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
            <span className="flex items-center gap-1">
              <Bed className="h-4 w-4" /> {p.rooms} bed
            </span>
            <span className="flex items-center gap-1">
              <Maximize2 className="h-4 w-4" /> {p.qm} m²
            </span>
            <span>£{p.qmprice}/m²</span>
          </div>

          {/* POI distances */}
          {p.poi_distances && p.poi_distances.length > 0 && (
            <div className="flex flex-wrap gap-1.5 mt-3">
              {p.poi_distances.slice(0, 4).map((d) => (
                <span
                  key={`${d.poi_id}_${d.travel_mode}`}
                  className="text-xs bg-muted px-2 py-0.5 rounded"
                >
                  {d.poi_name}: {Math.round(d.duration_seconds / 60)}m
                </span>
              ))}
            </div>
          )}
        </div>
      </div>
    </animated.div>
  );
}

Step 2: Commit

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.

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<HistoryEntry[]>([]);

  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 (
    <div className="fixed inset-0 z-50 bg-background flex flex-col">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b">
        <Button variant="ghost" size="sm" onClick={onClose}>
          <ArrowLeft className="h-4 w-4 mr-1" /> Back
        </Button>
        <span className="text-sm text-muted-foreground">
          {Math.min(currentIndex + 1, undecided.length)} / {undecided.length}
        </span>
        <div className="w-16" /> {/* Spacer for centering */}
      </div>

      {/* Card stack */}
      <div className="flex-1 relative overflow-hidden">
        {isFinished ? (
          <div className="flex items-center justify-center h-full">
            <div className="text-center p-8">
              <div className="text-4xl mb-4">All done!</div>
              <p className="text-muted-foreground mb-4">
                You've reviewed all {undecided.length} properties.
              </p>
              <Button onClick={onClose}>Back to listings</Button>
            </div>
          </div>
        ) : (
          <>
            {undecided.slice(currentIndex, currentIndex + 3).map((feature, i) => (
              <SwipeCard
                key={feature.properties.url}
                feature={feature}
                onSwipe={handleSwipe}
                isTop={i === 0}
                stackIndex={i}
              />
            ))}
          </>
        )}
      </div>

      {/* Action buttons */}
      {!isFinished && (
        <div className="flex items-center justify-center gap-6 py-6 px-4">
          <Button
            variant="outline"
            size="lg"
            className="rounded-full h-14 w-14 border-red-300 text-red-500 hover:bg-red-50"
            onClick={() => handleSwipe('left')}
          >
            <X className="h-6 w-6" />
          </Button>

          <Button
            variant="outline"
            size="sm"
            className="rounded-full h-10 w-10"
            onClick={handleUndo}
            disabled={history.length === 0}
          >
            <Undo2 className="h-4 w-4" />
          </Button>

          <Button
            variant="outline"
            size="sm"
            className="rounded-full h-10 w-10"
            onClick={() => handleSwipe('up')}
          >
            <ArrowUp className="h-4 w-4" />
          </Button>

          <Button
            variant="outline"
            size="lg"
            className="rounded-full h-14 w-14 border-green-300 text-green-500 hover:bg-green-50"
            onClick={() => handleSwipe('right')}
          >
            <Heart className="h-6 w-6" />
          </Button>
        </div>
      )}
    </div>
  );
}

Step 2: Commit

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 <SwipeReviewMode> overlay when active

Key additions:

import { useDecisions } from '@/hooks/useDecisions';
import { SwipeReviewMode } from './components/SwipeReviewMode';

State:

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 <SwipeReviewMode> 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

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:

export type ViewMode = 'map' | 'list' | 'split' | 'saved';

Add a "Saved" button in the view mode toggle:

<Button
    variant={viewMode === 'saved' ? 'secondary' : 'ghost'}
    size="sm"
    className="h-7 px-2"
    onClick={() => onViewModeChange('saved')}
>
    <Heart className="h-4 w-4" />
    <span className="hidden sm:inline ml-1">Saved ({likedCount})</span>
</Button>

Step 2: Create SavedView component

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 (
      <div className="flex-1 flex items-center justify-center">
        <div className="text-center p-8">
          <div className="text-4xl mb-4">No saved properties yet</div>
          <p className="text-muted-foreground">
            Use the Review mode to swipe through properties and save ones you like.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="h-full flex flex-col bg-background">
      <div className="px-3 py-2 text-sm text-muted-foreground border-b">
        {savedFeatures.length} saved properties
      </div>
      <Virtuoso
        className="flex-1"
        data={savedFeatures}
        overscan={200}
        itemContent={(_index, feature) => (
          <div className="px-3 pb-2 first:pt-3">
            <PropertyCard
              key={feature.properties.url}
              property={feature.properties}
              variant="compact"
            />
          </div>
        )}
      />
    </div>
  );
}

Step 3: Wire into App.tsx

In renderMainContent(), add a case for viewMode === 'saved'.

Step 4: Commit

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

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(<SwipeCard feature={mockFeature} onSwipe={vi.fn()} isTop stackIndex={0} />);
    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

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

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:

// 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

git add frontend/src/App.tsx
git commit -m "feat: client-side filtering of disliked listings for instant feedback"