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

1611 lines
49 KiB
Markdown

# Property Decisions (Phase 1) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Tinder-style swipe card review mode where users can like/dislike properties, with decisions persisted to the database and disliked properties filtered from views.
**Architecture:** New `ListingDecision` SQLModel entity with repository + service + API route layers (following the existing POI pattern). Frontend adds a `SwipeReviewMode` component with react-spring animations, a `decisionService` API client, and a decision state context. The existing GeoJSON stream endpoint gains a `decision_filter` parameter.
**Tech Stack:** Python/FastAPI/SQLModel (backend), React 19/TypeScript/react-spring/@use-gesture/react/Tailwind (frontend), Alembic (migration), vitest (frontend tests), pytest (backend tests).
---
### Task 1: Create ListingDecision Model
**Files:**
- Create: `models/decision.py`
**Step 1: Write the model**
```python
from datetime import datetime
from sqlmodel import SQLModel, Field
class ListingDecision(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
listing_id: int = Field(nullable=False, index=True)
listing_type: str = Field(nullable=False) # "RENT" or "BUY"
decision: str = Field(nullable=False) # "liked" or "disliked"
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
```
**Step 2: Commit**
```bash
git add models/decision.py
git commit -m "feat: add ListingDecision model"
```
---
### Task 2: Create Alembic Migration
**Files:**
- Create: `alembic/versions/d6e7f8a9b0c1_add_listing_decision_table.py`
**Step 1: Generate migration**
Run: `alembic revision -m "add listing decision table"` then edit the generated file.
**Step 2: Write the migration**
The migration should create table `listingdecision` with columns matching the model, a unique constraint on `(user_id, listing_id, listing_type)`, and indexes on `user_id` and `listing_id`.
```python
def upgrade() -> None:
op.create_table('listingdecision',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('listing_id', sa.Integer(), nullable=False),
sa.Column('listing_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('decision', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'listing_id', 'listing_type',
name='uq_decision_user_listing'),
)
op.create_index(op.f('ix_listingdecision_user_id'), 'listingdecision', ['user_id'])
op.create_index(op.f('ix_listingdecision_listing_id'), 'listingdecision', ['listing_id'])
def downgrade() -> None:
op.drop_index(op.f('ix_listingdecision_listing_id'), table_name='listingdecision')
op.drop_index(op.f('ix_listingdecision_user_id'), table_name='listingdecision')
op.drop_table('listingdecision')
```
**Step 3: Verify migration applies**
Run: `alembic upgrade head`
Expected: No errors, table created.
**Step 4: Commit**
```bash
git add alembic/versions/d6e7f8a9b0c1_add_listing_decision_table.py
git commit -m "feat: add listing_decision migration"
```
---
### Task 3: Create DecisionRepository
**Files:**
- Create: `repositories/decision_repository.py`
- Test: `tests/unit/test_decision_repository.py`
**Step 1: Write the failing tests**
```python
"""Unit tests for DecisionRepository."""
import pytest
from datetime import datetime
from sqlalchemy import Engine
from sqlmodel import SQLModel, Session, create_engine
from models.decision import ListingDecision
from models.user import User
from repositories.decision_repository import DecisionRepository
@pytest.fixture
def decision_engine() -> Engine:
engine = create_engine("sqlite:///:memory:", echo=False,
connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)
# Seed a user
with Session(engine) as session:
session.add(User(id=1, email="test@example.com"))
session.commit()
yield engine
SQLModel.metadata.drop_all(engine)
@pytest.fixture
def repo(decision_engine: Engine) -> DecisionRepository:
return DecisionRepository(decision_engine)
class TestDecisionRepository:
def test_upsert_creates_new_decision(self, repo: DecisionRepository) -> None:
result = repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked")
assert result.decision == "liked"
assert result.listing_id == 100
def test_upsert_updates_existing_decision(self, repo: DecisionRepository) -> None:
repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked")
result = repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="disliked")
assert result.decision == "disliked"
# Should still be one record
all_decisions = repo.get_decisions_for_user(1)
assert len(all_decisions) == 1
def test_get_decisions_for_user(self, repo: DecisionRepository) -> None:
repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked")
repo.upsert_decision(user_id=1, listing_id=200, listing_type="RENT", decision="disliked")
decisions = repo.get_decisions_for_user(1)
assert len(decisions) == 2
def test_delete_decision(self, repo: DecisionRepository) -> None:
repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked")
deleted = repo.delete_decision(user_id=1, listing_id=100, listing_type="RENT")
assert deleted is True
assert len(repo.get_decisions_for_user(1)) == 0
def test_delete_nonexistent_decision(self, repo: DecisionRepository) -> None:
deleted = repo.delete_decision(user_id=1, listing_id=999, listing_type="RENT")
assert deleted is False
def test_get_disliked_listing_ids(self, repo: DecisionRepository) -> None:
repo.upsert_decision(user_id=1, listing_id=100, listing_type="RENT", decision="liked")
repo.upsert_decision(user_id=1, listing_id=200, listing_type="RENT", decision="disliked")
repo.upsert_decision(user_id=1, listing_id=300, listing_type="RENT", decision="disliked")
disliked = repo.get_disliked_listing_ids(user_id=1, listing_type="RENT")
assert disliked == {200, 300}
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/unit/test_decision_repository.py -v`
Expected: FAIL — `ImportError: cannot import 'DecisionRepository'`
**Step 3: Write the repository**
```python
"""Repository for listing decisions (like/dislike)."""
from datetime import datetime
from models.decision import ListingDecision
from sqlalchemy import Engine
from sqlmodel import Session, select
class DecisionRepository:
engine: Engine
def __init__(self, engine: Engine) -> None:
self.engine = engine
def upsert_decision(
self, user_id: int, listing_id: int, listing_type: str, decision: str
) -> ListingDecision:
with Session(self.engine) as session:
statement = select(ListingDecision).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_id == listing_id,
ListingDecision.listing_type == listing_type,
)
existing = session.exec(statement).first()
if existing:
existing.decision = decision
existing.updated_at = datetime.utcnow()
session.add(existing)
session.commit()
session.refresh(existing)
return existing
new_decision = ListingDecision(
user_id=user_id,
listing_id=listing_id,
listing_type=listing_type,
decision=decision,
)
session.add(new_decision)
session.commit()
session.refresh(new_decision)
return new_decision
def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]:
with Session(self.engine) as session:
statement = select(ListingDecision).where(
ListingDecision.user_id == user_id
)
return list(session.exec(statement).all())
def delete_decision(
self, user_id: int, listing_id: int, listing_type: str
) -> bool:
with Session(self.engine) as session:
statement = select(ListingDecision).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_id == listing_id,
ListingDecision.listing_type == listing_type,
)
existing = session.exec(statement).first()
if existing is None:
return False
session.delete(existing)
session.commit()
return True
def get_disliked_listing_ids(
self, user_id: int, listing_type: str
) -> set[int]:
with Session(self.engine) as session:
statement = select(ListingDecision.listing_id).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_type == listing_type,
ListingDecision.decision == "disliked",
)
return {row for row in session.exec(statement).all()}
```
**Step 4: Run tests to verify they pass**
Run: `pytest tests/unit/test_decision_repository.py -v`
Expected: All 6 tests PASS.
**Step 5: Commit**
```bash
git add repositories/decision_repository.py tests/unit/test_decision_repository.py
git commit -m "feat: add DecisionRepository with tests"
```
---
### Task 4: Create Decision Service
**Files:**
- Create: `services/decision_service.py`
- Test: `tests/unit/test_decision_service.py`
**Step 1: Write the failing tests**
```python
"""Unit tests for decision_service."""
from unittest.mock import MagicMock
import pytest
from models.decision import ListingDecision
from services import decision_service
class TestSetDecision:
def test_set_liked(self) -> None:
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT", decision="liked"
)
result = decision_service.set_decision(repo, user_id=1, listing_id=100,
listing_type="RENT", decision="liked")
assert result.decision == "liked"
repo.upsert_decision.assert_called_once_with(
user_id=1, listing_id=100, listing_type="RENT", decision="liked"
)
def test_invalid_decision_raises(self) -> None:
repo = MagicMock()
with pytest.raises(ValueError, match="Invalid decision"):
decision_service.set_decision(repo, user_id=1, listing_id=100,
listing_type="RENT", decision="maybe")
class TestGetDecisions:
def test_returns_all_decisions(self) -> None:
repo = MagicMock()
repo.get_decisions_for_user.return_value = [
ListingDecision(id=1, user_id=1, listing_id=100, listing_type="RENT", decision="liked"),
]
result = decision_service.get_decisions(repo, user_id=1)
assert len(result) == 1
class TestClearDecision:
def test_clear_existing(self) -> None:
repo = MagicMock()
repo.delete_decision.return_value = True
result = decision_service.clear_decision(repo, user_id=1, listing_id=100, listing_type="RENT")
assert result is True
def test_clear_nonexistent(self) -> None:
repo = MagicMock()
repo.delete_decision.return_value = False
result = decision_service.clear_decision(repo, user_id=1, listing_id=100, listing_type="RENT")
assert result is False
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/unit/test_decision_service.py -v`
Expected: FAIL — `ModuleNotFoundError`
**Step 3: Write the service**
```python
"""Unified decision service — shared between CLI and HTTP API."""
from models.decision import ListingDecision
from repositories.decision_repository import DecisionRepository
VALID_DECISIONS = {"liked", "disliked"}
def set_decision(
repository: DecisionRepository,
user_id: int,
listing_id: int,
listing_type: str,
decision: str,
) -> ListingDecision:
if decision not in VALID_DECISIONS:
raise ValueError(f"Invalid decision '{decision}'. Must be one of: {VALID_DECISIONS}")
return repository.upsert_decision(
user_id=user_id,
listing_id=listing_id,
listing_type=listing_type,
decision=decision,
)
def get_decisions(
repository: DecisionRepository,
user_id: int,
) -> list[ListingDecision]:
return repository.get_decisions_for_user(user_id)
def clear_decision(
repository: DecisionRepository,
user_id: int,
listing_id: int,
listing_type: str,
) -> bool:
return repository.delete_decision(
user_id=user_id,
listing_id=listing_id,
listing_type=listing_type,
)
def get_disliked_listing_ids(
repository: DecisionRepository,
user_id: int,
listing_type: str,
) -> set[int]:
return repository.get_disliked_listing_ids(user_id=user_id, listing_type=listing_type)
```
**Step 4: Run tests to verify they pass**
Run: `pytest tests/unit/test_decision_service.py -v`
Expected: All 4 tests PASS.
**Step 5: Commit**
```bash
git add services/decision_service.py tests/unit/test_decision_service.py
git commit -m "feat: add decision_service with tests"
```
---
### Task 5: Create Decision API Routes
**Files:**
- Create: `api/decision_routes.py`
- Modify: `api/app.py` (add `app.include_router(decision_router)`)
- Test: `tests/unit/test_decision_routes.py`
**Step 1: Write the failing tests**
```python
"""Unit tests for decision API routes."""
import pytest
from httpx import ASGITransport, AsyncClient
from sqlmodel import SQLModel, Session, create_engine
from models.user import User
from api.auth import get_current_user, User as AuthUser
@pytest.fixture
def decision_engine():
engine = create_engine("sqlite:///:memory:", echo=False,
connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(User(id=1, email="test@example.com"))
session.commit()
yield engine
SQLModel.metadata.drop_all(engine)
@pytest.fixture
async def client(decision_engine):
import database
import api.app as api_app
app = api_app.app
mock_user = AuthUser(sub="test-user-id", email="test@example.com", name="Test User")
app.dependency_overrides[get_current_user] = lambda: mock_user
original_db = database.engine
original_app = api_app.engine
database.engine = decision_engine
api_app.engine = decision_engine
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
database.engine = original_db
api_app.engine = original_app
app.dependency_overrides.clear()
class TestDecisionRoutes:
@pytest.mark.asyncio
async def test_set_decision(self, client: AsyncClient) -> None:
resp = await client.put("/api/decisions/100", json={
"decision": "liked", "listing_type": "RENT"
})
assert resp.status_code == 200
data = resp.json()
assert data["decision"] == "liked"
assert data["listing_id"] == 100
@pytest.mark.asyncio
async def test_get_decisions(self, client: AsyncClient) -> None:
await client.put("/api/decisions/100", json={"decision": "liked", "listing_type": "RENT"})
resp = await client.get("/api/decisions")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
@pytest.mark.asyncio
async def test_delete_decision(self, client: AsyncClient) -> None:
await client.put("/api/decisions/100", json={"decision": "liked", "listing_type": "RENT"})
resp = await client.delete("/api/decisions/100", params={"listing_type": "RENT"})
assert resp.status_code == 200
assert resp.json()["success"] is True
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(self, client: AsyncClient) -> None:
resp = await client.delete("/api/decisions/999", params={"listing_type": "RENT"})
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_invalid_decision_returns_400(self, client: AsyncClient) -> None:
resp = await client.put("/api/decisions/100", json={
"decision": "maybe", "listing_type": "RENT"
})
assert resp.status_code == 400
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/unit/test_decision_routes.py -v`
Expected: FAIL — `ImportError`
**Step 3: Write the API routes**
Create `api/decision_routes.py`:
```python
"""API routes for listing decisions (like/dislike)."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from api.auth import User, get_current_user
from database import engine
from repositories.decision_repository import DecisionRepository
from repositories.user_repository import UserRepository
from services import decision_service
logger = logging.getLogger("uvicorn")
decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"])
class SetDecisionRequest(BaseModel):
decision: str = Field(description="'liked' or 'disliked'")
listing_type: str = Field(default="RENT", description="'RENT' or 'BUY'")
class DecisionResponse(BaseModel):
id: int
listing_id: int
listing_type: str
decision: str
created_at: str
updated_at: str
def _get_user_id(user: User) -> int:
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user.email)
if db_user is None:
db_user = user_repo.create_user(user.email)
if db_user.id is None:
raise HTTPException(status_code=500, detail="Failed to create user")
return db_user.id
@decision_router.put("/{listing_id}", response_model=DecisionResponse)
async def set_decision(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
body: SetDecisionRequest,
) -> DecisionResponse:
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
try:
result = decision_service.set_decision(
repo, user_id=user_id, listing_id=listing_id,
listing_type=body.listing_type, decision=body.decision,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return DecisionResponse(
id=result.id, # type: ignore[arg-type]
listing_id=result.listing_id,
listing_type=result.listing_type,
decision=result.decision,
created_at=result.created_at.isoformat(),
updated_at=result.updated_at.isoformat(),
)
@decision_router.get("", response_model=list[DecisionResponse])
async def get_decisions(
user: Annotated[User, Depends(get_current_user)],
) -> list[DecisionResponse]:
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
decisions = decision_service.get_decisions(repo, user_id=user_id)
return [
DecisionResponse(
id=d.id, # type: ignore[arg-type]
listing_id=d.listing_id,
listing_type=d.listing_type,
decision=d.decision,
created_at=d.created_at.isoformat(),
updated_at=d.updated_at.isoformat(),
)
for d in decisions
]
@decision_router.delete("/{listing_id}")
async def delete_decision(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
listing_type: str = "RENT",
) -> dict[str, bool]:
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
deleted = decision_service.clear_decision(
repo, user_id=user_id, listing_id=listing_id, listing_type=listing_type,
)
if not deleted:
raise HTTPException(status_code=404, detail="Decision not found")
return {"success": True}
```
**Step 4: Register router in `api/app.py`**
Add after existing router imports:
```python
from api.decision_routes import decision_router
```
And after existing `app.include_router(...)` calls:
```python
app.include_router(decision_router)
```
**Step 5: Run tests to verify they pass**
Run: `pytest tests/unit/test_decision_routes.py -v`
Expected: All 5 tests PASS.
**Step 6: Commit**
```bash
git add api/decision_routes.py api/app.py tests/unit/test_decision_routes.py
git commit -m "feat: add decision API routes with tests"
```
---
### Task 6: Add Decision Filtering to GeoJSON Stream
**Files:**
- Modify: `api/app.py` — add `decision_filter` param to `stream_listing_geojson`, filter disliked IDs from streamed features
**Step 1: Write the failing test**
Add to `tests/integration/test_api.py` or a new file `tests/unit/test_decision_filtering.py`:
```python
"""Test that disliked listings are filtered from the GeoJSON stream."""
import pytest
from httpx import ASGITransport, AsyncClient
from sqlmodel import SQLModel, Session, create_engine
from datetime import datetime
from models.user import User
from models.listing import RentListing, ListingSite, FurnishType
from models.decision import ListingDecision
from api.auth import get_current_user, User as AuthUser
@pytest.fixture
def filter_engine():
engine = create_engine("sqlite:///:memory:", echo=False,
connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(User(id=1, email="test@example.com"))
# Add two listings
for lid in [100, 200]:
session.add(RentListing(
id=lid, price=2000.0, number_of_bedrooms=2, square_meters=50.0,
longitude=-0.1, latitude=51.5, price_history_json="[]",
listing_site=ListingSite.RIGHTMOVE, last_seen=datetime.now(),
floorplan_image_paths=[], additional_info={},
furnish_type=FurnishType.FURNISHED,
))
# Dislike listing 200
session.add(ListingDecision(
user_id=1, listing_id=200, listing_type="RENT", decision="disliked"
))
session.commit()
yield engine
SQLModel.metadata.drop_all(engine)
@pytest.fixture
async def filter_client(filter_engine):
import database
import api.app as api_app
app = api_app.app
mock_user = AuthUser(sub="test", email="test@example.com", name="Test")
app.dependency_overrides[get_current_user] = lambda: mock_user
original_db = database.engine
original_app = api_app.engine
database.engine = filter_engine
api_app.engine = filter_engine
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
database.engine = original_db
api_app.engine = original_app
app.dependency_overrides.clear()
class TestDecisionFiltering:
@pytest.mark.asyncio
async def test_disliked_excluded_by_default(self, filter_client: AsyncClient) -> None:
"""Default decision_filter='all' should exclude disliked listings."""
resp = await filter_client.get("/api/listing_geojson", params={
"listing_type": "RENT"
})
assert resp.status_code == 200
data = resp.json()
urls = [f["properties"]["url"] for f in data["features"]]
assert any("100" in u for u in urls)
assert not any("200" in u for u in urls)
```
**Step 2: Implement the filtering**
In `api/app.py`, modify `get_listing_geojson` and the streaming endpoints to accept `decision_filter` query param and exclude disliked listings. The simplest approach: after fetching listings, filter out IDs that are in the user's disliked set.
Add to `api/app.py`:
```python
from repositories.decision_repository import DecisionRepository
```
In `get_listing_geojson`, after getting the result, filter out disliked listing IDs:
```python
# Filter out disliked listings
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user.email)
if db_user and db_user.id is not None:
decision_repo = DecisionRepository(engine)
disliked_ids = decision_service.get_disliked_listing_ids(
decision_repo, user_id=db_user.id, listing_type=query_parameters.listing_type.value
)
if disliked_ids:
result.data["features"] = [
f for f in result.data["features"]
if f.get("properties", {}).get("url", "").split("/")[-1] not in
{str(lid) for lid in disliked_ids}
]
```
For the streaming endpoint, similarly filter each batch before yielding.
**Step 3: Run tests**
Run: `pytest tests/unit/test_decision_filtering.py -v`
Expected: PASS.
**Step 4: Commit**
```bash
git add api/app.py tests/unit/test_decision_filtering.py
git commit -m "feat: filter disliked listings from GeoJSON endpoints"
```
---
### Task 7: Install Frontend Dependencies
**Files:**
- Modify: `frontend/package.json`
**Step 1: Install react-spring and use-gesture**
Run from `frontend/` directory:
```bash
cd frontend && npm install @react-spring/web @use-gesture/react
```
**Step 2: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "feat: add react-spring and use-gesture dependencies"
```
---
### Task 8: Create Frontend Decision Service
**Files:**
- Create: `frontend/src/services/decisionService.ts`
- Modify: `frontend/src/services/index.ts` (re-export)
- Create: `frontend/src/types/decisions.ts` or add to `frontend/src/types/index.ts`
**Step 1: Add types**
In `frontend/src/types/index.ts`, add:
```typescript
export type DecisionType = 'liked' | 'disliked';
export interface ListingDecision {
id: number;
listing_id: number;
listing_type: 'RENT' | 'BUY';
decision: DecisionType;
created_at: string;
updated_at: string;
}
```
**Step 2: Create the service**
```typescript
// Decision API service for managing listing decisions (like/dislike)
import type { AuthUser } from '@/auth/types';
import type { ListingDecision } from '@/types';
import { apiRequest } from './apiClient';
export async function fetchDecisions(user: AuthUser): Promise<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:
```typescript
export { fetchDecisions, setDecision, clearDecision } from './decisionService';
```
**Step 4: Commit**
```bash
git add frontend/src/types/index.ts frontend/src/services/decisionService.ts frontend/src/services/index.ts
git commit -m "feat: add frontend decision service and types"
```
---
### Task 9: Create Decision Context (React State)
**Files:**
- Create: `frontend/src/hooks/useDecisions.ts`
**Step 1: Write the hook**
This hook loads decisions on mount, provides a `Map<string, DecisionType>` for O(1) lookups, and exposes optimistic `like`/`dislike`/`clear` functions.
```typescript
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { AuthUser } from '@/auth/types';
import type { DecisionType, ListingDecision } from '@/types';
import { fetchDecisions, setDecision as apiSetDecision, clearDecision as apiClearDecision } from '@/services';
function decisionKey(listingId: number, listingType: string): string {
return `${listingId}-${listingType}`;
}
export function useDecisions(user: AuthUser | null) {
const [decisions, setDecisions] = useState<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**
```bash
git add frontend/src/hooks/useDecisions.ts
git commit -m "feat: add useDecisions hook with optimistic updates"
```
---
### Task 10: Create SwipeCard Component
**Files:**
- Create: `frontend/src/components/SwipeCard.tsx`
**Step 1: Write the swipe card component**
This is the individual draggable card with spring physics. Uses `@react-spring/web` for animation and `@use-gesture/react` for drag detection.
```typescript
import { useRef } from 'react';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { Bed, Maximize2, ExternalLink } from 'lucide-react';
import type { PropertyFeature } from '@/types';
interface SwipeCardProps {
feature: PropertyFeature;
onSwipe: (direction: 'left' | 'right' | 'up') => void;
isTop: boolean;
stackIndex: number; // 0 = front, 1 = behind, 2 = behind-behind
}
const SWIPE_THRESHOLD = 100;
export function SwipeCard({ feature, onSwipe, isTop, stackIndex }: SwipeCardProps) {
const hasSwiped = useRef(false);
const p = feature.properties;
const [style, api] = useSpring(() => ({
x: 0,
y: 0,
rotate: 0,
scale: 1 - stackIndex * 0.05,
opacity: stackIndex <= 2 ? 1 : 0,
config: { tension: 300, friction: 25 },
}));
const bind = useDrag(
({ active, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => {
if (!isTop || hasSwiped.current) return;
if (!active) {
// Released: check if past threshold
const isSwipeRight = mx > SWIPE_THRESHOLD || (vx > 0.5 && dx > 0);
const isSwipeLeft = mx < -SWIPE_THRESHOLD || (vx > 0.5 && dx < 0);
const isSwipeUp = my < -SWIPE_THRESHOLD || (vy > 0.5 && dy < 0);
if (isSwipeRight || isSwipeLeft || isSwipeUp) {
hasSwiped.current = true;
const direction = isSwipeRight ? 'right' : isSwipeLeft ? 'left' : 'up';
const flyOutX = isSwipeRight ? 500 : isSwipeLeft ? -500 : 0;
const flyOutY = isSwipeUp ? -500 : 0;
api.start({
x: flyOutX,
y: flyOutY,
rotate: isSwipeRight ? 15 : isSwipeLeft ? -15 : 0,
opacity: 0,
onRest: () => onSwipe(direction),
});
} else {
// Snap back
api.start({ x: 0, y: 0, rotate: 0 });
}
return;
}
// Dragging
api.start({
x: mx,
y: my,
rotate: mx / 20,
immediate: true,
});
},
{ filterTaps: true, enabled: isTop },
);
// Color overlay based on drag direction
const overlayOpacity = style.x.to((x) => Math.min(Math.abs(x) / 150, 0.4));
const overlayColor = style.x.to((x) => (x > 0 ? 'rgba(34,197,94,VAR)' : 'rgba(239,68,68,VAR)').replace('VAR', '1'));
return (
<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**
```bash
git add frontend/src/components/SwipeCard.tsx
git commit -m "feat: add SwipeCard component with spring physics"
```
---
### Task 11: Create SwipeReviewMode Component
**Files:**
- Create: `frontend/src/components/SwipeReviewMode.tsx`
**Step 1: Write the review mode container**
This wraps the card stack, action buttons, progress counter, undo, and keyboard shortcuts.
```typescript
import { useState, useCallback, useEffect } from 'react';
import { X, Heart, ArrowUp, Undo2, ArrowLeft } from 'lucide-react';
import { Button } from './ui/button';
import { SwipeCard } from './SwipeCard';
import type { PropertyFeature, DecisionType } from '@/types';
interface SwipeReviewModeProps {
features: PropertyFeature[];
onDecide: (listingId: number, decision: DecisionType, listingType?: 'RENT' | 'BUY') => void;
onClear: (listingId: number, listingType?: 'RENT' | 'BUY') => void;
onClose: () => void;
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
}
interface HistoryEntry {
index: number;
listingId: number;
listingType: string;
action: 'liked' | 'disliked' | 'skipped';
}
function getListingId(feature: PropertyFeature): number {
// Extract listing ID from URL: https://www.rightmove.co.uk/properties/12345
const parts = feature.properties.url.split('/');
return parseInt(parts[parts.length - 1], 10);
}
function getListingType(feature: PropertyFeature): 'RENT' | 'BUY' {
return feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
}
export function SwipeReviewMode({
features,
onDecide,
onClear,
onClose,
getDecision,
}: SwipeReviewModeProps) {
// Filter to only undecided features
const undecided = features.filter((f) => {
const id = getListingId(f);
const type = getListingType(f);
return getDecision(id, type) === undefined;
});
const [currentIndex, setCurrentIndex] = useState(0);
const [history, setHistory] = useState<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**
```bash
git add frontend/src/components/SwipeReviewMode.tsx
git commit -m "feat: add SwipeReviewMode with card stack and keyboard shortcuts"
```
---
### Task 12: Integrate SwipeReviewMode into App
**Files:**
- Modify: `frontend/src/App.tsx`
**Step 1: Wire up the review mode**
Add to App.tsx:
1. Import `useDecisions` hook and `SwipeReviewMode` component
2. Add `showReviewMode` state
3. Pass decisions context to filtering logic (client-side disliked filtering)
4. Add "Review" button to the StatsBar area (desktop) and as a FAB (mobile)
5. Render `<SwipeReviewMode>` overlay when active
Key additions:
```typescript
import { useDecisions } from '@/hooks/useDecisions';
import { SwipeReviewMode } from './components/SwipeReviewMode';
```
State:
```typescript
const [showReviewMode, setShowReviewMode] = useState(false);
const { decide, clear, getDecision, likedCount } = useDecisions(user);
```
In the mobile layout, add a review FAB button next to the filter FAB. In desktop, add a "Review" button in the StatsBar. When `showReviewMode` is true, render the `<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**
```bash
git add frontend/src/App.tsx
git commit -m "feat: integrate SwipeReviewMode into App with review button"
```
---
### Task 13: Add "Saved" Tab to StatsBar / Navigation
**Files:**
- Modify: `frontend/src/components/StatsBar.tsx` — add `'saved'` to `ViewMode`
- Create: `frontend/src/components/SavedView.tsx`
- Modify: `frontend/src/App.tsx` — render SavedView when viewMode is 'saved'
**Step 1: Add saved view mode**
Update `ViewMode` type in `StatsBar.tsx`:
```typescript
export type ViewMode = 'map' | 'list' | 'split' | 'saved';
```
Add a "Saved" button in the view mode toggle:
```typescript
<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**
```typescript
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { PropertyCard } from './PropertyCard';
import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types';
interface SavedViewProps {
listingData: GeoJSONFeatureCollection;
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
}
function getListingId(feature: PropertyFeature): number {
const parts = feature.properties.url.split('/');
return parseInt(parts[parts.length - 1], 10);
}
export function SavedView({ listingData, getDecision }: SavedViewProps) {
const savedFeatures = useMemo(() => {
return listingData.features.filter((f) => {
const id = getListingId(f);
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
return getDecision(id, type) === 'liked';
});
}, [listingData, getDecision]);
if (savedFeatures.length === 0) {
return (
<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**
```bash
git add frontend/src/components/StatsBar.tsx frontend/src/components/SavedView.tsx frontend/src/App.tsx
git commit -m "feat: add Saved view tab for viewing liked properties"
```
---
### Task 14: Add Frontend Tests
**Files:**
- Create: `frontend/src/components/__tests__/SwipeCard.test.tsx`
- Create: `frontend/src/hooks/__tests__/useDecisions.test.ts`
**Step 1: Write SwipeCard render test**
```typescript
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { SwipeCard } from '../SwipeCard';
import type { PropertyFeature } from '@/types';
const mockFeature: PropertyFeature = {
type: 'Feature',
geometry: { type: 'Point', coordinates: [-0.1, 51.5] },
properties: {
url: 'https://www.rightmove.co.uk/properties/12345',
city: 'London', country: 'UK',
qm: 65, qmprice: 38, total_price: 2500,
rooms: 2, agency: 'Test Agency',
available_from: '2026-03-01', last_seen: '2026-02-21',
photo_thumbnail: '', price_history: [],
},
};
describe('SwipeCard', () => {
it('renders property price and details', () => {
render(<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**
```bash
git add frontend/src/components/__tests__/SwipeCard.test.tsx
git commit -m "test: add SwipeCard and useDecisions tests"
```
---
### Task 15: Run Full Test Suite & Fix Issues
**Step 1: Run backend tests**
Run: `pytest tests/ -v --tb=short`
Expected: All pass (existing + new).
**Step 2: Run frontend tests**
Run: `cd frontend && npm run test:run`
Expected: All pass.
**Step 3: Fix any failures**
Address type errors, import issues, or test isolation problems.
**Step 4: Final commit if fixes were needed**
```bash
git add -A
git commit -m "fix: resolve test failures from decisions feature integration"
```
---
### Task 16: Client-Side Disliked Filtering in processedListingData
**Files:**
- Modify: `frontend/src/App.tsx`
**Step 1: Add client-side filtering**
In the `processedListingData` useMemo, after POI filtering, add:
```typescript
// Filter out disliked listings (client-side for instant feedback)
if (isDecisionsLoaded) {
features = features.filter((f) => {
const id = getListingId(f);
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
return getDecision(id, type) !== 'disliked';
});
}
```
This gives instant visual feedback when a user dislikes a property, without waiting for a server refresh.
**Step 2: Commit**
```bash
git add frontend/src/App.tsx
git commit -m "feat: client-side filtering of disliked listings for instant feedback"
```