feat: F021 market intelligence & signal discovery

Signal domain types, mock data (8 signals), provider interface + MockupMarketIntelligenceProvider, marketIntelligenceService, TanStack Query hooks, 10 ops components (SignalInbox, MarketSignalCard, MarketSignalFilterBar, MarketSignalSkeleton, MarketSignalEmptyState, SignalConfidenceBadge, SourceReliabilityBadge, SensitivityWarningPanel, SignalConversionPanel, SignalEvidenceList), MarketIntelligence page wired into AppShell nav and App router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 17:07:58 +02:00
parent 3cd2e83b25
commit cb18f3c381
17 changed files with 1389 additions and 4 deletions
@@ -0,0 +1,13 @@
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
export interface IMarketIntelligenceProvider {
getSignals(filters?: MarketSignalFilters): Promise<MarketSignal[]>
getSignalById(id: string): Promise<MarketSignal | null>
updateSignalStatus(id: string, status: SignalProcessingStatus): Promise<MarketSignal>
convertToFutureSignal(id: string): Promise<{ futureSignalId: string }>
linkSignalToEntity(
id: string,
entityType: 'property' | 'need',
entityId: string,
): Promise<MarketSignal>
}
@@ -0,0 +1,70 @@
import type { IMarketIntelligenceProvider } from './IMarketIntelligenceProvider'
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
import { MOCK_MARKET_SIGNALS } from '../mock-data/marketSignals'
// Mutable in-memory copy for status updates
let signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS]
function applyFilters(data: MarketSignal[], filters?: MarketSignalFilters): MarketSignal[] {
if (!filters) return data
return data.filter((s) => {
if (filters.sourceCategory && s.sourceCategory !== filters.sourceCategory) return false
if (filters.processingStatus && s.processingStatus !== filters.processingStatus) return false
if (filters.sensitivityLevel && s.sensitivityLevel !== filters.sensitivityLevel) return false
if (filters.signalType && s.signalType !== filters.signalType) return false
if (filters.search) {
const q = filters.search.toLowerCase()
const match = s.title.toLowerCase().includes(q)
|| s.summary.toLowerCase().includes(q)
|| s.location.toLowerCase().includes(q)
if (!match) return false
}
return true
})
}
export const MockupMarketIntelligenceProvider: IMarketIntelligenceProvider = {
async getSignals(filters?: MarketSignalFilters): Promise<MarketSignal[]> {
return applyFilters(signals, filters)
},
async getSignalById(id: string): Promise<MarketSignal | null> {
return signals.find((s) => s.id === id) ?? null
},
async updateSignalStatus(id: string, status: SignalProcessingStatus): Promise<MarketSignal> {
const idx = signals.findIndex((s) => s.id === id)
if (idx === -1) throw new Error(`Signal ${id} not found`)
signals[idx] = { ...signals[idx], processingStatus: status, updatedAt: new Date().toISOString() }
return signals[idx]
},
async convertToFutureSignal(id: string): Promise<{ futureSignalId: string }> {
const futureSignalId = `fs-${id}-${Date.now()}`
const idx = signals.findIndex((s) => s.id === id)
if (idx !== -1) {
signals[idx] = {
...signals[idx],
processingStatus: 'CONVERTED_TO_FUTURE_AVAILABILITY',
possibleFutureSignalId: futureSignalId,
updatedAt: new Date().toISOString(),
}
}
return { futureSignalId }
},
async linkSignalToEntity(
id: string,
entityType: 'property' | 'need',
entityId: string,
): Promise<MarketSignal> {
const idx = signals.findIndex((s) => s.id === id)
if (idx === -1) throw new Error(`Signal ${id} not found`)
signals[idx] = {
...signals[idx],
...(entityType === 'property' ? { linkedPropertyId: entityId } : { linkedNeedId: entityId }),
updatedAt: new Date().toISOString(),
}
return signals[idx]
},
}