16-task TDD plan covering backend (model, migration, repo, service, API routes, decision filtering) and frontend (decision service, hook, SwipeCard, SwipeReviewMode, SavedView, integration).
1611 lines
49 KiB
Markdown
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"
|
|
```
|