1612 lines
49 KiB
Markdown
1612 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"
|
||
|
|
```
|