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

@ -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')

View file

@ -17,14 +17,20 @@ decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"])
class SetDecisionRequest(BaseModel): 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'") 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): class DecisionResponse(BaseModel):
listing_id: int listing_id: int
listing_type: str listing_type: str
decision: str decision: str
price_at_decision: float | None = None
created_at: str created_at: str
updated_at: str updated_at: str
@ -46,6 +52,7 @@ def _to_response(d: decision_service.ListingDecision) -> DecisionResponse:
listing_id=d.listing_id, listing_id=d.listing_id,
listing_type=d.listing_type, listing_type=d.listing_type,
decision=d.decision, decision=d.decision,
price_at_decision=d.price_at_decision,
created_at=d.created_at.isoformat(), created_at=d.created_at.isoformat(),
updated_at=d.updated_at.isoformat(), updated_at=d.updated_at.isoformat(),
) )
@ -73,6 +80,7 @@ async def set_decision(
listing_id=listing_id, listing_id=listing_id,
listing_type=body.listing_type, listing_type=body.listing_type,
decision=body.decision, decision=body.decision,
price_at_decision=body.price_at_decision,
) )
timings.append(f"upsert;dur={(time.monotonic() - t0) * 1000:.1f}") timings.append(f"upsert;dur={(time.monotonic() - t0) * 1000:.1f}")
except ValueError as e: except ValueError as e:

View file

