2026-02-21 13:54:38 +00:00
|
|
|
import logging
|
2026-02-23 21:30:51 +00:00
|
|
|
import time
|
2026-02-21 13:54:38 +00:00
|
|
|
from typing import Annotated
|
|
|
|
|
|
2026-02-23 21:30:51 +00:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
2026-02-21 13:54:38 +00:00
|
|
|
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):
|
wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
|
|
|
decision: str = Field(description="'liked', 'disliked', or 'seen'")
|
2026-02-21 15:48:02 +00:00
|
|
|
listing_type: str = Field(description="'RENT' or 'BUY'")
|
wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
|
|
|
price_at_decision: float | None = Field(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Total price at the time of the decision; used by the client to "
|
|
|
|
|
"resurface a 'seen' listing whose price has changed since dismissal.",
|
|
|
|
|
)
|
2026-02-21 13:54:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class DecisionResponse(BaseModel):
|
|
|
|
|
listing_id: int
|
|
|
|
|
listing_type: str
|
|
|
|
|
decision: str
|
wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
|
|
|
price_at_decision: float | None = None
|
2026-02-21 13:54:38 +00:00
|
|
|
created_at: str
|
|
|
|
|
updated_at: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_user_id(user: User) -> int:
|
|
|
|
|
"""Resolve auth User to database user ID."""
|
|
|
|
|
user_repo = UserRepository(engine)
|
|
|
|
|
db_user = user_repo.get_user_by_email(user.email)
|
|
|
|
|
if db_user is None:
|
2026-02-21 15:48:02 +00:00
|
|
|
# Auto-create user on first decision interaction
|
2026-02-21 13:54:38 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 15:48:02 +00:00
|
|
|
def _to_response(d: decision_service.ListingDecision) -> DecisionResponse:
|
|
|
|
|
return DecisionResponse(
|
|
|
|
|
listing_id=d.listing_id,
|
|
|
|
|
listing_type=d.listing_type,
|
|
|
|
|
decision=d.decision,
|
wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
|
|
|
price_at_decision=d.price_at_decision,
|
2026-02-21 15:48:02 +00:00
|
|
|
created_at=d.created_at.isoformat(),
|
|
|
|
|
updated_at=d.updated_at.isoformat(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 13:54:38 +00:00
|
|
|
@decision_router.put("/{listing_id}", response_model=DecisionResponse)
|
|
|
|
|
async def set_decision(
|
|
|
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
|
|
|
listing_id: int,
|
|
|
|
|
body: SetDecisionRequest,
|
2026-02-23 21:30:51 +00:00
|
|
|
response: Response,
|
2026-02-21 13:54:38 +00:00
|
|
|
) -> DecisionResponse:
|
2026-02-21 15:48:02 +00:00
|
|
|
"""Set or update a like/dislike decision for a listing."""
|
2026-02-23 21:30:51 +00:00
|
|
|
timings: list[str] = []
|
|
|
|
|
t0_total = time.monotonic()
|
|
|
|
|
t0 = time.monotonic()
|
2026-02-21 13:54:38 +00:00
|
|
|
user_id = _get_user_id(user)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
|
2026-02-21 13:54:38 +00:00
|
|
|
repo = DecisionRepository(engine)
|
|
|
|
|
try:
|
2026-02-23 21:30:51 +00:00
|
|
|
t0 = time.monotonic()
|
2026-02-21 13:54:38 +00:00
|
|
|
result = decision_service.set_decision(
|
|
|
|
|
repo,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
listing_id=listing_id,
|
|
|
|
|
listing_type=body.listing_type,
|
|
|
|
|
decision=body.decision,
|
wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.
New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.
## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
`price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
set_decision accepts an optional `price_at_decision`; it's only
forwarded to the repo for decision='seen' (other types null-out the
column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
expose the new field.
## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
(decision + price snapshot). New `markSeen(id, price, type)` short-
circuits on existing liked/disliked. New `getDecisionEntry`,
`seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
detail sheet stays open. Filter logic in `processedListingData`
hides `seen` rows whose `total_price === price_at_decision`, with
`showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
when hiddenCount > 0.
## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
even_if_supplied) + 1 updated to assert the new 5-arg repo
signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
snapshot, re-mark, null price, seenCount) + 5 new for decisionService
payload shape. 221 total passed, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
|
|
|
price_at_decision=body.price_at_decision,
|
2026-02-21 13:54:38 +00:00
|
|
|
)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"upsert;dur={(time.monotonic() - t0) * 1000:.1f}")
|
2026-02-21 13:54:38 +00:00
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
|
|
|
|
|
response.headers["Server-Timing"] = ", ".join(timings)
|
2026-02-21 15:48:02 +00:00
|
|
|
return _to_response(result)
|
2026-02-21 13:54:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@decision_router.get("", response_model=list[DecisionResponse])
|
|
|
|
|
async def get_decisions(
|
|
|
|
|
user: Annotated[User, Depends(get_current_user)],
|
2026-02-23 21:30:51 +00:00
|
|
|
response: Response,
|
2026-02-21 13:54:38 +00:00
|
|
|
) -> list[DecisionResponse]:
|
|
|
|
|
"""Get all decisions for the current user."""
|
2026-02-23 21:30:51 +00:00
|
|
|
timings: list[str] = []
|
|
|
|
|
t0_total = time.monotonic()
|
|
|
|
|
t0 = time.monotonic()
|
2026-02-21 13:54:38 +00:00
|
|
|
user_id = _get_user_id(user)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
|
2026-02-21 13:54:38 +00:00
|
|
|
repo = DecisionRepository(engine)
|
2026-02-23 21:30:51 +00:00
|
|
|
t0 = time.monotonic()
|
2026-02-21 15:48:02 +00:00
|
|
|
decisions = decision_service.get_user_decisions(repo, user_id)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"fetch;dur={(time.monotonic() - t0) * 1000:.1f}")
|
|
|
|
|
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
|
|
|
|
|
response.headers["Server-Timing"] = ", ".join(timings)
|
2026-02-21 15:48:02 +00:00
|
|
|
return [_to_response(d) for d in decisions]
|
2026-02-21 13:54:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@decision_router.delete("/{listing_id}")
|
|
|
|
|
async def delete_decision(
|
|
|
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
|
|
|
listing_id: int,
|
2026-02-23 21:30:51 +00:00
|
|
|
response: Response,
|
2026-02-21 15:48:02 +00:00
|
|
|
listing_type: str = Query(..., description="RENT or BUY"),
|
2026-02-21 13:54:38 +00:00
|
|
|
) -> dict[str, bool]:
|
2026-02-21 15:48:02 +00:00
|
|
|
"""Remove a decision (un-like/un-dislike)."""
|
2026-02-23 21:30:51 +00:00
|
|
|
timings: list[str] = []
|
|
|
|
|
t0_total = time.monotonic()
|
|
|
|
|
t0 = time.monotonic()
|
2026-02-21 13:54:38 +00:00
|
|
|
user_id = _get_user_id(user)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
|
2026-02-21 13:54:38 +00:00
|
|
|
repo = DecisionRepository(engine)
|
2026-02-21 15:48:02 +00:00
|
|
|
try:
|
2026-02-23 21:30:51 +00:00
|
|
|
t0 = time.monotonic()
|
2026-02-21 15:48:02 +00:00
|
|
|
deleted = decision_service.remove_decision(
|
|
|
|
|
repo, user_id, listing_id, listing_type
|
|
|
|
|
)
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"delete;dur={(time.monotonic() - t0) * 1000:.1f}")
|
2026-02-21 15:48:02 +00:00
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2026-02-21 13:54:38 +00:00
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Decision not found")
|
2026-02-23 21:30:51 +00:00
|
|
|
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
|
|
|
|
|
response.headers["Server-Timing"] = ", ".join(timings)
|
2026-02-21 13:54:38 +00:00
|
|
|
return {"success": True}
|