99// it renders all four of its charts at once from day-bucketed data, a
1010// different shape than the per-record metric charts here.
1111//
12+ // Points are one per day per variant, not one per session (RRT's shape):
13+ // a dozen short games in an evening read as one day's result instead of a
14+ // dozen dots. Metrics say how their day collapses via `agg`.
15+ //
1216// Subclass contract:
1317// views() -> [{ view, label }] - first entry is the default tab;
1418// a 'time' view gets the minutes-per-day bar chart
15- // metrics() -> { view: { y, has, axis, fmt, empty? } }
19+ // metrics() -> { view: { y, has, axis, fmt, empty?, agg? } }
20+ // agg: 'mean' (default) | 'min' | 'max' - how a day's
21+ // sessions collapse to one point ('min' for fastest-X,
22+ // 'max' for best-X, mean for everything else)
1623// includes(r) -> record filter (completed-status check)
1724// groupKey(r) -> dataset label (structural-variant grouping)
18- // pointMeta(r) -> extra fields carried on each point for the tooltip
19- // tooltipSession(raw) -> session summary appended to the dataset label,
20- // e.g. " 3-back (5 min)" - tooltips show the training
21- // mode + value only; other metrics have their own graphs
25+ // minutes(r) -> that record's play time in minutes, summed per day
26+ // for the tooltip
2227// emptyDefault -> getter, default empty-state text
28+
29+ // 4 AM day rollover, so a late-night session counts toward the day it started
30+ // in. Deliberately the same shape as getTruncatedDate (js/quadbox/engine/utils.js)
31+ // and dayKey (js/cct/engine/gamedb.js) - roll the *local* hour back rather than
32+ // subtracting 4h of absolute time, or a DST-shift day would bucket a session
33+ // differently here than in the minutes-per-day bar chart those two feed.
34+ // Duplicated rather than imported: both of those are ES modules and this file
35+ // is a classic script.
36+ const cvDayKey = ( timestamp ) => {
37+ const d = new Date ( timestamp ) ;
38+ if ( d . getHours ( ) < 4 ) d . setDate ( d . getDate ( ) - 1 ) ;
39+ return `${ d . getFullYear ( ) } -${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } -${ String ( d . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
40+ } ;
41+
42+ // ponytail: mean is unweighted - a 3-trial warmup counts as much as a 60-trial
43+ // run. Weight by trial count if that skews days in practice.
44+ const cvCollapse = {
45+ mean : ys => ys . reduce ( ( a , b ) => a + b , 0 ) / ys . length ,
46+ min : ys => Math . min ( ...ys ) ,
47+ max : ys => Math . max ( ...ys ) ,
48+ } ;
49+
50+ // sessions/minutes count only the day's records that HAVE this metric, so a
51+ // reaction-time tab can legitimately read fewer sessions than the accuracy tab
52+ const cvDaySummary = ( { sessions, minutes } ) => {
53+ const mins = Math . round ( minutes ) ;
54+ return ` · ${ sessions } session${ sessions === 1 ? '' : 's' } ${ mins > 0 ? `, ${ mins } min` : '' } ` ;
55+ } ;
56+
2357class CvMetricGraphs extends HTMLElement {
2458 connectedCallback ( ) {
2559 const views = this . views ( ) ;
@@ -84,19 +118,28 @@ class CvMetricGraphs extends HTMLElement {
84118 else this . renderMetric ( this . metrics ( ) [ this . view ] ) ;
85119 }
86120
87- renderMetric ( { y, has, axis, fmt, empty } ) {
121+ renderMetric ( { y, has, axis, fmt, empty, agg } ) {
88122 const rows = this . records
89123 . filter ( r => this . includes ( r ) && has ( r ) )
90124 . sort ( ( a , b ) => a . timestamp - b . timestamp ) ;
125+ // variant -> day -> the day's sessions; rows are timestamp-sorted, so
126+ // insertion order already puts each dataset's days in ascending order
91127 const groups = { } ;
92128 for ( const r of rows ) {
93129 const key = this . groupKey ( r ) ;
94- groups [ key ] = groups [ key ] ?? [ ] ;
95- groups [ key ] . push ( { x : r . timestamp , y : y ( r ) , ...this . pointMeta ( r ) } ) ;
130+ const day = cvDayKey ( r . timestamp ) ;
131+ groups [ key ] = groups [ key ] ?? { } ;
132+ const bucket = groups [ key ] [ day ] = groups [ key ] [ day ] ?? { x : day , ys : [ ] , sessions : 0 , minutes : 0 } ;
133+ bucket . ys . push ( y ( r ) ) ;
134+ bucket . sessions += 1 ;
135+ bucket . minutes += this . minutes ( r ) ;
96136 }
137+ const collapse = cvCollapse [ agg ?? 'mean' ] ;
97138 const { fg, palette } = this . tokens ( ) ;
98- const datasets = Object . entries ( groups ) . map ( ( [ label , data ] , i ) => ( {
99- label, data, borderColor : this . paletteColor ( i , palette ) ,
139+ const datasets = Object . entries ( groups ) . map ( ( [ label , days ] , i ) => ( {
140+ label,
141+ data : Object . values ( days ) . map ( d => ( { ...d , y : collapse ( d . ys ) } ) ) ,
142+ borderColor : this . paletteColor ( i , palette ) ,
100143 backgroundColor : this . paletteColor ( i , palette ) , tension : 0.2 , pointRadius : 3 ,
101144 } ) ) ;
102145 this . setEmpty ( datasets . some ( d => d . data . length > 0 ) , empty ) ;
@@ -116,7 +159,7 @@ class CvMetricGraphs extends HTMLElement {
116159 legend : { labels : { color : fg } } ,
117160 tooltip : {
118161 callbacks : {
119- label : ( item ) => `${ item . dataset . label } ${ this . tooltipSession ( item . raw ) } : ${ fmt ( item . raw . y ) } ` ,
162+ label : ( item ) => `${ item . dataset . label } ${ cvDaySummary ( item . raw ) } : ${ fmt ( item . raw . y ) } ` ,
120163 } ,
121164 } ,
122165 } ,
0 commit comments