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 (