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>
This commit is contained in:
Viktor Barzin 2026-05-16 11:07:44 +00:00
parent 9a5ad7878c
commit c2e08fe46e
13 changed files with 474 additions and 25 deletions

View file

@ -130,7 +130,29 @@ function AppContent() {
const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("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<string | null>(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}
/>
</div>

View file

@ -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<FurnishType[]>(
initialValues?.furnish_types ?? [],
@ -711,6 +720,24 @@ export function FilterBar({
{/* ── Spacer ── */}
<div className="flex-1" />
{/* 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 && (
<Button
type="button"
size="sm"
variant={showHidden ? 'secondary' : 'ghost'}
className="h-8 text-xs gap-1.5"
onClick={onToggleShowHidden}
aria-pressed={showHidden}
aria-label={showHidden ? 'Hide previously-seen listings' : 'Show hidden listings'}
>
{showHidden ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
{showHidden ? `Showing hidden (${hiddenCount})` : `Hidden (${hiddenCount})`}
</Button>
)}
{/* ── Action Buttons (right side) ── */}
<Button
type="button"

View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDecisions } from '@/hooks/useDecisions';
import type { AuthUser } from '@/auth/types';
vi.mock('@/services', () => ({
fetchDecisions: vi.fn(),
setDecision: vi.fn(),
clearDecision: vi.fn(),
}));
import { fetchDecisions, setDecision, clearDecision } from '@/services';
const fetchDecisionsMock = vi.mocked(fetchDecisions);
const setDecisionMock = vi.mocked(setDecision);
const clearDecisionMock = vi.mocked(clearDecision);
const user: AuthUser = {
sub: 'u1',
email: 't@example.com',
name: 'Test',
accessToken: 'tok',
provider: 'oidc',
};
describe('useDecisions — markSeen + price snapshot', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchDecisionsMock.mockResolvedValue([]);
setDecisionMock.mockImplementation(async (_u, listing_id, decision, listing_type, price) => ({
id: 1,
listing_id,
listing_type: listing_type ?? 'RENT',
decision,
price_at_decision: price ?? null,
created_at: '2026-05-16T00:00:00Z',
updated_at: '2026-05-16T00:00:00Z',
}));
clearDecisionMock.mockResolvedValue();
});
it('markSeen persists the current price', async () => {
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
await act(async () => {
await result.current.markSeen(100, 2500, 'RENT');
});
expect(setDecisionMock).toHaveBeenCalledWith(user, 100, 'seen', 'RENT', 2500);
const entry = result.current.getDecisionEntry(100, 'RENT');
expect(entry?.decision).toBe('seen');
expect(entry?.price_at_decision).toBe(2500);
});
it('markSeen skips when the existing decision is liked', async () => {
fetchDecisionsMock.mockResolvedValueOnce([
{
id: 1, listing_id: 100, listing_type: 'RENT',
decision: 'liked', created_at: '', updated_at: '',
},
]);
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
await act(async () => {
await result.current.markSeen(100, 2500, 'RENT');
});
expect(setDecisionMock).not.toHaveBeenCalled();
expect(result.current.getDecisionEntry(100, 'RENT')?.decision).toBe('liked');
});
it('markSeen skips when the existing decision is disliked', async () => {
fetchDecisionsMock.mockResolvedValueOnce([
{
id: 1, listing_id: 100, listing_type: 'RENT',
decision: 'disliked', created_at: '', updated_at: '',
},
]);
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
await act(async () => {
await result.current.markSeen(100, 2500, 'RENT');
});
expect(setDecisionMock).not.toHaveBeenCalled();
});
it('markSeen re-marks an existing seen with the latest price', async () => {
fetchDecisionsMock.mockResolvedValueOnce([
{
id: 1, listing_id: 100, listing_type: 'RENT',
decision: 'seen', price_at_decision: 2500,
created_at: '', updated_at: '',
},
]);
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
// Listing's live price changed; re-marking captures the new snapshot.
await act(async () => {
await result.current.markSeen(100, 2300, 'RENT');
});
expect(setDecisionMock).toHaveBeenCalledWith(user, 100, 'seen', 'RENT', 2300);
expect(result.current.getDecisionEntry(100, 'RENT')?.price_at_decision).toBe(2300);
});
it('markSeen sends null when the live price is missing', async () => {
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
await act(async () => {
await result.current.markSeen(100, null, 'RENT');
});
expect(setDecisionMock).toHaveBeenCalledWith(user, 100, 'seen', 'RENT', null);
});
it('seenCount tracks only seen rows', async () => {
fetchDecisionsMock.mockResolvedValueOnce([
{ id: 1, listing_id: 1, listing_type: 'RENT', decision: 'seen', price_at_decision: 100, created_at: '', updated_at: '' },
{ id: 2, listing_id: 2, listing_type: 'RENT', decision: 'seen', price_at_decision: 200, created_at: '', updated_at: '' },
{ id: 3, listing_id: 3, listing_type: 'RENT', decision: 'liked', created_at: '', updated_at: '' },
]);
const { result } = renderHook(() => useDecisions(user));
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.seenCount).toBe(2);
expect(result.current.likedCount).toBe(1);
});
});

View file

@ -7,8 +7,15 @@ function decisionKey(listingId: number, listingType: string): string {
return `${listingId}-${listingType}`;
}
// Stored value per (listing_id, listing_type). price_at_decision is only
// meaningful for decision='seen' (powers the price-change resurfacing).
export interface DecisionEntry {
decision: DecisionType;
price_at_decision?: number | null;
}
export function useDecisions(user: AuthUser | null) {
const [decisions, setDecisions] = useState<Map<string, DecisionType>>(new Map());
const [decisions, setDecisions] = useState<Map<string, DecisionEntry>>(new Map());
const [isLoaded, setIsLoaded] = useState(false);
// Load decisions on mount
@ -16,9 +23,12 @@ export function useDecisions(user: AuthUser | null) {
if (!user) return;
fetchDecisions(user)
.then((list) => {
const map = new Map<string, DecisionType>();
const map = new Map<string, DecisionEntry>();
for (const d of list) {
map.set(decisionKey(d.listing_id, d.listing_type), d.decision);
map.set(decisionKey(d.listing_id, d.listing_type), {
decision: d.decision,
price_at_decision: d.price_at_decision ?? null,
});
}
setDecisions(map);
setIsLoaded(true);
@ -27,19 +37,28 @@ export function useDecisions(user: AuthUser | null) {
}, [user]);
const decide = useCallback(
async (listingId: number, decision: DecisionType, listingType: 'RENT' | 'BUY' = 'RENT') => {
async (
listingId: number,
decision: DecisionType,
listingType: 'RENT' | 'BUY' = 'RENT',
priceAtDecision?: number | null,
) => {
if (!user) return;
const key = decisionKey(listingId, listingType);
const entry: DecisionEntry = {
decision,
price_at_decision: decision === 'seen' ? priceAtDecision ?? null : null,
};
// Optimistic update
setDecisions((prev) => {
const next = new Map(prev);
next.set(key, decision);
next.set(key, entry);
return next;
});
try {
await apiSetDecision(user, listingId, decision, listingType);
await apiSetDecision(user, listingId, decision, listingType, priceAtDecision);
} catch {
// Revert on failure
setDecisions((prev) => {
@ -79,22 +98,66 @@ export function useDecisions(user: AuthUser | null) {
[user, decisions],
);
// Liked / disliked decisions are never auto-overwritten by `seen`:
// - liked: opening a saved listing shouldn't bury it.
// - disliked: explicit user action takes precedence over passive dwell.
// `seen` re-marks with the latest price (refreshes the snapshot so a
// subsequent price change still resurfaces the listing).
const markSeen = useCallback(
async (
listingId: number,
currentPrice: number | null | undefined,
listingType: 'RENT' | 'BUY' = 'RENT',
) => {
if (!user) return;
const key = decisionKey(listingId, listingType);
const existing = decisions.get(key);
if (existing?.decision === 'liked' || existing?.decision === 'disliked') return;
const price = typeof currentPrice === 'number' && Number.isFinite(currentPrice) ? currentPrice : null;
await decide(listingId, 'seen', listingType, price);
},
[user, decisions, decide],
);
const getDecision = useCallback(
(listingId: number, listingType: string = 'RENT'): DecisionType | undefined => {
return decisions.get(decisionKey(listingId, listingType))?.decision;
},
[decisions],
);
const getDecisionEntry = useCallback(
(listingId: number, listingType: string = 'RENT'): DecisionEntry | undefined => {
return decisions.get(decisionKey(listingId, listingType));
},
[decisions],
);
const likedCount = useMemo(
() => Array.from(decisions.values()).filter((d) => d === 'liked').length,
() => Array.from(decisions.values()).filter((d) => d.decision === 'liked').length,
[decisions],
);
const dislikedCount = useMemo(
() => Array.from(decisions.values()).filter((d) => d === 'disliked').length,
() => Array.from(decisions.values()).filter((d) => d.decision === 'disliked').length,
[decisions],
);
return { decisions, isLoaded, decide, clear, getDecision, likedCount, dislikedCount };
const seenCount = useMemo(
() => Array.from(decisions.values()).filter((d) => d.decision === 'seen').length,
[decisions],
);
return {
decisions,
isLoaded,
decide,
clear,
markSeen,
getDecision,
getDecisionEntry,
likedCount,
dislikedCount,
seenCount,
};
}

View file

@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setDecision } from '@/services/decisionService';
import type { AuthUser } from '@/auth/types';
vi.mock('@/services/apiClient', () => ({
apiRequest: vi.fn(),
}));
import { apiRequest } from '@/services/apiClient';
const apiRequestMock = vi.mocked(apiRequest);
const user: AuthUser = {
sub: 'u1',
email: 't@example.com',
name: 'Test',
accessToken: 'tok',
provider: 'oidc',
};
describe('decisionService.setDecision', () => {
beforeEach(() => {
apiRequestMock.mockReset();
apiRequestMock.mockResolvedValue({} as never);
});
it("sends price_at_decision when decision='seen'", async () => {
await setDecision(user, 100, 'seen', 'RENT', 2500);
expect(apiRequestMock).toHaveBeenCalledWith(user, '/api/decisions/100', {
method: 'PUT',
body: { decision: 'seen', listing_type: 'RENT', price_at_decision: 2500 },
});
});
it("drops price_at_decision for decision='liked' (server would null it anyway)", async () => {
await setDecision(user, 100, 'liked', 'RENT', 9999);
expect(apiRequestMock).toHaveBeenCalledWith(user, '/api/decisions/100', {
method: 'PUT',
body: { decision: 'liked', listing_type: 'RENT' },
});
});
it("drops price_at_decision for decision='disliked'", async () => {
await setDecision(user, 100, 'disliked', 'BUY', 9999);
expect(apiRequestMock).toHaveBeenCalledWith(user, '/api/decisions/100', {
method: 'PUT',
body: { decision: 'disliked', listing_type: 'BUY' },
});
});
it("omits price_at_decision when seen but no price supplied", async () => {
await setDecision(user, 100, 'seen', 'RENT');
expect(apiRequestMock).toHaveBeenCalledWith(user, '/api/decisions/100', {
method: 'PUT',
body: { decision: 'seen', listing_type: 'RENT' },
});
});
it("omits price_at_decision when seen but price is non-finite", async () => {
await setDecision(user, 100, 'seen', 'RENT', NaN);
expect(apiRequestMock).toHaveBeenCalledWith(user, '/api/decisions/100', {
method: 'PUT',
body: { decision: 'seen', listing_type: 'RENT' },
});
});
});

View file

@ -1,7 +1,7 @@
// Decision API service for managing listing decisions (like/dislike)
// Decision API service for managing listing decisions (like/dislike/seen)
import type { AuthUser } from '@/auth/types';
import type { ListingDecision } from '@/types';
import type { DecisionType, ListingDecision } from '@/types';
import { apiRequest } from './apiClient';
export async function fetchDecisions(user: AuthUser): Promise<ListingDecision[]> {
@ -11,12 +11,19 @@ export async function fetchDecisions(user: AuthUser): Promise<ListingDecision[]>
export async function setDecision(
user: AuthUser,
listingId: number,
decision: 'liked' | 'disliked',
decision: DecisionType,
listingType: 'RENT' | 'BUY' = 'RENT',
priceAtDecision?: number | null,
): Promise<ListingDecision> {
const body: Record<string, unknown> = { decision, listing_type: listingType };
// Only send the price column for 'seen' — the backend already drops it for
// other decision types, but keeping the payload tight saves bytes.
if (decision === 'seen' && typeof priceAtDecision === 'number' && Number.isFinite(priceAtDecision)) {
body.price_at_decision = priceAtDecision;
}
return apiRequest<ListingDecision>(user, `/api/decisions/${listingId}`, {
method: 'PUT',
body: { decision, listing_type: listingType },
body,
});
}

View file

@ -182,13 +182,19 @@ export interface WSPongMessage {
export type WSMessage = WSInitMessage | WSTaskUpdateMessage | WSPongMessage;
// Decision types
export type DecisionType = 'liked' | 'disliked';
// `seen` is a soft-hide: the listing is removed from the main list by default
// but can be re-revealed via the "Show hidden" filter toggle. Distinct from
// `disliked` — seen listings resurface automatically when their price changes.
export type DecisionType = 'liked' | 'disliked' | 'seen';
export interface ListingDecision {
id: number;
listing_id: number;
listing_type: 'RENT' | 'BUY';
decision: DecisionType;
// Total price at the time the user marked the listing seen. Only populated
// for decision='seen'; null otherwise.
price_at_decision?: number | null;
created_at: string;
updated_at: string;
}