226226 }
227227 .checkbox input { accent-color : var (--hyb ); }
228228
229+ /* PERIOD (dual-thumb) */
230+ .period-labels {
231+ display : flex;
232+ justify-content : space-between;
233+ margin-bottom : 8px ;
234+ font-family : var (--mono );
235+ font-size : 12px ;
236+ color : var (--ink );
237+ }
238+ .period-slider {
239+ position : relative;
240+ height : 18px ;
241+ }
242+ .period-slider .track {
243+ position : absolute;
244+ top : 7px ; left : 0 ;
245+ width : 100% ; height : 4px ;
246+ background : var (--line-2 );
247+ border-radius : 2px ;
248+ }
249+ .period-slider .track-fill {
250+ position : absolute;
251+ top : 0 ; height : 100% ;
252+ background : var (--hyb );
253+ border-radius : 2px ;
254+ }
255+ .period-slider input [type = range ] {
256+ position : absolute;
257+ top : 0 ; left : 0 ;
258+ width : 100% ; height : 18px ;
259+ background : transparent;
260+ pointer-events : none;
261+ -webkit-appearance : none;
262+ margin : 0 ;
263+ }
264+ .period-slider input [type = range ]::-webkit-slider-runnable-track {
265+ background : transparent; height : 18px ; border : none;
266+ }
267+ .period-slider input [type = range ]::-moz-range-track {
268+ background : transparent; height : 18px ; border : none;
269+ }
270+ .period-slider input [type = range ]::-webkit-slider-thumb {
271+ pointer-events : auto;
272+ -webkit-appearance : none;
273+ width : 14px ; height : 14px ;
274+ border-radius : 50% ;
275+ background : var (--ink );
276+ border : 2px solid var (--surface );
277+ box-shadow : 0 0 0 1px var (--line-2 );
278+ cursor : pointer;
279+ position : relative;
280+ z-index : 2 ;
281+ margin-top : 0 ;
282+ transition : transform 0.1s ;
283+ }
284+ .period-slider input [type = range ]::-webkit-slider-thumb : hover { transform : scale (1.15 ); }
285+ .period-slider input [type = range ]::-moz-range-thumb {
286+ pointer-events : auto;
287+ width : 14px ; height : 14px ;
288+ border-radius : 50% ;
289+ background : var (--ink );
290+ border : 2px solid var (--surface );
291+ box-shadow : 0 0 0 1px var (--line-2 );
292+ cursor : pointer;
293+ }
294+
229295 /* RIGHT PANE */
230296 .pane {
231297 padding : 32px 48px ;
@@ -362,6 +428,22 @@ <h1>Buy the dip <em>vs</em> just keep buying.</h1>
362428< main >
363429 < aside class ="knobs ">
364430
431+ < div class ="knob-group ">
432+ < h3 > Period</ h3 >
433+ < div class ="knob ">
434+ < div class ="period-labels ">
435+ < span id ="lbl-period-start "> —</ span >
436+ < span id ="lbl-period-end "> —</ span >
437+ </ div >
438+ < div class ="period-slider ">
439+ < div class ="track "> </ div >
440+ < div class ="track-fill " id ="period-fill "> </ div >
441+ < input type ="range " id ="p-start " min ="0 " max ="100 " step ="1 " value ="0 ">
442+ < input type ="range " id ="p-end " min ="0 " max ="100 " step ="1 " value ="100 ">
443+ </ div >
444+ </ div >
445+ </ div >
446+
365447 < div class ="knob-group ">
366448 < h3 > Contribution</ h3 >
367449 < div class ="knob ">
@@ -513,6 +595,36 @@ <h2>Strategy comparison</h2>
513595// ============================================================
514596function ymKey ( d ) { return d . getFullYear ( ) * 12 + d . getMonth ( ) ; }
515597
598+ function ymString ( d ) {
599+ return `${ d . getFullYear ( ) } -${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } ` ;
600+ }
601+
602+ const MONTH_NAMES = [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ] ;
603+ function fmtMonth ( ym ) {
604+ const [ y , m ] = ym . split ( '-' ) . map ( Number ) ;
605+ return `${ MONTH_NAMES [ m - 1 ] } ${ y } ` ;
606+ }
607+
608+ let MONTHS = [ ] ; // sorted unique "YYYY-MM" strings present in DATA
609+ function buildMonths ( data ) {
610+ const set = new Set ( ) ;
611+ for ( const r of data ) set . add ( ymString ( r . date ) ) ;
612+ return [ ...set ] . sort ( ) ;
613+ }
614+
615+ function filterByMonths ( data , startYM , endYM ) {
616+ const out = [ ] ;
617+ for ( const r of data ) {
618+ const ym = ymString ( r . date ) ;
619+ if ( ym >= startYM && ym <= endYM ) out . push ( { ...r } ) ;
620+ }
621+ // recompute nextClose so the slice is self-contained (T+1 within slice; last → same close)
622+ for ( let i = 0 ; i < out . length ; i ++ ) {
623+ out [ i ] . nextClose = ( i + 1 < out . length ) ? out [ i + 1 ] . close : out [ i ] . close ;
624+ }
625+ return out ;
626+ }
627+
516628function firstTradingDays ( data ) {
517629 const seen = new Set ( ) ;
518630 const set = new Set ( ) ;
@@ -762,6 +874,8 @@ <h2>Strategy comparison</h2>
762874 deployLeftover : document . getElementById ( 'leftover' ) . checked ,
763875 sipAmt : monthly * splitPct / 100 ,
764876 dipAmt : monthly * ( 100 - splitPct ) / 100 ,
877+ periodStart : + document . getElementById ( 'p-start' ) . value ,
878+ periodEnd : + document . getElementById ( 'p-end' ) . value ,
765879 } ;
766880}
767881
@@ -948,16 +1062,62 @@ <h2>Strategy comparison</h2>
9481062}
9491063
9501064function recompute ( ) {
951- if ( ! DATA ) return ;
1065+ if ( ! DATA || MONTHS . length === 0 ) return ;
9521066 const p = readKnobs ( ) ;
9531067 syncLabels ( p ) ;
954- const sip = strategySip ( DATA , p ) ;
955- const dip = strategyDip ( DATA , p ) ;
956- const hyb = strategyHybrid ( DATA , p ) ;
957- lastResults = { sip, dip, hyb } ;
958- const periodYears = ( DATA [ DATA . length - 1 ] . date - DATA [ 0 ] . date ) / MS_YR ;
1068+ const startYM = MONTHS [ p . periodStart ] ;
1069+ const endYM = MONTHS [ p . periodEnd ] ;
1070+ const data = filterByMonths ( DATA , startYM , endYM ) ;
1071+ if ( data . length < 20 ) return ;
1072+ const sip = strategySip ( data , p ) ;
1073+ const dip = strategyDip ( data , p ) ;
1074+ const hyb = strategyHybrid ( data , p ) ;
1075+ lastResults = { sip, dip, hyb, data } ;
1076+ const periodYears = ( data [ data . length - 1 ] . date - data [ 0 ] . date ) / MS_YR ;
9591077 renderTable ( sip , dip , hyb , periodYears ) ;
960- renderChart ( DATA , sip , dip , hyb ) ;
1078+ renderChart ( data , sip , dip , hyb ) ;
1079+ updatePeriodMeta ( data ) ;
1080+ }
1081+
1082+ function updatePeriodMeta ( data ) {
1083+ const start = data [ 0 ] . date . toISOString ( ) . slice ( 0 , 10 ) ;
1084+ const end = data [ data . length - 1 ] . date . toISOString ( ) . slice ( 0 , 10 ) ;
1085+ const yrs = ( ( data [ data . length - 1 ] . date - data [ 0 ] . date ) / MS_YR ) . toFixed ( 1 ) ;
1086+ document . getElementById ( 'period-meta' ) . textContent =
1087+ `${ start } → ${ end } · ${ yrs } yr · ${ data . length . toLocaleString ( ) } sessions` ;
1088+ }
1089+
1090+ function updatePeriodFill ( ) {
1091+ const s = + document . getElementById ( 'p-start' ) . value ;
1092+ const e = + document . getElementById ( 'p-end' ) . value ;
1093+ const max = + document . getElementById ( 'p-start' ) . max ;
1094+ const fill = document . getElementById ( 'period-fill' ) ;
1095+ fill . style . left = `${ ( s / max ) * 100 } %` ;
1096+ fill . style . width = `${ ( ( e - s ) / max ) * 100 } %` ;
1097+ }
1098+
1099+ function syncPeriodLabels ( ) {
1100+ const s = + document . getElementById ( 'p-start' ) . value ;
1101+ const e = + document . getElementById ( 'p-end' ) . value ;
1102+ document . getElementById ( 'lbl-period-start' ) . textContent = fmtMonth ( MONTHS [ s ] ) ;
1103+ document . getElementById ( 'lbl-period-end' ) . textContent = fmtMonth ( MONTHS [ e ] ) ;
1104+ }
1105+
1106+ function wirePeriod ( ) {
1107+ const start = document . getElementById ( 'p-start' ) ;
1108+ const end = document . getElementById ( 'p-end' ) ;
1109+ const MIN_SPAN = 12 ; // months — at least 1 year of contributions
1110+
1111+ const handler = ( which ) => ( ) => {
1112+ let s = + start . value , e = + end . value ;
1113+ if ( which === 'start' && s > e - MIN_SPAN ) start . value = Math . max ( 0 , e - MIN_SPAN ) ;
1114+ if ( which === 'end' && e < s + MIN_SPAN ) end . value = Math . min ( + end . max , s + MIN_SPAN ) ;
1115+ updatePeriodFill ( ) ;
1116+ syncPeriodLabels ( ) ;
1117+ debouncedRecompute ( ) ;
1118+ } ;
1119+ start . addEventListener ( 'input' , handler ( 'start' ) ) ;
1120+ end . addEventListener ( 'input' , handler ( 'end' ) ) ;
9611121}
9621122
9631123function wire ( ) {
@@ -967,16 +1127,17 @@ <h2>Strategy comparison</h2>
9671127 el . addEventListener ( 'input' , debouncedRecompute ) ;
9681128 el . addEventListener ( 'change' , debouncedRecompute ) ;
9691129 } ) ;
1130+ wirePeriod ( ) ;
9701131 document . getElementById ( 'theme-toggle' ) . addEventListener ( 'click' , ( ) => {
9711132 const cur = document . documentElement . dataset . theme || 'light' ;
9721133 const next = cur === 'dark' ? 'light' : 'dark' ;
9731134 document . documentElement . dataset . theme = next ;
9741135 try { localStorage . setItem ( 'theme' , next ) ; } catch ( e ) { }
975- if ( DATA && lastResults . sip ) renderChart ( DATA , lastResults . sip , lastResults . dip , lastResults . hyb ) ;
1136+ if ( lastResults . sip ) renderChart ( lastResults . data , lastResults . sip , lastResults . dip , lastResults . hyb ) ;
9761137 } ) ;
9771138}
9781139
979- let lastResults = { sip : null , dip : null , hyb : null } ;
1140+ let lastResults = { sip : null , dip : null , hyb : null , data : null } ;
9801141
9811142( function initTheme ( ) {
9821143 let saved = 'light' ;
@@ -988,14 +1149,18 @@ <h2>Strategy comparison</h2>
9881149 try {
9891150 const { data, source } = await loadData ( ) ;
9901151 DATA = data ;
991- const start = data [ 0 ] . date . toISOString ( ) . slice ( 0 , 10 ) ;
992- const end = data [ data . length - 1 ] . date . toISOString ( ) . slice ( 0 , 10 ) ;
993- const yrs = ( ( data [ data . length - 1 ] . date - data [ 0 ] . date ) / MS_YR ) . toFixed ( 1 ) ;
994- document . getElementById ( 'period-meta' ) . textContent =
995- `${ start } → ${ end } · ${ yrs } yr · ${ data . length . toLocaleString ( ) } sessions` ;
1152+ MONTHS = buildMonths ( data ) ;
1153+ // initialize period sliders to full range
1154+ const lastIdx = MONTHS . length - 1 ;
1155+ const pStart = document . getElementById ( 'p-start' ) ;
1156+ const pEnd = document . getElementById ( 'p-end' ) ;
1157+ pStart . max = lastIdx ; pStart . value = 0 ;
1158+ pEnd . max = lastIdx ; pEnd . value = lastIdx ;
9961159 document . getElementById ( 'status' ) . style . display = 'none' ;
9971160 document . getElementById ( 'content' ) . style . display = 'block' ;
9981161 wire ( ) ;
1162+ updatePeriodFill ( ) ;
1163+ syncPeriodLabels ( ) ;
9991164 recompute ( ) ;
10001165 } catch ( e ) {
10011166 const s = document . getElementById ( 'status' ) ;
0 commit comments