From c2e08fe46eecfcfd44cfe64c36f87eac87cea639 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 16 May 2026 11:07:44 +0000 Subject: [PATCH] wrongmove: add "seen" soft-hide decision with price-aware resurfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (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 --- ...7a8b9c0d1e2_add_seen_decision_and_price.py | 33 +++++ api/decision_routes.py | 10 +- frontend/src/App.tsx | 69 +++++++++- frontend/src/components/FilterBar.tsx | 29 ++++- .../src/hooks/__tests__/useDecisions.test.tsx | 123 ++++++++++++++++++ frontend/src/hooks/useDecisions.ts | 81 ++++++++++-- .../__tests__/decisionService.test.ts | 66 ++++++++++ frontend/src/services/decisionService.ts | 15 ++- frontend/src/types/index.ts | 8 +- models/decision.py | 6 +- repositories/decision_repository.py | 4 + services/decision_service.py | 16 ++- tests/unit/test_decision_service.py | 39 +++++- 13 files changed, 474 insertions(+), 25 deletions(-) create mode 100644 alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py create mode 100644 frontend/src/hooks/__tests__/useDecisions.test.tsx create mode 100644 frontend/src/services/__tests__/decisionService.test.ts diff --git a/alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py b/alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py new file mode 100644 index 0000000..28abb56 --- /dev/null +++ b/alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py @@ -0,0 +1,33 @@ +"""add seen decision and price_at_decision + +Revision ID: f7a8b9c0d1e2 +Revises: d6e7f8a9b0c1 +Create Date: 2026-05-16 00:00:00.000000 + +Adds support for the soft-hide "seen" decision type. The decision column is +already a free string, so no schema change is needed for the enum value; we +only need to add price_at_decision so the client can resurface listings +whose price has changed since the user marked them seen. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f7a8b9c0d1e2' +down_revision: Union[str, None] = 'd6e7f8a9b0c1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'listingdecision', + sa.Column('price_at_decision', sa.Float(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('listingdecision', 'price_at_decision') diff --git a/api/decision_routes.py b/api/decision_routes.py index db902d3..8d168ad 100644 --- a/api/decision_routes.py +++ b/api/decision_routes.py @@ -17,14 +17,20 @@ decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"]) class SetDecisionRequest(BaseModel): - decision: str = Field(description="'liked' or 'disliked'") + decision: str = Field(description="'liked', 'disliked', or 'seen'") listing_type: str = Field(description="'RENT' or 'BUY'") + 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.", + ) class DecisionResponse(BaseModel): listing_id: int listing_type: str decision: str + price_at_decision: float | None = None created_at: str updated_at: str @@ -46,6 +52,7 @@ def _to_response(d: decision_service.ListingDecision) -> DecisionResponse: listing_id=d.listing_id, listing_type=d.listing_type, decision=d.decision, + price_at_decision=d.price_at_decision, created_at=d.created_at.isoformat(), updated_at=d.updated_at.isoformat(), ) @@ -73,6 +80,7 @@ async def set_decision( listing_id=listing_id, listing_type=body.listing_type, decision=body.decision, + price_at_decision=body.price_at_decision, ) timings.append(f"upsert;dur={(time.monotonic() - t0) * 1000:.1f}") except ValueError as e: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a523eb..03eb6dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -130,7 +130,29 @@ function AppContent() { const [bottomSheetSnap, setBottomSheetSnap] = useState("148px"); // Decision state (like/dislike) - const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); + const { decide, clear, markSeen, getDecision, getDecisionEntry, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); + // When true, listings marked "seen" with unchanged price are still shown + // (with an Unhide affordance) instead of hidden. Off by default. + const [showHidden, setShowHidden] = useState(false); + + // After 3 seconds of dwell in the detail sheet, mark the listing as `seen` + // (passively hide it from the main list unless its price later changes). + // 3s is enough to ignore accidental flick-taps but short enough to be + // invisible during real reading. Liked listings are skipped inside markSeen. + useEffect(() => { + if (selectedListingId === null) return; + const id = selectedListingId; + const listingType = (queryParameters?.listing_type ?? 'RENT') as 'RENT' | 'BUY'; + const timer = window.setTimeout(() => { + const feature = listingData?.features.find((f) => { + const parts = f.properties.url.split('/'); + return parseInt(parts[parts.length - 1], 10) === id; + }); + const price = feature?.properties.total_price ?? null; + void markSeen(id, price, listingType); + }, 3000); + return () => window.clearTimeout(timer); + }, [selectedListingId, listingData, markSeen, queryParameters?.listing_type]); // Explicit task ID set by fetch-data action (to track as "active") const [explicitTaskId, setExplicitTaskId] = useState(null); @@ -379,18 +401,54 @@ function AppContent() { }); } - // Filter out disliked listings (client-side for instant feedback) + // Filter out disliked listings (client-side for instant feedback). + // Also hide listings marked "seen" UNLESS: + // - the toggle "Show hidden" is on, OR + // - the listing's current price differs from price_at_decision (any + // change resurfaces it per the user's spec). if (isDecisionsLoaded) { features = features.filter((f) => { const parts = f.properties.url.split('/'); const id = parseInt(parts[parts.length - 1], 10); const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; - return getDecision(id, type) !== 'disliked'; + const entry = getDecisionEntry(id, type); + if (!entry) return true; + if (entry.decision === 'disliked') return false; + if (entry.decision === 'seen') { + if (showHidden) return true; + const priceNow = f.properties.total_price; + const priceThen = entry.price_at_decision; + // Resurface if either price is missing (we can't compare safely) OR + // the live price diverges from the snapshot taken at mark-seen time. + if (typeof priceNow !== 'number' || typeof priceThen !== 'number') return true; + return priceNow !== priceThen; + } + return true; }); } return { ...listingData, features }; - }, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecision]); + }, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecisionEntry, showHidden]); + + // Count of listings that WOULD be hidden by the seen-filter at the current + // price (i.e. seen + price unchanged). Drives the "Hidden (N)" toggle — + // when 0 we don't surface the toggle at all. + const hiddenCount = useMemo(() => { + if (!isDecisionsLoaded || !listingData) return 0; + let n = 0; + for (const f of listingData.features) { + const parts = f.properties.url.split('/'); + const id = parseInt(parts[parts.length - 1], 10); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + const entry = getDecisionEntry(id, type); + if (entry?.decision !== 'seen') continue; + const priceNow = f.properties.total_price; + const priceThen = entry.price_at_decision; + if (typeof priceNow !== 'number' || typeof priceThen !== 'number') continue; + if (priceNow === priceThen) n++; + } + return n; + }, [listingData, isDecisionsLoaded, getDecisionEntry]); // Compute the effective metric string for the heatmap const effectiveMetric = useMemo(() => { @@ -792,6 +850,9 @@ function AppContent() { onTaskCreated={handlePOITaskCreated} initialValues={initialFilterValuesRef.current} onFormReady={handleFilterBarFormReady} + showHidden={showHidden} + onToggleShowHidden={() => setShowHidden((v) => !v)} + hiddenCount={hiddenCount} /> diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index c6bcfed..22599d8 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { ChevronDown, Loader2, RefreshCw, Search, MapPin, SlidersHorizontal } from 'lucide-react'; +import { ChevronDown, Loader2, RefreshCw, Search, MapPin, SlidersHorizontal, Eye, EyeOff } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; @@ -70,6 +70,12 @@ interface FilterBarProps { initialValues?: ParameterValues; /** Provides parent access to the form handle (e.g. for chip-remove resets). */ onFormReady?: (handle: FilterBarFormHandle) => void; + /** When true, listings marked "seen" with unchanged price are still shown. */ + showHidden?: boolean; + /** Toggles the showHidden flag (re-renders the main list). */ + onToggleShowHidden?: () => void; + /** Count of currently-hidden listings; controls whether the toggle is offered. */ + hiddenCount?: number; } // ── Helpers ── @@ -140,6 +146,9 @@ export function FilterBar({ onTaskCreated, initialValues, onFormReady, + showHidden = false, + onToggleShowHidden, + hiddenCount = 0, }: FilterBarProps) { const [selectedFurnishTypes, setSelectedFurnishTypes] = useState( initialValues?.furnish_types ?? [], @@ -711,6 +720,24 @@ export function FilterBar({ {/* ── Spacer ── */}
+ {/* ── Show Hidden Toggle ── + Surfaces only when the user has at least one "seen" listing hidden. + Reveals those rows in the main list with an Unhide affordance. */} + {hiddenCount > 0 && onToggleShowHidden && ( + + )} + {/* ── Action Buttons (right side) ── */}