diff --git a/src/components/supply/PropertyIntelligenceCard.tsx b/src/components/supply/PropertyIntelligenceCard.tsx
index 1996b5e..5e669c0 100644
--- a/src/components/supply/PropertyIntelligenceCard.tsx
+++ b/src/components/supply/PropertyIntelligenceCard.tsx
@@ -1,3 +1,4 @@
+import { memo } from 'react'
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { LocationPreview } from '../shared/LocationPreview'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
@@ -14,7 +15,7 @@ function availabilityBadgeColor(status: string): string {
return '#64748b'
}
-export function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
+export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
const confPct = Math.round(p.confidenceScore * 100)
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
@@ -138,4 +139,4 @@ export function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
)
-}
+})
diff --git a/src/components/supply/ReminderFeed.tsx b/src/components/supply/ReminderFeed.tsx
index 111b391..3e3d574 100644
--- a/src/components/supply/ReminderFeed.tsx
+++ b/src/components/supply/ReminderFeed.tsx
@@ -1,3 +1,4 @@
+import { useMemo, useCallback } from 'react'
import { Box, Typography } from '@mui/material'
import { useReminders } from '../../hooks/useReminders'
import { useShallow } from 'zustand/react/shallow'
@@ -43,17 +44,21 @@ export function ReminderFeed() {
setSearchQuery: s.setSearchQuery,
})))
- if (isLoading) return
-
const reminders = data ?? []
- const filtered = applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery)
- function resetFilters() {
+ const filtered = useMemo(
+ () => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
+ [reminders, filterType, filterStatus, filterPriority, searchQuery],
+ )
+
+ const resetFilters = useCallback(() => {
setFilterType('ALL')
setFilterStatus('ALL')
setFilterPriority('ALL')
setSearchQuery('')
- }
+ }, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
+
+ if (isLoading) return
if (filtered.length === 0) {
return
diff --git a/src/components/supply/ReminderListRow.tsx b/src/components/supply/ReminderListRow.tsx
index 23e4b96..14136a6 100644
--- a/src/components/supply/ReminderListRow.tsx
+++ b/src/components/supply/ReminderListRow.tsx
@@ -1,3 +1,4 @@
+import { memo } from 'react'
import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material'
import { Check, Bell, X } from 'lucide-react'
import type { Reminder } from '../../domain/reminder'
@@ -26,7 +27,7 @@ interface Props {
reminder: Reminder
}
-export function ReminderListRow({ reminder }: Props) {
+export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) {
const { setSelectedId, setDrawerOpen } = useReminderStore()
const complete = useCompleteReminder()
const dismiss = useDismissReminder()
@@ -139,4 +140,4 @@ export function ReminderListRow({ reminder }: Props) {
)
-}
+})
diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx
index 5c49e17..041292c 100644
--- a/src/pages/demand/Results.tsx
+++ b/src/pages/demand/Results.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
@@ -61,36 +61,46 @@ export default function Results() {
const activeNeed = allNeeds.find(n => n.id === effectiveNeedId) ?? allNeeds[0]
- // Ensure query cache is invalidated when a new need was just created
- if (activeNeedIdFromNav) {
- queryClient.invalidateQueries({ queryKey: ['needs'] })
- }
+ // Invalidate when navigating from NeedBuilder — must be in an effect, not render body
+ useEffect(() => {
+ if (activeNeedIdFromNav) {
+ queryClient.invalidateQueries({ queryKey: ['needs'] })
+ }
+ }, [activeNeedIdFromNav, queryClient])
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
- const filtered = results.filter(r => {
- if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
- if (r.resultType === 'VERIFIED_PORTFOLIO') {
- if (!showOwnProperties) return false
- return filterSource === 'ALL' || filterSource === 'PLATFORM'
+ // Counts over the full result set — single pass, stable across filter changes
+ const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
+ let platform = 0, maison = 0, future = 0, missing = 0
+ for (const r of results) {
+ if (r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET') platform++
+ else if (r.resultType === 'MAISON_WORK') maison++
+ else if (r.resultType === 'FUTURE_AVAILABILITY') future++
+ if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
+ ((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
}
- if (filterSource === 'ALL') return true
- if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
- return r.resultType === filterSource // MAISON_WORK
- })
+ return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
+ }, [results])
- const sorted = sortResults(filtered, sortBy)
+ // Filter + sort in one memo — only reruns when inputs change
+ const sorted = useMemo(() => {
+ const filtered = results.filter(r => {
+ if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
+ if (r.resultType === 'VERIFIED_PORTFOLIO') {
+ if (!showOwnProperties) return false
+ return filterSource === 'ALL' || filterSource === 'PLATFORM'
+ }
+ if (filterSource === 'ALL') return true
+ if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
+ return r.resultType === filterSource
+ })
+ return sortResults(filtered, sortBy)
+ }, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
- const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length
- const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
- const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
- const strongCount = filtered.filter(r => r.matchScore >= 80).length
- const missingDataCount = results.filter(r =>
- 'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
- ((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0
- ).length
+ const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
return (
diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx
index 0abffed..0eb7efe 100644
--- a/src/pages/supply/Properties.tsx
+++ b/src/pages/supply/Properties.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useMemo } from 'react'
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout'
@@ -58,22 +58,37 @@ export default function Properties() {
)
const { data: properties = [], isLoading, isError } = useProperties()
- const filtered = applyFilters(properties, filters)
- // Decision-relevant aggregates
- const matchReady = properties.filter(
- p => (p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
- p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0
- )
- const criticalGaps = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
- const lowConfidence = properties.filter(p => p.confidenceScore < 0.55)
- const staleOrOutdated = properties.filter(
- p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED'
- )
+ const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
- const allMissingFields = [...new Set(
- properties.flatMap(p => p.dataQuality.missingCriticalFields)
- )].slice(0, 4)
+ // Decision-relevant aggregates — single pass over the full list
+ const { matchReady, criticalGaps, lowConfidence, staleOrOutdated, allMissingFields } = useMemo(() => {
+ const matchReady: typeof properties = []
+ const criticalGaps: typeof properties = []
+ const lowConfidence: typeof properties = []
+ const staleOrOutdated: typeof properties = []
+ const missingSet = new Set()
+
+ for (const p of properties) {
+ if ((p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
+ p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0)
+ matchReady.push(p)
+ if (p.dataQuality.missingCriticalFields.length > 0) {
+ criticalGaps.push(p)
+ p.dataQuality.missingCriticalFields.forEach(f => missingSet.add(f))
+ }
+ if (p.confidenceScore < 0.55) lowConfidence.push(p)
+ if (p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED') staleOrOutdated.push(p)
+ }
+
+ return {
+ matchReady,
+ criticalGaps,
+ lowConfidence,
+ staleOrOutdated,
+ allMissingFields: [...missingSet].slice(0, 4),
+ }
+ }, [properties])
return (