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:
parent
9a5ad7878c
commit
c2e08fe46e
13 changed files with 474 additions and 25 deletions
33
alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py
Normal file
33
alembic/versions/f7a8b9c0d1e2_add_seen_decision_and_price.py
Normal 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')
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
123
frontend/src/hooks/__tests__/useDecisions.test.tsx
Normal file
123
frontend/src/hooks/__tests__/useDecisions.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
66
frontend/src/services/__tests__/decisionService.test.ts
Normal file
66
frontend/src/services/__tests__/decisionService.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class ListingDecision(SQLModel, table=True):
|
|||
user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
|
||||
listing_id: int = Field(nullable=False, index=True)
|
||||
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)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class DecisionRepository:
|
|||
listing_id: int,
|
||||
listing_type: str,
|
||||
decision: str,
|
||||
price_at_decision: float | None = None,
|
||||
) -> ListingDecision:
|
||||
"""Create or update a decision. Uses dialect-specific upsert."""
|
||||
t0 = time.monotonic()
|
||||
|
|
@ -29,6 +30,7 @@ class DecisionRepository:
|
|||
"listing_id": listing_id,
|
||||
"listing_type": listing_type,
|
||||
"decision": decision,
|
||||
"price_at_decision": price_at_decision,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
|
@ -38,6 +40,7 @@ class DecisionRepository:
|
|||
stmt = mysql_insert(ListingDecision).values(**values)
|
||||
stmt = stmt.on_duplicate_key_update(
|
||||
decision=stmt.inserted.decision,
|
||||
price_at_decision=stmt.inserted.price_at_decision,
|
||||
updated_at=stmt.inserted.updated_at,
|
||||
)
|
||||
else:
|
||||
|
|
@ -47,6 +50,7 @@ class DecisionRepository:
|
|||
index_elements=["user_id", "listing_id", "listing_type"],
|
||||
set_={
|
||||
"decision": stmt.excluded.decision,
|
||||
"price_at_decision": stmt.excluded.price_at_decision,
|
||||
"updated_at": stmt.excluded.updated_at,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This module provides the core business logic for listing decision operations
|
|||
from models.decision import ListingDecision
|
||||
from repositories.decision_repository import DecisionRepository
|
||||
|
||||
VALID_DECISIONS = {"liked", "disliked"}
|
||||
VALID_DECISIONS = {"liked", "disliked", "seen"}
|
||||
VALID_LISTING_TYPES = {"RENT", "BUY"}
|
||||
|
||||
|
||||
|
|
@ -16,8 +16,13 @@ def set_decision(
|
|||
listing_id: int,
|
||||
listing_type: str,
|
||||
decision: str,
|
||||
price_at_decision: float | None = None,
|
||||
) -> 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:
|
||||
raise ValueError(
|
||||
f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}"
|
||||
|
|
@ -26,7 +31,12 @@ def set_decision(
|
|||
raise ValueError(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class TestSetDecision:
|
|||
listing_type="RENT", decision="liked",
|
||||
)
|
||||
assert result.decision == "liked"
|
||||
# 5th arg is the price column; liked/disliked decisions drop it.
|
||||
repo.upsert_decision.assert_called_once_with(
|
||||
1, 100, "RENT", "liked"
|
||||
1, 100, "RENT", "liked", None
|
||||
)
|
||||
|
||||
def test_set_disliked(self) -> None:
|
||||
|
|
@ -48,6 +49,42 @@ class TestSetDecision:
|
|||
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:
|
||||
def test_returns_all_decisions(self) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue