16-task TDD plan covering backend (model, migration, repo, service, API routes, decision filtering) and frontend (decision service, hook, SwipeCard, SwipeReviewMode, SavedView, integration).
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(addapp.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— adddecision_filterparam tostream_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.tsor add tofrontend/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:
- Import
useDecisionshook andSwipeReviewModecomponent - Add
showReviewModestate - Pass decisions context to filtering logic (client-side disliked filtering)
- Add "Review" button to the StatsBar area (desktop) and as a FAB (mobile)
- 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'toViewMode - 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"