wrongmove: guard property cards against null backend fields (fix BUY crash)

BUY listings can come back from the API with null `total_price`, `qm`,
`qmprice`, `rooms`, and `last_seen` even though the TypeScript types
declare them non-nullable. The cards called `.toLocaleString()` and
`.split('T')` on those values, throwing TypeError and tripping the
ErrorBoundary — which is the "BUY part of the page crashes" symptom.

Coerce numeric fields via a `safeNum` helper (0 fallback), gate the
"Nd ago" line on a finite last_seen, and apply the same guards in
PropertyCard, PropertyCardCompact, SwipeCard, and the price-history
section of ListingDetail. Added regression tests asserting both card
variants render with all-null backend fields without throwing.
This commit is contained in:
Viktor Barzin 2026-05-10 21:17:41 +00:00
parent 73823bd381
commit 0b5308200e
6 changed files with 97 additions and 30 deletions

View file

@ -214,8 +214,12 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
<div className="space-y-2">
{detail.price_history.map((entry) => (
<div key={entry.id} className="flex justify-between items-center text-sm py-1.5 border-b last:border-0">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span className="font-medium">£{entry.price.toLocaleString()}</span>
<span className="text-muted-foreground">
{typeof entry.last_seen === 'string' ? entry.last_seen.split('T')[0] : '—'}
</span>
<span className="font-medium">
£{typeof entry.price === 'number' && Number.isFinite(entry.price) ? entry.price.toLocaleString() : '—'}
</span>
</div>
))}
</div>

View file

@ -145,8 +145,18 @@ export function PropertyCard({
allPOIs,
onClick,
}: PropertyCardProps) {
const lastSeenDate = property.last_seen.split('T')[0];
const lastSeenDays = Math.round((Date.now() - new Date(lastSeenDate).getTime()) / (1000 * 60 * 60 * 24));
// BUY listings may have null numeric / date fields; coerce so renders don't throw.
const lastSeenRaw = property.last_seen;
const lastSeenDate = typeof lastSeenRaw === 'string' ? lastSeenRaw.split('T')[0] : null;
const lastSeenTime = lastSeenDate ? new Date(lastSeenDate).getTime() : NaN;
const lastSeenDays = Number.isFinite(lastSeenTime)
? Math.round((Date.now() - lastSeenTime) / (1000 * 60 * 60 * 24))
: null;
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const safeTotalPrice = safeNum(property.total_price);
const safeQm = safeNum(property.qm);
const safeQmprice = safeNum(property.qmprice);
const safeRooms = safeNum(property.rooms);
// Determine if this is a good deal
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
@ -175,7 +185,7 @@ export function PropertyCard({
{(property.photos?.length || property.photo_thumbnail) ? (
<CardCarousel
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
altText={`${safeRooms}-bed, ${safeQm}m², £${safeTotalPrice.toLocaleString()}`}
/>
) : null}
</div>
@ -185,7 +195,7 @@ export function PropertyCard({
{/* Price */}
<div className="flex items-center gap-1.5">
<span className="text-lg font-bold tracking-tight">
£{property.total_price.toLocaleString()}
£{safeTotalPrice.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
@ -200,18 +210,22 @@ export function PropertyCard({
{/* Key metrics on one line */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
<span>{property.rooms}</span><span>bed</span>
<span>{safeRooms}</span><span>bed</span>
<span>·</span>
<span>{property.qm} m²</span>
<span>{safeQm} m²</span>
<span>·</span>
<span>£{property.qmprice}/m²</span>
<span>£{safeQmprice}/m²</span>
</div>
{/* Agency + freshness */}
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<span>{property.agency}</span>
<span>·</span>
<span>{lastSeenDays}d ago</span>
{lastSeenDays !== null && (
<>
<span>·</span>
<span>{lastSeenDays}d ago</span>
</>
)}
</div>
{/* POI badges */}
@ -234,7 +248,7 @@ export function PropertyCard({
{(property.photos?.length || property.photo_thumbnail) ? (
<CardCarousel
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
altText={`${safeRooms}-bed, ${safeQm}m², £${safeTotalPrice.toLocaleString()}`}
/>
) : null}
@ -257,7 +271,7 @@ export function PropertyCard({
{/* Price as dominant element */}
<div className="flex items-center gap-1.5">
<span className="text-lg font-bold tracking-tight">
£{property.total_price.toLocaleString()}
£{safeTotalPrice.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
@ -272,11 +286,11 @@ export function PropertyCard({
{/* Key metrics on one line */}
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>{property.rooms}</span><span>bed</span>
<span>{safeRooms}</span><span>bed</span>
<span>·</span>
<span>{property.qm} m²</span>
<span>{safeQm} m²</span>
<span>·</span>
<span>£{property.qmprice}/m²</span>
<span>£{safeQmprice}/m²</span>
</div>
{/* Location */}
@ -294,8 +308,12 @@ export function PropertyCard({
{/* Agency + freshness */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{property.agency}</span>
<span>·</span>
<span>{lastSeenDays}d ago</span>
{lastSeenDays !== null && (
<>
<span>·</span>
<span>{lastSeenDays}d ago</span>
</>
)}
</div>
</div>
</div>

View file

@ -16,8 +16,15 @@ export function PropertyCardCompact({
avgPricePerSqm,
onClick,
}: PropertyCardCompactProps) {
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
// BUY listings may have null numeric fields; coerce so renders don't throw.
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const safeTotalPrice = safeNum(property.total_price);
const safeQm = safeNum(property.qm);
const safeQmprice = safeNum(property.qmprice);
const safeRooms = safeNum(property.rooms);
const isGoodDeal = avgPricePerSqm && safeQmprice > 0 && safeQmprice < avgPricePerSqm * 0.9;
const isExpensive = avgPricePerSqm && safeQmprice > avgPricePerSqm * 1.1;
const priceIndicator = isGoodDeal
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
@ -37,7 +44,7 @@ export function PropertyCardCompact({
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt={`${property.rooms}-bed, £${property.total_price.toLocaleString()}`}
alt={`${safeRooms}-bed, £${safeTotalPrice.toLocaleString()}`}
className="w-full h-full object-cover"
/>
)}
@ -48,7 +55,7 @@ export function PropertyCardCompact({
{/* Price bold */}
<div className="flex items-center gap-1.5">
<span className="font-bold text-base">
£{property.total_price.toLocaleString()}
£{safeTotalPrice.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
@ -62,10 +69,10 @@ export function PropertyCardCompact({
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Bed className="h-3.5 w-3.5" />
{property.rooms} bed
{safeRooms} bed
</span>
<span>·</span>
<span>{property.qm} m²</span>
<span>{safeQm} m²</span>
</div>
{/* Location */}

View file

@ -19,6 +19,12 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
const hasSwiped = useRef(false);
const p = feature.properties;
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
// BUY listings may have null numeric fields; coerce so renders don't throw.
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const safeTotalPrice = safeNum(p.total_price);
const safeQm = safeNum(p.qm);
const safeQmprice = safeNum(p.qmprice);
const safeRooms = safeNum(p.rooms);
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
@ -150,7 +156,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
</div>
</>
) : photos.length === 1 ? (
<img src={photos[0]} alt={`${p.rooms}-bed, ${p.qm}m², £${p.total_price.toLocaleString()}`} className="w-full h-full object-cover" />
<img src={photos[0]} alt={`${safeRooms}-bed, ${safeQm}m², £${safeTotalPrice.toLocaleString()}`} className="w-full h-full object-cover" />
) : null}
<button
className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"
@ -171,7 +177,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
>
{/* Price */}
<div className="text-2xl font-semibold">
£{p.total_price.toLocaleString()}
£{safeTotalPrice.toLocaleString()}
{p.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-base">/mo</span>
)}
@ -180,12 +186,12 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
{/* Key stats */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Bed className="h-4 w-4" /> {p.rooms} bed
<Bed className="h-4 w-4" /> {safeRooms} bed
</span>
<span className="flex items-center gap-1">
<Maximize2 className="h-4 w-4" /> {p.qm} m²
<Maximize2 className="h-4 w-4" /> {safeQm} m²
</span>
<span>£{p.qmprice}/m²</span>
<span>£{safeQmprice}/m²</span>
</div>
{/* Agency & availability */}

View file

@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PropertyCard } from '@/components/PropertyCard';
import { createMockProperty } from '@/__tests__/helpers';
import type { PropertyProperties } from '@/types';
describe('PropertyCard', () => {
it('renders rent price with /mo suffix', () => {
@ -88,4 +89,35 @@ describe('PropertyCard', () => {
const { container } = render(<PropertyCard property={property} isHighlighted />);
expect(container.querySelector('.ring-2')).toBeInTheDocument();
});
it('renders without crashing when BUY listing has null numeric/date fields', () => {
// Regression: backend returns null for total_price/qm/qmprice/rooms/last_seen on some BUY listings.
const partial = {
...createMockProperty({ listing_type: 'BUY' }),
total_price: null,
qm: null,
qmprice: null,
rooms: null,
last_seen: null,
} as unknown as PropertyProperties;
expect(() => render(<PropertyCard property={partial} />)).not.toThrow();
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
expect(screen.queryByText(/d ago/)).not.toBeInTheDocument();
});
it('renders full variant without crashing when BUY listing has null fields', () => {
const partial = {
...createMockProperty({ listing_type: 'BUY' }),
total_price: null,
qm: null,
qmprice: null,
rooms: null,
last_seen: null,
} as unknown as PropertyProperties;
expect(() => render(<PropertyCard property={partial} variant="full" />)).not.toThrow();
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
expect(screen.queryByText(/d ago/)).not.toBeInTheDocument();
});
});

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/errorboundary.tsx","./src/components/favoritesview.tsx","./src/components/filterbar.tsx","./src/components/filterchips.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/usefilterparams.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingcache.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/format.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/utils/taskutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authService.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyService.ts","./src/auth/types.ts","./src/components/AlertError.tsx","./src/components/AuthCallback.tsx","./src/components/ErrorBoundary.tsx","./src/components/FavoritesView.tsx","./src/components/FilterBar.tsx","./src/components/FilterChips.tsx","./src/components/FilterPanel.tsx","./src/components/Header.tsx","./src/components/HealthIndicator.tsx","./src/components/ListView.tsx","./src/components/ListingDetail.tsx","./src/components/ListingDetailSheet.tsx","./src/components/LoginModal.tsx","./src/components/Map.tsx","./src/components/MobileBottomSheet.tsx","./src/components/MobileMenu.tsx","./src/components/POIManager.tsx","./src/components/PhotoCarousel.tsx","./src/components/PropertyCard.tsx","./src/components/PropertyCardCompact.tsx","./src/components/Spinner.tsx","./src/components/StatsBar.tsx","./src/components/StreamingProgressBar.tsx","./src/components/SwipeCard.tsx","./src/components/SwipeReviewMode.tsx","./src/components/SwipeableCardRow.tsx","./src/components/SwipeablePropertyCard.tsx","./src/components/TaskIndicator.tsx","./src/components/TaskProgressDrawer.tsx","./src/components/VisualizationCard.tsx","./src/components/ui/DatePicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorSchemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/useDecisions.ts","./src/hooks/useFilterParams.ts","./src/hooks/useListingDetail.ts","./src/hooks/useTaskProgress.ts","./src/lib/utils.ts","./src/services/apiClient.ts","./src/services/decisionService.ts","./src/services/healthService.ts","./src/services/index.ts","./src/services/listingCache.ts","./src/services/listingDetailService.ts","./src/services/listingService.ts","./src/services/perfCollector.ts","./src/services/poiService.ts","./src/services/streamingService.ts","./src/services/taskService.ts","./src/types/index.ts","./src/utils/format.ts","./src/utils/mapUtils.ts","./src/utils/poiUtils.ts","./src/utils/taskUtils.ts","./src/workers/HexgridHeatmapClient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}