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:
parent
73823bd381
commit
0b5308200e
6 changed files with 97 additions and 30 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue