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) ── */}