@@ -122,6 +122,25 @@ export function collectQuarters(rows) {
122122 return Array . from ( seen ) . sort ( ) . reverse ( ) ;
123123}
124124
125+ /**
126+ * Normalize a raw instrument_id by extracting just the <name> portion from
127+ * legacy naming patterns:
128+ * <location>_<name>_<date>
129+ * <location>-<name>_<date>
130+ * <location>-<name>_<morename>_<date>
131+ *
132+ * where <date> is YYYYMMDD or YYYY-MM-DD.
133+ * IDs that don't match these patterns are returned unchanged.
134+ *
135+ * @param {string|null } id
136+ * @returns {string }
137+ */
138+ export function normalizeInstrumentId ( id ) {
139+ if ( ! id ) return id ?? '' ;
140+ const m = String ( id ) . match ( / ^ [ ^ _ - ] + [ _ - ] ( .+ ) _ ( \d { 8 } | \d { 4 } - \d { 2 } - \d { 2 } | (?: 2 [ 3 - 6 ] ) \d { 4 } ) $ / ) ;
141+ return m ? m [ 1 ] : String ( id ) ;
142+ }
143+
125144/**
126145 * Collect unique non-null, non-empty values for a column, sorted.
127146 *
@@ -299,6 +318,34 @@ const COLUMN_LABELS = {
299318const PAGE_SIZE = 100 ;
300319const SELECT_THRESHOLD = 50 ;
301320
321+ // ---------------------------------------------------------------------------
322+ // CSV export
323+ // ---------------------------------------------------------------------------
324+
325+ /**
326+ * Trigger a CSV file download in the browser.
327+ *
328+ * @param {string } filename
329+ * @param {string[] } headers
330+ * @param {string[][] } dataRows
331+ */
332+ export function downloadCsv ( filename , headers , dataRows ) {
333+ const escape = ( v ) => {
334+ const s = String ( v ?? '' ) ;
335+ return s . includes ( ',' ) || s . includes ( '"' ) || s . includes ( '\n' )
336+ ? `"${ s . replace ( / " / g, '""' ) } "`
337+ : s ;
338+ } ;
339+ const lines = [ headers , ...dataRows ] . map ( ( row ) => row . map ( escape ) . join ( ',' ) ) ;
340+ const blob = new Blob ( [ lines . join ( '\r\n' ) ] , { type : 'text/csv' } ) ;
341+ const url = URL . createObjectURL ( blob ) ;
342+ const a = document . createElement ( 'a' ) ;
343+ a . href = url ;
344+ a . download = filename ;
345+ a . click ( ) ;
346+ URL . revokeObjectURL ( url ) ;
347+ }
348+
302349/**
303350 * Render one table row.
304351 *
@@ -372,6 +419,9 @@ export function createSessionsView(coord, metadata) {
372419 // -------------------------------------------------------------------------
373420
374421 function buildPage ( allRows ) {
422+ // Normalize instrument_ids once so all downstream filtering and display
423+ // sees clean names rather than <location>_<name>_<date> variants.
424+ allRows = allRows . map ( ( r ) => ( { ...r , instrument_id : normalizeInstrumentId ( r . instrument_id ) } ) ) ;
375425 // -- URL state helpers ---------------------------------------------------
376426 function readUrlState ( ) {
377427 const p = new URLSearchParams ( window . location . search ) ;
@@ -666,6 +716,27 @@ export function createSessionsView(coord, metadata) {
666716 const pagingBar = document . createElement ( 'div' ) ;
667717 pagingBar . className = 'assets-paging' ;
668718
719+ const tableExportBtn = document . createElement ( 'button' ) ;
720+ tableExportBtn . className = 'sessions-export-btn sessions-table-export-btn' ;
721+ tableExportBtn . textContent = 'Export CSV' ;
722+ tableExportBtn . addEventListener ( 'click' , ( ) => {
723+ const filtered = getFilteredRows ( ) ;
724+ const sorted = sortRows ( [ ...filtered ] , sortCol , sortDir ) ;
725+ downloadCsv ( 'sessions.csv' ,
726+ DISPLAY_COLUMNS . map ( ( c ) => COLUMN_LABELS [ c ] ?? c ) ,
727+ sorted . map ( ( row ) => [
728+ row . subject_id ?? '' ,
729+ formatDate ( row . acquisition_start_time ?? null ) ,
730+ row . project_name ?? '' ,
731+ row . instrument_id ?? '' ,
732+ row . experimenters ?? '' ,
733+ row . modalities ?? '' ,
734+ row . genotype ?? '' ,
735+ ] ) ,
736+ ) ;
737+ } ) ;
738+
739+ tableArea . appendChild ( tableExportBtn ) ;
669740 tableArea . appendChild ( table ) ;
670741 tableArea . appendChild ( pagingBar ) ;
671742
@@ -731,10 +802,13 @@ export function createSessionsView(coord, metadata) {
731802 statsWarningEl . hidden = true ;
732803 }
733804
805+ const total = filteredRows . length ;
806+ const pct = ( n ) => total > 0 ? `${ ( ( n / total ) * 100 ) . toFixed ( 1 ) } %` : '—' ;
807+
734808 // Total
735809 statsTotalEl . innerHTML = `
736810 <div class="sessions-stat-title">Total Sessions</div>
737- <div class="sessions-stat-value">${ filteredRows . length . toLocaleString ( ) } </div>
811+ <div class="sessions-stat-value">${ total . toLocaleString ( ) } </div>
738812 ` ;
739813
740814 // By experimenter
@@ -744,31 +818,73 @@ export function createSessionsView(coord, metadata) {
744818 const known = formatDuration ( knownMs ) ;
745819 let timeCell = known ?? '—' ;
746820 if ( unknownCount > 0 ) timeCell += `<span class="stat-unknown"> +${ unknownCount } unknown</span>` ;
747- return `<tr><td>${ escHtml ( experimenter ) } </td><td class="stat-count">${ count . toLocaleString ( ) } </td><td class="stat-duration">${ timeCell } </td></tr>` ;
821+ return `<tr><td>${ escHtml ( experimenter ) } </td><td class="stat-count">${ count . toLocaleString ( ) } </td><td class="stat-pct"> ${ pct ( count ) } </td><td class="stat- duration">${ timeCell } </td></tr>` ;
748822 } )
749823 . join ( '' ) ;
750824 statsExperimenterEl . innerHTML = `
751- <div class="sessions-stat-title">Sessions by Experimenter</div>
825+ <div class="sessions-stat-title-row">
826+ <div class="sessions-stat-title">Sessions by Experimenter</div>
827+ <button class="sessions-export-btn" data-export="experimenter">Export CSV</button>
828+ </div>
752829 <table class="sessions-stat-table">
753- <thead><tr><th>Experimenter</th><th>Count</th><th>Total Time</th></tr></thead>
754- <tbody>${ expRows || '<tr><td colspan="3 ">No data</td></tr>' } </tbody>
830+ <thead><tr><th>Experimenter</th><th>Count</th><th>%</th><th> Total Time</th></tr></thead>
831+ <tbody>${ expRows || '<tr><td colspan="4 ">No data</td></tr>' } </tbody>
755832 </table>
756833 ` ;
834+ statsExperimenterEl . querySelector ( '[data-export="experimenter"]' ) . addEventListener ( 'click' , ( ) => {
835+ downloadCsv ( `${ selectedQuarter ?? 'all' } _experimenter-summary.csv` ,
836+ [ 'Experimenter' , 'Count' , 'Percent' , 'Total Time' ] ,
837+ byExp . map ( ( { experimenter, count, knownMs, unknownCount } ) => {
838+ const known = formatDuration ( knownMs ) ?? '' ;
839+ const timeVal = unknownCount > 0 ? `${ known } (+${ unknownCount } unknown)` . trim ( ) : known ;
840+ return [ experimenter , count , pct ( count ) , timeVal ] ;
841+ } ) ,
842+ ) ;
843+ } ) ;
757844
758- // By project
845+ // By project — also compute total time per project
759846 const byProj = countByProject ( filteredRows ) ;
847+ // Build a project → total ms map
848+ const projMs = new Map ( ) ;
849+ const projUnknown = new Map ( ) ;
850+ for ( const row of filteredRows ) {
851+ const p = String ( row . project_name ?? 'Unknown' ) ;
852+ const start = row . acquisition_start_time ? new Date ( row . acquisition_start_time ) . getTime ( ) : null ;
853+ const end = row . acquisition_end_time ? new Date ( row . acquisition_end_time ) . getTime ( ) : null ;
854+ const durMs = ( start && end && end > start ) ? ( end - start ) : null ;
855+ if ( durMs !== null ) projMs . set ( p , ( projMs . get ( p ) ?? 0 ) + durMs ) ;
856+ else projUnknown . set ( p , ( projUnknown . get ( p ) ?? 0 ) + 1 ) ;
857+ }
760858 const projRows = byProj
761- . map ( ( { project, count } ) =>
762- `<tr><td>${ escHtml ( project ) } </td><td class="stat-count">${ count . toLocaleString ( ) } </td></tr>` ,
763- )
859+ . map ( ( { project, count } ) => {
860+ const known = formatDuration ( projMs . get ( project ) ?? 0 ) ;
861+ const unk = projUnknown . get ( project ) ?? 0 ;
862+ let timeCell = known ?? '—' ;
863+ if ( unk > 0 ) timeCell += `<span class="stat-unknown"> +${ unk } unknown</span>` ;
864+ return `<tr><td>${ escHtml ( project ) } </td><td class="stat-count">${ count . toLocaleString ( ) } </td><td class="stat-pct">${ pct ( count ) } </td><td class="stat-duration">${ timeCell } </td></tr>` ;
865+ } )
764866 . join ( '' ) ;
765867 statsProjectEl . innerHTML = `
766- <div class="sessions-stat-title">Sessions by Project</div>
868+ <div class="sessions-stat-title-row">
869+ <div class="sessions-stat-title">Sessions by Project</div>
870+ <button class="sessions-export-btn" data-export="project">Export CSV</button>
871+ </div>
767872 <table class="sessions-stat-table">
768- <thead><tr><th>Project</th><th>Count</th></tr></thead>
769- <tbody>${ projRows || '<tr><td colspan="2 ">No data</td></tr>' } </tbody>
873+ <thead><tr><th>Project</th><th>Count</th><th>%</th><th>Total Time</th>< /tr></thead>
874+ <tbody>${ projRows || '<tr><td colspan="4 ">No data</td></tr>' } </tbody>
770875 </table>
771876 ` ;
877+ statsProjectEl . querySelector ( '[data-export="project"]' ) . addEventListener ( 'click' , ( ) => {
878+ downloadCsv ( `${ selectedQuarter ?? 'all' } _project-summary.csv` ,
879+ [ 'Project' , 'Count' , 'Percent' , 'Total Time' ] ,
880+ byProj . map ( ( { project, count } ) => {
881+ const known = formatDuration ( projMs . get ( project ) ?? 0 ) ?? '' ;
882+ const unk = projUnknown . get ( project ) ?? 0 ;
883+ const timeVal = unk > 0 ? `${ known } (+${ unk } unknown)` . trim ( ) : known ;
884+ return [ project , count , pct ( count ) , timeVal ] ;
885+ } ) ,
886+ ) ;
887+ } ) ;
772888 }
773889
774890 function updateSortIndicators ( ) {
0 commit comments