@ -130,7 +130,29 @@ function AppContent() {
const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("148px"); const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("148px");
// Decision state (like/dislike) // 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") // Explicit task ID set by fetch-data action (to track as "active")
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null); 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) { if (isDecisionsLoaded) {
features = features.filter((f) => { features = features.filter((f) => {
const parts = f.properties.url.split('/'); const parts = f.properties.url.split('/');
const id = parseInt(parts[parts.length - 1], 10); const id = parseInt(parts[parts.length - 1], 10);
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; 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 }; 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 // Compute the effective metric string for the heatmap
const effectiveMetric = useMemo(() => { const effectiveMetric = useMemo(() => {
@ -792,6 +850,9 @@ function AppContent() {
onTaskCreated={handlePOITaskCreated} onTaskCreated={handlePOITaskCreated}
initialValues={initialFilterValuesRef.current} initialValues={initialFilterValuesRef.current}
onFormReady={handleFilterBarFormReady} onFormReady={handleFilterBarFormReady}
showHidden={showHidden}
onToggleShowHidden={() => setShowHidden((v) => !v)}
hiddenCount={hiddenCount}
/> />
</div> </div>

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from '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 { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
@ -70,6 +70,12 @@ interface FilterBarProps {
initialValues?: ParameterValues; initialValues?: ParameterValues;
/** Provides parent access to the form handle (e.g. for chip-remove resets). */ /** Provides parent access to the form handle (e.g. for chip-remove resets). */
onFormReady?: (handle: FilterBarFormHandle) => void; 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 ── // ── Helpers ──
@ -140,6 +146,9 @@ export function FilterBar({
onTaskCreated, onTaskCreated,
initialValues, initialValues,
onFormReady, onFormReady,
showHidden = false,
onToggleShowHidden,
hiddenCount = 0,
}: FilterBarProps) { }: FilterBarProps) {
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>( const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>(
initialValues?.furnish_types ?? [], initialValues?.furnish_types ?? [],
@ -711,6 +720,24 @@ export function FilterBar({
{/* ── Spacer ── */} {/* ── Spacer ── */}
<div className="flex-1" /> <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) ── */} {/* ── Action Buttons (right side) ── */}
<Button <Button
type="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}`; 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) { 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); const [isLoaded, setIsLoaded] = useState(false);
// Load decisions on mount // Load decisions on mount
@ -16,9 +23,12 @@ export function useDecisions(user: AuthUser | null) {
if (!user) return; if (!user) return;
fetchDecisions(user) fetchDecisions(user)
.then((list) => { .then((list) => {
const map = new Map<string, DecisionType>(); const map = new Map<string, DecisionEntry>();
for (const d of list) { 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); setDecisions(map);
setIsLoaded(true); setIsLoaded(true);
@ -27,19 +37,28 @@ export function useDecisions(user: AuthUser | null) {
}, [user]); }, [user]);
const decide = useCallback( 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; if (!user) return;
const key = decisionKey(listingId, listingType); const key = decisionKey(listingId, listingType);
const entry: DecisionEntry = {
decision,
price_at_decision: decision === 'seen' ? priceAtDecision ?? null : null,
};
// Optimistic update // Optimistic update
setDecisions((prev) => { setDecisions((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(key, decision); next.set(key, entry);
return next; return next;
}); });
try { try {
await apiSetDecision(user, listingId, decision, listingType); await apiSetDecision(user, listingId, decision, listingType, priceAtDecision);
} catch { } catch {
// Revert on failure // Revert on failure
setDecisions((prev) => { setDecisions((prev) => {
@ -79,22 +98,66 @@ export function useDecisions(user: AuthUser | null) {
[user, decisions], [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( const getDecision = useCallback(
(listingId: number, listingType: string = 'RENT'): DecisionType | undefined => { (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)); return decisions.get(decisionKey(listingId, listingType));
}, },
[decisions], [decisions],
); );
const likedCount = useMemo( const likedCount = useMemo(
() => Array.from(decisions.values()).filter((d) => d === 'liked').length, () => Array.from(decisions.values()).filter((d) => d.decision === 'liked').length,
[decisions], [decisions],
); );
const dislikedCount = useMemo( const dislikedCount = useMemo(
() => Array.from(decisions.values()).filter((d) => d === 'disliked').length, () => Array.from(decisions.values()).filter((d) => d.decision === 'disliked').length,
[decisions], [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 { AuthUser } from '@/auth/types';
import type { ListingDecision } from '@/types'; import type { DecisionType, ListingDecision } from '@/types';
import { apiRequest } from './apiClient'; import { apiRequest } from './apiClient';
export async function fetchDecisions(user: AuthUser): Promise<ListingDecision[]> { export async function fetchDecisions(user: AuthUser): Promise<ListingDecision[]> {
@ -11,12 +11,19 @@ export async function fetchDecisions(user: AuthUser): Promise<ListingDecision[]>
export async function setDecision( export async function setDecision(
user: AuthUser, user: AuthUser,
listingId: number, listingId: number,
decision: 'liked' | 'disliked', decision: DecisionType,
listingType: 'RENT' | 'BUY' = 'RENT', listingType: 'RENT' | 'BUY' = 'RENT',
priceAtDecision?: number | null,
): Promise<ListingDecision> { ): 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}`, { return apiRequest<ListingDecision>(user, `/api/decisions/${listingId}`, {
method: 'PUT', method: 'PUT',
body: { decision, listing_type: listingType }, body,
}); });
} }

View file

@ -182,13 +182,19 @@ export interface WSPongMessage {
export type WSMessage = WSInitMessage | WSTaskUpdateMessage | WSPongMessage; export type WSMessage = WSInitMessage | WSTaskUpdateMessage | WSPongMessage;
// Decision types // 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 { export interface ListingDecision {
id: number; id: number;
listing_id: number; listing_id: number;
listing_type: 'RENT' | 'BUY'; listing_type: 'RENT' | 'BUY';
decision: DecisionType; 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; created_at: string;
updated_at: string; updated_at: string;
} }

View file

@ -15,6 +15,10 @@ class ListingDecision(SQLModel, table=True):
user_id: int = Field(nullable=False, foreign_key="user.id", index=True) user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
listing_id: int = Field(nullable=False, index=True) listing_id: int = Field(nullable=False, index=True)
listing_type: str = Field(nullable=False) # "RENT" or "BUY" listing_type: str = Field(nullable=False) # "RENT" or "BUY"
decision: str = Field(nullable=False) # "liked" or "disliked" decision: str = Field(nullable=False) # "liked" | "disliked" | "seen"
# Total price at the time the user marked the listing seen. Only set for
# decision="seen"; used client-side to resurface listings whose price has
# changed since the user dismissed them.
price_at_decision: float | None = Field(default=None, nullable=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)

View file

@ -19,6 +19,7 @@ class DecisionRepository:
listing_id: int, listing_id: int,
listing_type: str, listing_type: str,
decision: str, decision: str,
price_at_decision: float | None = None,
) -> ListingDecision: ) -> ListingDecision:
"""Create or update a decision. Uses dialect-specific upsert.""" """Create or update a decision. Uses dialect-specific upsert."""
t0 = time.monotonic() t0 = time.monotonic()
@ -29,6 +30,7 @@ class DecisionRepository:
"listing_id": listing_id, "listing_id": listing_id,
"listing_type": listing_type, "listing_type": listing_type,
"decision": decision, "decision": decision,
"price_at_decision": price_at_decision,
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
} }
@ -38,6 +40,7 @@ class DecisionRepository:
stmt = mysql_insert(ListingDecision).values(**values) stmt = mysql_insert(ListingDecision).values(**values)
stmt = stmt.on_duplicate_key_update( stmt = stmt.on_duplicate_key_update(
decision=stmt.inserted.decision, decision=stmt.inserted.decision,
price_at_decision=stmt.inserted.price_at_decision,
updated_at=stmt.inserted.updated_at, updated_at=stmt.inserted.updated_at,
) )
else: else:
@ -47,6 +50,7 @@ class DecisionRepository:
index_elements=["user_id", "listing_id", "listing_type"], index_elements=["user_id", "listing_id", "listing_type"],
set_={ set_={
"decision": stmt.excluded.decision, "decision": stmt.excluded.decision,
"price_at_decision": stmt.excluded.price_at_decision,
"updated_at": stmt.excluded.updated_at, "updated_at": stmt.excluded.updated_at,
}, },
) )

View file

@ -6,7 +6,7 @@ This module provides the core business logic for listing decision operations
from models.decision import ListingDecision from models.decision import ListingDecision
from repositories.decision_repository import DecisionRepository from repositories.decision_repository import DecisionRepository
VALID_DECISIONS = {"liked", "disliked"} VALID_DECISIONS = {"liked", "disliked", "seen"}
VALID_LISTING_TYPES = {"RENT", "BUY"} VALID_LISTING_TYPES = {"RENT", "BUY"}
@ -16,8 +16,13 @@ def set_decision(
listing_id: int, listing_id: int,
listing_type: str, listing_type: str,
decision: str, decision: str,
price_at_decision: float | None = None,
) -> ListingDecision: ) -> ListingDecision:
"""Set or update a like/dislike decision for a listing.""" """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: if decision not in VALID_DECISIONS:
raise ValueError( raise ValueError(
f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}" f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}"
@ -26,7 +31,12 @@ def set_decision(
raise ValueError( raise ValueError(
f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}" f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
) )
return repository.upsert_decision(user_id, listing_id, listing_type, decision) # 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( def get_user_decisions(

View file

@ -17,8 +17,9 @@ class TestSetDecision:
listing_type="RENT", decision="liked", listing_type="RENT", decision="liked",
) )
assert result.decision == "liked" assert result.decision == "liked"
# 5th arg is the price column; liked/disliked decisions drop it.
repo.upsert_decision.assert_called_once_with( repo.upsert_decision.assert_called_once_with(
1, 100, "RENT", "liked" 1, 100, "RENT", "liked", None
) )
def test_set_disliked(self) -> None: def test_set_disliked(self) -> None:
@ -48,6 +49,42 @@ class TestSetDecision:
listing_type="SELL", decision="liked", listing_type="SELL", decision="liked",
) )
def test_set_seen_carries_price(self) -> None:
"""`seen` decisions persist the price-at-mark so the client can
resurface listings whose price has changed since dismissal."""
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT",
decision="seen", price_at_decision=2500.0,
)
result = decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="seen",
price_at_decision=2500.0,
)
assert result.decision == "seen"
assert result.price_at_decision == 2500.0
repo.upsert_decision.assert_called_once_with(
1, 100, "RENT", "seen", 2500.0
)
def test_liked_drops_price_even_if_supplied(self) -> None:
"""price_at_decision is only meaningful for seen — liked/disliked must
ignore it so the column stays null on those rows."""
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT",
decision="liked",
)
decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="liked",
price_at_decision=999.0,
)
repo.upsert_decision.assert_called_once_with(
1, 100, "RENT", "liked", None
)
class TestGetDecisions: class TestGetDecisions:
def test_returns_all_decisions(self) -> None: def test_returns_all_decisions(self) -> None: