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>
79 lines
2.6 KiB
Python
79 lines
2.6 KiB
Python
"""Unified decision service - shared between CLI and HTTP API.
|
|
|
|
This module provides the core business logic for listing decision operations
|
|
(like/dislike). Both the CLI and HTTP API should use these functions.
|
|
"""
|
|
from models.decision import ListingDecision
|
|
from repositories.decision_repository import DecisionRepository
|
|
|
|
VALID_DECISIONS = {"liked", "disliked", "seen"}
|
|
VALID_LISTING_TYPES = {"RENT", "BUY"}
|
|
|
|
|
|
def set_decision(
|
|
repository: DecisionRepository,
|
|
user_id: int,
|
|
listing_id: int,
|
|
listing_type: str,
|
|
decision: str,
|
|
price_at_decision: float | None = None,
|
|
) -> ListingDecision:
|
|
"""Set or update a decision for a listing.
|
|
|
|
price_at_decision is only meaningful for decision="seen"; it lets the
|
|
client resurface a listing whose price has changed since dismissal.
|
|
"""
|
|
if decision not in VALID_DECISIONS:
|
|
raise ValueError(
|
|
f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}"
|
|
)
|
|
if listing_type not in VALID_LISTING_TYPES:
|
|
raise ValueError(
|
|
f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
|
|
)
|
|
# Only carry the price column when the decision is "seen" — liked/disliked
|
|
# decisions ignore it. This keeps the column null for those rows.
|
|
price = price_at_decision if decision == "seen" else None
|
|
return repository.upsert_decision(
|
|
user_id, listing_id, listing_type, decision, price
|
|
)
|
|
|
|
|
|
def get_user_decisions(
|
|
repository: DecisionRepository,
|
|
user_id: int,
|
|
) -> list[ListingDecision]:
|
|
"""Get all decisions for a user."""
|
|
return repository.get_decisions_for_user(user_id)
|
|
|
|
|
|
def remove_decision(
|
|
repository: DecisionRepository,
|
|
user_id: int,
|
|
listing_id: int,
|
|
listing_type: str,
|
|
) -> bool:
|
|
"""Remove a decision (un-like/un-dislike). Returns False if not found."""
|
|
if listing_type not in VALID_LISTING_TYPES:
|
|
raise ValueError(
|
|
f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
|
|
)
|
|
return repository.delete_decision(user_id, listing_id, listing_type)
|
|
|
|
|
|
def get_disliked_ids(
|
|
repository: DecisionRepository,
|
|
user_id: int,
|
|
listing_type: str,
|
|
) -> set[int]:
|
|
"""Get all disliked listing IDs for a user and listing type."""
|
|
return repository.get_disliked_listing_ids(user_id, listing_type)
|
|
|
|
|
|
def get_liked_ids(
|
|
repository: DecisionRepository,
|
|
user_id: int,
|
|
listing_type: str,
|
|
) -> set[int]:
|
|
"""Get all liked listing IDs for a user and listing type."""
|
|
return repository.get_liked_listing_ids(user_id, listing_type)
|