@@ -4,7 +4,6 @@ import { parseDateOrdinal, TimelineBar } from './MarkmapView';
44import { findDateIndex } from '../hooks/useTimelineCutoff' ;
55import type { TimelineRange } from '../hooks/useTimelineCutoff' ;
66import { queryCompetitorsAI } from '../api' ;
7- import { ReactSearchAutocomplete } from 'react-search-autocomplete' ;
87
98interface CompetitorViewProps {
109 data : LandscapeData ;
@@ -218,36 +217,26 @@ function TableView({ competitors, onHover, onLeave }: {
218217const HISTORY_KEY = 'ai-competitor-search-history' ;
219218const MAX_HISTORY = 10 ;
220219
221- interface SearchItem {
220+ interface HistoryItem {
222221 id : number ;
223222 name : string ;
224- type : 'history' | 'suggestion' ;
225223}
226224
227- const PRESET_SUGGESTIONS : SearchItem [ ] = [
228- { id : 1001 , name : 'Which companies use AI for compliance?' , type : 'suggestion' } ,
229- { id : 1002 , name : 'High threat competitors targeting CNAs' , type : 'suggestion' } ,
230- { id : 1003 , name : 'Compare pricing models across competitors' , type : 'suggestion' } ,
231- { id : 1004 , name : 'Companies serving both CNA and RN markets' , type : 'suggestion' } ,
232- { id : 1005 , name : 'Well-funded competitors with AI capabilities' , type : 'suggestion' } ,
233- { id : 1006 , name : 'What are the key differentiators of top threats?' , type : 'suggestion' } ,
234- ] ;
235-
236- function loadHistory ( ) : SearchItem [ ] {
225+ function loadHistory ( ) : HistoryItem [ ] {
237226 try {
238227 const raw = localStorage . getItem ( HISTORY_KEY ) ;
239228 if ( ! raw ) return [ ] ;
240- return JSON . parse ( raw ) as SearchItem [ ] ;
229+ return JSON . parse ( raw ) as HistoryItem [ ] ;
241230 } catch { return [ ] ; }
242231}
243232
244- function saveHistory ( items : SearchItem [ ] ) {
233+ function saveHistory ( items : HistoryItem [ ] ) {
245234 localStorage . setItem ( HISTORY_KEY , JSON . stringify ( items . slice ( 0 , MAX_HISTORY ) ) ) ;
246235}
247236
248237function addToHistory ( query : string ) {
249238 const history = loadHistory ( ) . filter ( h => h . name !== query ) ;
250- const newItem : SearchItem = { id : Date . now ( ) , name : query , type : 'history' } ;
239+ const newItem : HistoryItem = { id : Date . now ( ) , name : query } ;
251240 saveHistory ( [ newItem , ...history ] ) ;
252241}
253242
@@ -364,28 +353,30 @@ function AIResultModal({ result, competitors, onClose }: {
364353
365354/* ── Main component ─────────────────────────────────────── */
366355
356+ /* ── Quick filter types ─────────────────────────────────── */
357+
358+ type ThreatFilter = 'high' | 'medium' | 'low' ;
359+ type BoolFilter = 'uses_ai' | 'serves_cna' | 'serves_rn' ;
360+
367361export function CompetitorView ( { data, timelineRange, onTimelineRangeChange } : CompetitorViewProps ) {
368362 const [ view , setView ] = useState < 'map' | 'table' > ( 'map' ) ;
369363 const [ tooltip , setTooltip ] = useState < TooltipState | null > ( null ) ;
370364 const hideTimer = useRef < ReturnType < typeof setTimeout > > ( undefined ) ;
371365
372- // AI query state
373- const [ aiQuery , setAiQuery ] = useState ( '' ) ;
366+ // Quick filter state
367+ const [ textFilter , setTextFilter ] = useState ( '' ) ;
368+ const [ threatFilters , setThreatFilters ] = useState < Set < ThreatFilter > > ( new Set ( ) ) ;
369+ const [ boolFilters , setBoolFilters ] = useState < Set < BoolFilter > > ( new Set ( ) ) ;
370+ const [ sectionFilter , setSectionFilter ] = useState < string > ( '' ) ;
371+
372+ // AI query state (uses textFilter as the shared input)
374373 const [ aiLoading , setAiLoading ] = useState ( false ) ;
375374 const [ aiResult , setAiResult ] = useState < AIQueryResult | null > ( null ) ;
376375 const [ aiError , setAiError ] = useState < string | null > ( null ) ;
377376
378- // Autocomplete items: history + suggestions
379- const [ searchItems , setSearchItems ] = useState < SearchItem [ ] > ( [ ] ) ;
380-
381- useEffect ( ( ) => {
382- setSearchItems ( [ ...loadHistory ( ) , ...PRESET_SUGGESTIONS ] ) ;
383- } , [ ] ) ;
384-
385377 const fireQuery = useCallback ( async ( query : string ) => {
386378 if ( ! query . trim ( ) || aiLoading ) return ;
387379 addToHistory ( query ) ;
388- setSearchItems ( [ ...loadHistory ( ) , ...PRESET_SUGGESTIONS ] ) ;
389380 setAiLoading ( true ) ;
390381 setAiError ( null ) ;
391382 try {
@@ -443,7 +434,66 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
443434 } ) ;
444435 } , [ data . competitors , allDates , startIndex , endIndex , getDate ] ) ;
445436
446- const sections = useMemo ( ( ) => groupBySection ( filtered ) , [ filtered ] ) ;
437+ // Apply quick filters on top of date-filtered results
438+ const quickFiltered = useMemo ( ( ) => {
439+ let result = filtered ;
440+ // Text filter (name, category, primary_focus)
441+ if ( textFilter . trim ( ) ) {
442+ const q = textFilter . toLowerCase ( ) ;
443+ result = result . filter ( c =>
444+ c . name . toLowerCase ( ) . includes ( q ) ||
445+ ( c . category || '' ) . toLowerCase ( ) . includes ( q ) ||
446+ ( c . primary_focus || '' ) . toLowerCase ( ) . includes ( q ) ||
447+ ( c . subcategory || '' ) . toLowerCase ( ) . includes ( q )
448+ ) ;
449+ }
450+ // Threat level filter
451+ if ( threatFilters . size > 0 ) {
452+ result = result . filter ( c => threatFilters . has ( c . threat ) ) ;
453+ }
454+ // Boolean attribute filters (AND — must match all selected)
455+ if ( boolFilters . has ( 'uses_ai' ) ) result = result . filter ( c => c . uses_ai ) ;
456+ if ( boolFilters . has ( 'serves_cna' ) ) result = result . filter ( c => c . serves_cna ) ;
457+ if ( boolFilters . has ( 'serves_rn' ) ) result = result . filter ( c => c . serves_rn ) ;
458+ // Section filter
459+ if ( sectionFilter ) {
460+ result = result . filter ( c => c . section === sectionFilter ) ;
461+ }
462+ return result ;
463+ } , [ filtered , textFilter , threatFilters , boolFilters , sectionFilter ] ) ;
464+
465+ // Unique sections for dropdown
466+ const sectionOptions = useMemo ( ( ) => {
467+ const set = new Set ( filtered . map ( c => c . section ) ) ;
468+ return Array . from ( set ) . sort ( ) ;
469+ } , [ filtered ] ) ;
470+
471+ const hasActiveFilters = textFilter . trim ( ) || threatFilters . size > 0 || boolFilters . size > 0 || sectionFilter ;
472+
473+ const toggleThreat = ( t : ThreatFilter ) => {
474+ setThreatFilters ( prev => {
475+ const next = new Set ( prev ) ;
476+ if ( next . has ( t ) ) next . delete ( t ) ; else next . add ( t ) ;
477+ return next ;
478+ } ) ;
479+ } ;
480+
481+ const toggleBool = ( b : BoolFilter ) => {
482+ setBoolFilters ( prev => {
483+ const next = new Set ( prev ) ;
484+ if ( next . has ( b ) ) next . delete ( b ) ; else next . add ( b ) ;
485+ return next ;
486+ } ) ;
487+ } ;
488+
489+ const clearFilters = ( ) => {
490+ setTextFilter ( '' ) ;
491+ setThreatFilters ( new Set ( ) ) ;
492+ setBoolFilters ( new Set ( ) ) ;
493+ setSectionFilter ( '' ) ;
494+ } ;
495+
496+ const sections = useMemo ( ( ) => groupBySection ( quickFiltered ) , [ quickFiltered ] ) ;
447497
448498 const showTooltip = useCallback ( ( row : CompetitorRow , e : React . MouseEvent ) => {
449499 clearTimeout ( hideTimer . current ) ;
@@ -457,15 +507,6 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
457507
458508 const { meta } = data ;
459509
460- const formatResult = ( item : SearchItem ) => (
461- < div className = "ai-search-result-item" >
462- < span className = { `ai-search-icon ${ item . type } ` } >
463- { item . type === 'history' ? '\u23F3' : '\u2728' }
464- </ span >
465- < span > { item . name } </ span >
466- </ div >
467- ) ;
468-
469510 return (
470511 < div className = "competitor-view" >
471512 < div className = "competitor-scroll" >
@@ -474,52 +515,70 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
474515 < p > { meta . subtitle } { meta . last_update ? ` · Updated ${ meta . last_update } ` : '' } </ p >
475516 </ div >
476517
477- < div className = "ai-query-bar" >
478- { aiLoading && (
479- < div className = "ai-query-loading-overlay" >
480- < div className = "ai-query-spinner" />
481- < span > Analyzing competitors...</ span >
482- </ div >
483- ) }
484- < ReactSearchAutocomplete < SearchItem >
485- items = { searchItems }
486- onSearch = { ( string ) => setAiQuery ( string ) }
487- onSelect = { ( item ) => fireQuery ( item . name ) }
488- onClear = { ( ) => setAiQuery ( '' ) }
489- inputSearchString = { aiQuery }
490- placeholder = "Ask AI about competitors..."
491- formatResult = { formatResult }
492- showItemsOnFocus
493- maxResults = { 8 }
494- styling = { {
495- height : '44px' ,
496- border : '1px solid var(--border)' ,
497- borderRadius : '8px' ,
498- backgroundColor : 'var(--surface)' ,
499- color : 'var(--text)' ,
500- fontSize : '13px' ,
501- fontFamily : 'var(--font-body)' ,
502- iconColor : 'var(--purple)' ,
503- placeholderColor : 'var(--text3)' ,
504- hoverBackgroundColor : 'var(--purple-light)' ,
505- boxShadow : 'none' ,
506- clearIconMargin : '3px 8px 0 0' ,
507- zIndex : 10 ,
508- } }
509- fuseOptions = { { keys : [ 'name' ] , threshold : 0.4 } }
510- />
511- < button
512- className = "ai-query-btn"
513- disabled = { aiLoading || ! aiQuery . trim ( ) }
514- onClick = { ( ) => fireQuery ( aiQuery ) }
515- >
516- Ask AI
517- </ button >
518+ < div className = "competitor-quick-filters" >
519+ < div className = "ai-query-bar" >
520+ { aiLoading && (
521+ < div className = "ai-query-loading-overlay" >
522+ < div className = "ai-query-spinner" />
523+ < span > Analyzing competitors...</ span >
524+ </ div >
525+ ) }
526+ < input
527+ type = "text"
528+ className = "competitor-text-filter"
529+ placeholder = "Filter by name or ask AI..."
530+ value = { textFilter }
531+ onChange = { ( e ) => setTextFilter ( e . target . value ) }
532+ onKeyDown = { ( e ) => { if ( e . key === 'Enter' ) fireQuery ( textFilter ) ; } }
533+ />
534+ < button
535+ className = "ai-query-btn"
536+ disabled = { aiLoading || ! textFilter . trim ( ) }
537+ onClick = { ( ) => fireQuery ( textFilter ) }
538+ >
539+ Ask AI
540+ </ button >
541+ </ div >
542+ { aiError && < div className = "ai-query-error" > { aiError } </ div > }
543+ < div className = "competitor-filter-chips" >
544+ < span className = "competitor-filter-label" > Threat:</ span >
545+ { ( [ 'high' , 'medium' , 'low' ] as ThreatFilter [ ] ) . map ( t => (
546+ < button
547+ key = { t }
548+ className = { `competitor-filter-chip threat-${ t } ${ threatFilters . has ( t ) ? ' active' : '' } ` }
549+ onClick = { ( ) => toggleThreat ( t ) }
550+ >
551+ { t }
552+ </ button >
553+ ) ) }
554+ < span className = "competitor-filter-sep" />
555+ { ( [ [ 'uses_ai' , 'AI' ] , [ 'serves_cna' , 'CNA' ] , [ 'serves_rn' , 'RN' ] ] as [ BoolFilter , string ] [ ] ) . map ( ( [ key , label ] ) => (
556+ < button
557+ key = { key }
558+ className = { `competitor-filter-chip bool${ boolFilters . has ( key ) ? ' active' : '' } ` }
559+ onClick = { ( ) => toggleBool ( key ) }
560+ >
561+ { label }
562+ </ button >
563+ ) ) }
564+ < select
565+ className = "competitor-section-select"
566+ value = { sectionFilter }
567+ onChange = { ( e ) => setSectionFilter ( e . target . value ) }
568+ >
569+ < option value = "" > All sections</ option >
570+ { sectionOptions . map ( s => (
571+ < option key = { s } value = { s } > { s } </ option >
572+ ) ) }
573+ </ select >
574+ { hasActiveFilters && (
575+ < button className = "competitor-filter-clear" onClick = { clearFilters } > Clear</ button >
576+ ) }
577+ </ div >
518578 </ div >
519- { aiError && < div className = "ai-query-error" > { aiError } </ div > }
520579
521580 < div className = "landscape-toolbar" >
522- < span className = "landscape-count" > { filtered . length } companies{ allDates . length > 1 ? ` (of ${ data . competitors . length } )` : '' } </ span >
581+ < span className = "landscape-count" > { quickFiltered . length } companies{ quickFiltered . length !== data . competitors . length ? ` (of ${ data . competitors . length } )` : '' } </ span >
523582 < div className = "view-toggle" >
524583 < button className = { view === 'map' ? 'active' : '' } onClick = { ( ) => setView ( 'map' ) } > Map</ button >
525584 < button className = { view === 'table' ? 'active' : '' } onClick = { ( ) => setView ( 'table' ) } > Table</ button >
@@ -529,7 +588,7 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
529588 { view === 'map' ? (
530589 < MapView sections = { sections } onHover = { showTooltip } onLeave = { hideTooltip } />
531590 ) : (
532- < TableView competitors = { filtered } onHover = { showTooltip } onLeave = { hideTooltip } />
591+ < TableView competitors = { quickFiltered } onHover = { showTooltip } onLeave = { hideTooltip } />
533592 ) }
534593
535594 </ div >
0 commit comments