Skip to content

Commit a63eed5

Browse files
committed
refactor: exports buttons, instrument_id normalization
1 parent e708891 commit a63eed5

3 files changed

Lines changed: 221 additions & 13 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* sessions-view.test.js — Unit tests for pure helpers in sessions/view.js.
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { normalizeInstrumentId } from '../sessions/view.js';
7+
8+
describe('normalizeInstrumentId', () => {
9+
it('returns a bare ID unchanged', () => {
10+
expect(normalizeInstrumentId('MESO.0')).toBe('MESO.0');
11+
expect(normalizeInstrumentId('BEH.1')).toBe('BEH.1');
12+
});
13+
14+
it('extracts name from <location>_<name>_<YYYYMMDD>', () => {
15+
expect(normalizeInstrumentId('323_MESO.0_20241219')).toBe('MESO.0');
16+
expect(normalizeInstrumentId('440_BEH.2_20230601')).toBe('BEH.2');
17+
});
18+
19+
it('extracts name from <location>_<name>_<YYYY-MM-DD>', () => {
20+
expect(normalizeInstrumentId('323_MESO.0_2024-12-19')).toBe('MESO.0');
21+
});
22+
23+
it('extracts name from <location>-<name>_<YYYYMMDD>', () => {
24+
expect(normalizeInstrumentId('323-MESO.0_20241219')).toBe('MESO.0');
25+
});
26+
27+
it('extracts name from <location>-<name>_<morename>_<YYYYMMDD>', () => {
28+
expect(normalizeInstrumentId('323-MESO.0_extra_20241219')).toBe('MESO.0_extra');
29+
});
30+
31+
it('handles null and empty string', () => {
32+
expect(normalizeInstrumentId(null)).toBe('');
33+
expect(normalizeInstrumentId('')).toBe('');
34+
});
35+
36+
it('extracts name from <location>_<name>_<YYMMDD> (short year 23-26)', () => {
37+
expect(normalizeInstrumentId('323_MESO.0_241219')).toBe('MESO.0');
38+
expect(normalizeInstrumentId('440_BEH.2_230601')).toBe('BEH.2');
39+
expect(normalizeInstrumentId('440_BEH.2_260101')).toBe('BEH.2');
40+
});
41+
42+
it('does not match short-year dates outside 23-26', () => {
43+
expect(normalizeInstrumentId('323_MESO.0_221219')).toBe('323_MESO.0_221219');
44+
expect(normalizeInstrumentId('323_MESO.0_270101')).toBe('323_MESO.0_270101');
45+
});
46+
47+
it('does not mangle IDs with no date suffix', () => {
48+
expect(normalizeInstrumentId('323_MESO.0_notadate')).toBe('323_MESO.0_notadate');
49+
});
50+
});

web/src/sessions/view.js

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = {
299318
const PAGE_SIZE = 100;
300319
const 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() {

web/styles/app.css

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3152,7 +3152,45 @@ body {
31523152
padding: 4px 6px;
31533153
}
31543154

3155-
.sessions-filter-clear {
3155+
.sessions-stat-title-row {
3156+
display: flex;
3157+
align-items: center;
3158+
justify-content: space-between;
3159+
gap: 8px;
3160+
}
3161+
3162+
.sessions-export-btn {
3163+
padding: 2px 8px;
3164+
font-size: 0.72rem;
3165+
border: 1px solid var(--surface-border);
3166+
border-radius: 3px;
3167+
background: var(--surface-bg);
3168+
color: var(--text-secondary);
3169+
cursor: pointer;
3170+
white-space: nowrap;
3171+
transition: background 0.15s, color 0.15s;
3172+
}
3173+
3174+
.sessions-export-btn:hover {
3175+
background: var(--text-primary);
3176+
color: var(--surface-bg);
3177+
border-color: var(--text-primary);
3178+
}
3179+
3180+
.sessions-table-export-btn {
3181+
align-self: flex-end;
3182+
margin-bottom: 6px;
3183+
display: block;
3184+
}
3185+
3186+
.sessions-stat-table tbody td.stat-pct {
3187+
text-align: right;
3188+
font-variant-numeric: tabular-nums;
3189+
color: var(--text-secondary);
3190+
white-space: nowrap;
3191+
}
3192+
3193+
31563194
align-self: flex-start;
31573195
padding: 2px 8px;
31583196
font-size: 0.76rem;
@@ -3275,6 +3313,10 @@ body {
32753313
color: var(--text-secondary);
32763314
}
32773315

3316+
.sessions-stat-table thead th:not(:first-child) {
3317+
text-align: right;
3318+
}
3319+
32783320
.sessions-stat-table tbody td {
32793321
padding: 3px 6px;
32803322
border-bottom: 1px solid var(--surface-border);

0 commit comments

Comments
 (0)