1- import { useEffect , useMemo , useState , type CSSProperties } from "react" ;
1+ import { useCallback , useEffect , useMemo , useState , type CSSProperties } from "react" ;
22import {
33 Bar ,
44 BarChart ,
55 CartesianGrid ,
66 Legend ,
77 Line ,
88 LineChart ,
9+ ReferenceLine ,
910 ResponsiveContainer ,
11+ Scatter ,
12+ ScatterChart ,
1013 Tooltip ,
1114 XAxis ,
1215 YAxis ,
16+ ZAxis ,
1317} from "recharts" ;
1418import {
1519 abortExperiment ,
@@ -20,13 +24,26 @@ import {
2024 type ExperimentStratum ,
2125 type ExperimentSummary ,
2226 type ExperimentPipeline ,
27+ type PromotedComparisonPoint ,
2328} from "../api/client" ;
24- import { useExperiments , useExperimentDetail , useExperimentSettings } from "../hooks/useExperiments" ;
29+ import { useExperiments , useExperimentDetail , useExperimentSettings , usePromotedComparison } from "../hooks/useExperiments" ;
2530import { useAuth } from "../hooks/useAuth" ;
2631import ExperimentSettings from "./ExperimentSettings" ;
2732
2833const CHALLENGER_COLOR = "#00e5c8" ;
2934const CONTROL_COLOR = "#f0a030" ;
35+ // Promoted-vs-original scatter: a promotion still beating its original is a win
36+ // (green, matching the "promoted" lifecycle color), still trailing is a loss.
37+ const WIN_COLOR = "#20e070" ;
38+ const LOSS_COLOR = "#ff4060" ;
39+
40+ // Selectable now-relative windows for the promoted-vs-original scatter.
41+ const COMPARISON_WINDOWS : Array < { label : string ; hours : number } > = [
42+ { label : "6h" , hours : 6 } ,
43+ { label : "24h" , hours : 24 } ,
44+ { label : "7d" , hours : 168 } ,
45+ { label : "30d" , hours : 720 } ,
46+ ] ;
3047
3148// Lifecycle status → display color, matching the dashboard's accent palette.
3249const STATUS_COLORS : Record < string , string > = {
@@ -92,27 +109,47 @@ function StatusBadge({ status }: { status: string }) {
92109
93110// ── Lifecycle pipeline strip ──
94111
95- function PipelineStrip ( { pipeline } : { pipeline : ExperimentPipeline | null } ) {
112+ // PipelineStrip doubles as the table's status filter: each stage card is a toggle
113+ // button. A stage that's toggled off (dimmed + struck through) is hidden from the
114+ // experiments list below. Counts always reflect the full pipeline, not the filter.
115+ function PipelineStrip ( { pipeline, hiddenStatuses, onToggle } : {
116+ pipeline : ExperimentPipeline | null ;
117+ hiddenStatuses : Set < string > ;
118+ onToggle : ( status : string ) => void ;
119+ } ) {
96120 if ( ! pipeline ) return null ;
97121 return (
98122 < div style = { card } >
99- < div style = { sectionLabel } > Lifecycle pipeline</ div >
123+ < div style = { { ...sectionLabel , display : "flex" , justifyContent : "space-between" , gap : "1rem" } } >
124+ < span > Lifecycle pipeline</ span >
125+ < span style = { { textTransform : "none" , letterSpacing : 0 } } > click a stage to show / hide it in the list</ span >
126+ </ div >
100127 < div style = { { display : "flex" , gap : "0.4rem" , flexWrap : "wrap" } } >
101128 { pipeline . order . map ( ( status ) => {
102129 const count = pipeline . counts [ status ] ?? 0 ;
103130 const color = STATUS_COLORS [ status ] || "#8890a0" ;
104- const active = count > 0 ;
131+ const hasCount = count > 0 ;
132+ const on = ! hiddenStatuses . has ( status ) ;
105133 return (
106- < div key = { status } style = { {
107- flex : "1 1 6rem" , minWidth : "5.5rem" ,
108- borderRadius : "var(--radius-sm)" ,
109- border : `1px solid ${ active ? `${ color } 40` : "#ffffff0d" } ` ,
110- background : active ? `${ color } 12` : "#ffffff05" ,
111- padding : "0.5rem 0.6rem" ,
112- } } >
113- < div style = { { ...mono , fontSize : "1.3rem" , fontWeight : 600 , color : active ? color : "#5a6472" } } > { count } </ div >
114- < div style = { { ...mono , fontSize : "0.5rem" , textTransform : "uppercase" , letterSpacing : "0.05em" , color : "var(--text-muted)" } } > { status } </ div >
115- </ div >
134+ < button
135+ type = "button"
136+ key = { status }
137+ onClick = { ( ) => onToggle ( status ) }
138+ aria-pressed = { on }
139+ title = { on ? `Hide ${ status } from the list` : `Show ${ status } in the list` }
140+ style = { {
141+ flex : "1 1 6rem" , minWidth : "5.5rem" , textAlign : "left" ,
142+ appearance : "none" , cursor : "pointer" ,
143+ borderRadius : "var(--radius-sm)" ,
144+ border : `1px solid ${ on && hasCount ? `${ color } 40` : "#ffffff0d" } ` ,
145+ background : on && hasCount ? `${ color } 12` : "#ffffff05" ,
146+ padding : "0.5rem 0.6rem" ,
147+ opacity : on ? 1 : 0.45 ,
148+ } }
149+ >
150+ < div style = { { ...mono , fontSize : "1.3rem" , fontWeight : 600 , color : on && hasCount ? color : "#5a6472" } } > { count } </ div >
151+ < div style = { { ...mono , fontSize : "0.5rem" , textTransform : "uppercase" , letterSpacing : "0.05em" , color : "var(--text-muted)" , textDecoration : on ? "none" : "line-through" } } > { status } </ div >
152+ </ button >
116153 ) ;
117154 } ) }
118155 </ div >
@@ -464,7 +501,7 @@ function ExperimentsTable({ experiments, selectedId, onSelect, onChanged }: {
464501 experiments : ExperimentSummary [ ] ; selectedId : number | null ; onSelect : ( id : number | null ) => void ; onChanged : ( ) => void ;
465502} ) {
466503 if ( experiments . length === 0 ) {
467- return < div style = { { ...mono , color : "var(--text-muted)" , padding : "1rem" } } > No experiments yet .</ div > ;
504+ return < div style = { { ...mono , color : "var(--text-muted)" , padding : "1rem" } } > No experiments in the selected stages .</ div > ;
468505 }
469506 const headerStyle : CSSProperties = {
470507 display : "grid" , gridTemplateColumns : colTemplate , gap : "0.5rem" ,
@@ -515,14 +552,204 @@ function ExperimentsTable({ experiments, selectedId, onSelect, onChanged }: {
515552 ) ;
516553}
517554
555+ // ── Promoted vs original scatter ──
556+
557+ const filterLabel : CSSProperties = {
558+ ...mono , fontSize : "0.5rem" , textTransform : "uppercase" , letterSpacing : "0.05em" ,
559+ color : "var(--text-muted)" , marginBottom : "0.2rem" ,
560+ } ;
561+
562+ const filterSelect : CSSProperties = {
563+ ...mono , fontSize : "0.65rem" , color : "var(--text-primary)" ,
564+ background : "#ffffff08" , border : "1px solid #ffffff10" ,
565+ borderRadius : "var(--radius-sm)" , padding : "0.35rem 0.55rem" ,
566+ } ;
567+
568+ function chip ( active : boolean ) : CSSProperties {
569+ return {
570+ ...mono , fontSize : "0.6rem" , padding : "0.3rem 0.7rem" , borderRadius : "var(--radius-sm)" ,
571+ cursor : "pointer" , userSelect : "none" , appearance : "none" ,
572+ background : active ? "var(--accent-primary-dim)" : "#ffffff08" ,
573+ color : active ? "var(--accent-primary)" : "var(--text-muted)" ,
574+ border : `1px solid ${ active ? "#00e5c830" : "#ffffff10" } ` ,
575+ } ;
576+ }
577+
578+ function ComparisonTooltip ( { active, payload } : {
579+ active ?: boolean ;
580+ payload ?: Array < { payload : PromotedComparisonPoint } > ;
581+ } ) {
582+ if ( ! active || ! payload || payload . length === 0 ) return null ;
583+ const p = payload [ 0 ] . payload ;
584+ const delta = p . originalGoodput > 0 ? ( ( p . promotedGoodput - p . originalGoodput ) / p . originalGoodput ) * 100 : 0 ;
585+ const deltaColor = delta >= 0 ? WIN_COLOR : LOSS_COLOR ;
586+ return (
587+ < div style = { { ...mono , fontSize : "0.62rem" , background : "var(--bg-secondary)" , border : "1px solid #ffffff14" , borderRadius : "var(--radius-sm)" , padding : "0.5rem 0.6rem" , lineHeight : 1.5 } } >
588+ < div style = { { color : "var(--text-muted)" } } > #{ p . experimentId } · { p . targetCountry } · { p . protocolName || "—" } { p . providerName ? ` · ${ p . providerName } ` : "" } </ div >
589+ < div > < span style = { { color : WIN_COLOR } } > { p . promotedTrackName } </ span > < span style = { { color : "var(--text-muted)" } } > (promoted)</ span > </ div >
590+ < div > < span style = { { color : CONTROL_COLOR } } > { p . originalTrackName } </ span > < span style = { { color : "var(--text-muted)" } } > (original)</ span > </ div >
591+ < div style = { { marginTop : "0.25rem" } } > promoted: { formatBytesPerSec ( p . promotedGoodput ) } < span style = { { color : "var(--text-muted)" } } > ({ p . promotedSamples } sess)</ span > </ div >
592+ < div > original: { formatBytesPerSec ( p . originalGoodput ) } < span style = { { color : "var(--text-muted)" } } > ({ p . originalSamples } sess)</ span > </ div >
593+ < div style = { { color : deltaColor , marginTop : "0.15rem" } } > { delta >= 0 ? "+" : "" } { delta . toFixed ( 0 ) } % vs original</ div >
594+ </ div >
595+ ) ;
596+ }
597+
598+ function PromotedComparison ( { enabled } : { enabled : boolean } ) {
599+ const [ hours , setHours ] = useState ( 168 ) ;
600+ const [ country , setCountry ] = useState ( "" ) ;
601+ const [ protocol , setProtocol ] = useState ( "" ) ;
602+ const [ provider , setProvider ] = useState ( "" ) ;
603+ const { data, isLoading, error } = usePromotedComparison ( enabled , hours ) ;
604+
605+ const allPoints = useMemo ( ( ) => data ?. points ?? [ ] , [ data ] ) ;
606+
607+ // Filter option lists come from all points (before country/protocol/provider
608+ // filtering) so choosing one filter never empties the others' dropdowns.
609+ const countries = useMemo ( ( ) => [ ...new Set ( allPoints . map ( ( p ) => p . targetCountry ) . filter ( Boolean ) ) ] . sort ( ) , [ allPoints ] ) ;
610+ const protocols = useMemo ( ( ) => [ ...new Set ( allPoints . map ( ( p ) => p . protocolName ) . filter ( Boolean ) ) ] . sort ( ) , [ allPoints ] ) ;
611+ const providers = useMemo ( ( ) => [ ...new Set ( allPoints . map ( ( p ) => p . providerName ) . filter ( Boolean ) ) ] . sort ( ) , [ allPoints ] ) ;
612+
613+ const matchesFilters = useCallback ( ( p : PromotedComparisonPoint ) =>
614+ ( ! country || p . targetCountry === country ) &&
615+ ( ! protocol || p . protocolName === protocol ) &&
616+ ( ! provider || p . providerName === provider ) ,
617+ [ country , protocol , provider ] ) ;
618+
619+ // A point is plottable only when both arms have live samples in the window;
620+ // otherwise its goodput is a meaningless 0 that would pile on the origin.
621+ const hasSamples = ( p : PromotedComparisonPoint ) => p . promotedSamples > 0 && p . originalSamples > 0 ;
622+ const filtered = useMemo ( ( ) => allPoints . filter ( ( p ) => hasSamples ( p ) && matchesFilters ( p ) ) , [ allPoints , matchesFilters ] ) ;
623+
624+ // Hidden = points matching the active filters but lacking live samples, so the
625+ // "N hidden" note stays consistent with the filtered track count above it.
626+ const hidden = useMemo ( ( ) => allPoints . filter ( ( p ) => matchesFilters ( p ) && ! hasSamples ( p ) ) . length , [ allPoints , matchesFilters ] ) ;
627+
628+ const wins = filtered . filter ( ( p ) => p . promotedGoodput >= p . originalGoodput ) ;
629+ const losses = filtered . filter ( ( p ) => p . promotedGoodput < p . originalGoodput ) ;
630+ const axisMax = useMemo ( ( ) => {
631+ // reduce (not Math.max(...spread)) so any number of points is safe.
632+ const m = filtered . reduce ( ( acc , p ) => Math . max ( acc , p . promotedGoodput , p . originalGoodput ) , 0 ) ;
633+ return m > 0 ? m * 1.08 : 1 ; // headroom so parity line + points aren't clipped
634+ } , [ filtered ] ) ;
635+
636+ const hasFilters = Boolean ( country || protocol || provider ) ;
637+
638+ return (
639+ < div style = { card } >
640+ < div style = { { display : "flex" , justifyContent : "space-between" , alignItems : "flex-start" , gap : "1rem" , flexWrap : "wrap" , marginBottom : "0.6rem" } } >
641+ < div >
642+ < div style = { { ...sectionLabel , marginBottom : "0.15rem" } } > Promoted vs original — median goodput</ div >
643+ < div style = { { ...mono , fontSize : "0.55rem" , color : "var(--text-muted)" } } >
644+ Each point is a promoted track vs the original it beat, over the last { COMPARISON_WINDOWS . find ( ( w ) => w . hours === hours ) ?. label ?? `${ hours } h` } , in its target market. Above the parity line = promotion still winning.
645+ </ div >
646+ </ div >
647+ < div style = { { display : "flex" , gap : "0.3rem" , alignItems : "center" , flexWrap : "wrap" } } >
648+ { COMPARISON_WINDOWS . map ( ( w ) => (
649+ < button type = "button" key = { w . hours } onClick = { ( ) => setHours ( w . hours ) } style = { chip ( hours === w . hours ) } aria-pressed = { hours === w . hours } > { w . label } </ button >
650+ ) ) }
651+ </ div >
652+ </ div >
653+
654+ < div style = { { display : "flex" , gap : "0.7rem" , flexWrap : "wrap" , alignItems : "flex-end" , marginBottom : "0.6rem" } } >
655+ < div style = { { display : "flex" , flexDirection : "column" , minWidth : 110 } } >
656+ < span style = { filterLabel } > Country</ span >
657+ < select style = { filterSelect } value = { country } onChange = { ( e ) => setCountry ( e . target . value ) } >
658+ < option value = "" > All</ option >
659+ { countries . map ( ( c ) => < option key = { c } value = { c } > { c } </ option > ) }
660+ </ select >
661+ </ div >
662+ < div style = { { display : "flex" , flexDirection : "column" , minWidth : 130 } } >
663+ < span style = { filterLabel } > Protocol</ span >
664+ < select style = { filterSelect } value = { protocol } onChange = { ( e ) => setProtocol ( e . target . value ) } >
665+ < option value = "" > All</ option >
666+ { protocols . map ( ( p ) => < option key = { p } value = { p } > { p } </ option > ) }
667+ </ select >
668+ </ div >
669+ < div style = { { display : "flex" , flexDirection : "column" , minWidth : 130 } } >
670+ < span style = { filterLabel } > Provider</ span >
671+ < select style = { filterSelect } value = { provider } onChange = { ( e ) => setProvider ( e . target . value ) } >
672+ < option value = "" > All</ option >
673+ { providers . map ( ( p ) => < option key = { p } value = { p } > { p } </ option > ) }
674+ </ select >
675+ </ div >
676+ { hasFilters && (
677+ < button type = "button" onClick = { ( ) => { setCountry ( "" ) ; setProtocol ( "" ) ; setProvider ( "" ) ; } } style = { chip ( false ) } > Clear</ button >
678+ ) }
679+ </ div >
680+
681+ { error ? (
682+ < div style = { { ...mono , fontSize : "0.65rem" , color : "var(--accent-danger, #ff4060)" } } > { error } </ div >
683+ ) : data ?. statsError ? (
684+ < div style = { { ...mono , fontSize : "0.62rem" , color : "#e0a060" , background : "#f0a03012" , border : "1px solid #f0a03030" , borderRadius : "var(--radius-sm)" , padding : "0.5rem 0.7rem" } } > { data . statsError } </ div >
685+ ) : ! data || ( isLoading && filtered . length === 0 ) ? (
686+ < div style = { { ...mono , fontSize : "0.65rem" , color : "var(--text-muted)" } } > Loading comparison…</ div >
687+ ) : filtered . length === 0 ? (
688+ < div style = { { ...mono , fontSize : "0.65rem" , color : "var(--text-muted)" } } >
689+ No promoted tracks with live samples in this window{ hasFilters ? " for the selected filters" : "" } .
690+ </ div >
691+ ) : (
692+ < >
693+ < ResponsiveContainer width = "100%" height = { 340 } >
694+ < ScatterChart margin = { { top : 8 , right : 16 , bottom : 24 , left : 16 } } >
695+ < CartesianGrid strokeDasharray = "3 3" stroke = "#ffffff10" />
696+ < XAxis
697+ type = "number" dataKey = "originalGoodput" name = "Original goodput"
698+ domain = { [ 0 , axisMax ] } tickFormatter = { formatBytesPerSec }
699+ tick = { { fontSize : 10 , fill : "#8890a0" } }
700+ label = { { value : "Original (control) goodput" , position : "insideBottom" , offset : - 14 , fontSize : 10 , fill : "#8890a0" } }
701+ />
702+ < YAxis
703+ type = "number" dataKey = "promotedGoodput" name = "Promoted goodput"
704+ domain = { [ 0 , axisMax ] } tickFormatter = { formatBytesPerSec } width = { 64 }
705+ tick = { { fontSize : 10 , fill : "#8890a0" } }
706+ label = { { value : "Promoted goodput" , angle : - 90 , position : "insideLeft" , fontSize : 10 , fill : "#8890a0" } }
707+ />
708+ < ZAxis range = { [ 60 , 60 ] } />
709+ < ReferenceLine
710+ segment = { [ { x : 0 , y : 0 } , { x : axisMax , y : axisMax } ] }
711+ stroke = "#8890a0" strokeDasharray = "4 4" ifOverflow = "hidden"
712+ />
713+ < Tooltip content = { < ComparisonTooltip /> } cursor = { { strokeDasharray : "3 3" } } />
714+ < Legend wrapperStyle = { { fontSize : 11 } } />
715+ < Scatter name = "Promotion winning" data = { wins } fill = { WIN_COLOR } />
716+ < Scatter name = "Promotion losing" data = { losses } fill = { LOSS_COLOR } />
717+ </ ScatterChart >
718+ </ ResponsiveContainer >
719+ < div style = { { ...mono , fontSize : "0.55rem" , color : "var(--text-muted)" , marginTop : "0.4rem" } } >
720+ { filtered . length } promoted { filtered . length === 1 ? "track" : "tracks" } · { wins . length } winning · { losses . length } losing
721+ { hidden > 0 && ` · ${ hidden } hidden (no live samples in window)` }
722+ </ div >
723+ </ >
724+ ) }
725+ </ div >
726+ ) ;
727+ }
728+
518729// ── Top-level tab content ──
519730
520731export default function ExperimentsOverview ( { enabled } : { enabled : boolean } ) {
521732 const [ view , setView ] = useState < "experiments" | "settings" > ( "experiments" ) ;
522733 const [ selectedId , setSelectedId ] = useState < number | null > ( null ) ;
734+ // Status filter driven by the pipeline strip. Retired experiments are hidden by
735+ // default — they're the bulk of terminal history and rarely what you're after.
736+ const [ hiddenStatuses , setHiddenStatuses ] = useState < Set < string > > ( ( ) => new Set ( [ "retired" ] ) ) ;
523737 const { experiments, pipeline, isLoading, hasLoaded, error, refresh } = useExperiments ( enabled ) ;
524738 const settings = useExperimentSettings ( enabled ) ;
525739
740+ const toggleStatus = useCallback ( ( status : string ) => {
741+ setHiddenStatuses ( ( prev ) => {
742+ const next = new Set ( prev ) ;
743+ if ( next . has ( status ) ) next . delete ( status ) ; else next . add ( status ) ;
744+ return next ;
745+ } ) ;
746+ } , [ ] ) ;
747+
748+ const visibleExperiments = useMemo (
749+ ( ) => experiments . filter ( ( e ) => ! hiddenStatuses . has ( e . status ) ) ,
750+ [ experiments , hiddenStatuses ] ,
751+ ) ;
752+
526753 // Surface a banner when the core automation workers are paused.
527754 const automationOff = useMemo ( ( ) => {
528755 const ed = settings . settings ?. editable ?? [ ] ;
@@ -559,11 +786,12 @@ export default function ExperimentsOverview({ enabled }: { enabled: boolean }) {
559786 { error && (
560787 < div style = { { ...mono , fontSize : "0.65rem" , color : "var(--accent-danger, #ff4060)" , background : "#ff406012" , border : "1px solid #ff406030" , borderRadius : "var(--radius-sm)" , padding : "0.5rem 0.75rem" } } > { error } </ div >
561788 ) }
562- < PipelineStrip pipeline = { pipeline } />
789+ < PipelineStrip pipeline = { pipeline } hiddenStatuses = { hiddenStatuses } onToggle = { toggleStatus } />
790+ < PromotedComparison enabled = { enabled && view === "experiments" } />
563791 { isLoading && ! hasLoaded ? (
564792 < div style = { { ...mono , color : "var(--text-muted)" , padding : "1rem" } } > Loading experiments…</ div >
565793 ) : (
566- < ExperimentsTable experiments = { experiments } selectedId = { selectedId } onSelect = { setSelectedId } onChanged = { refresh } />
794+ < ExperimentsTable experiments = { visibleExperiments } selectedId = { selectedId } onSelect = { setSelectedId } onChanged = { refresh } />
567795 ) }
568796 </ >
569797 ) }
0 commit comments