From bc30648f09fc562708a345336f116111d30484d2 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 25 Sep 2026 11:48:38 -0400 Subject: [PATCH 1/3] Add Review statistics and aggregate sequence timelines --- .../components/Review/ReviewDatasetsPanel.vue | 2 +- .../components/Review/ReviewPage.spec.ts | 43 +++++- .../components/Review/ReviewPage.vue | 18 ++- .../Review/ReviewStatisticsPanel.vue | 125 ++++++++++++++++++ client/dive-common/review/reviewSession.ts | 2 +- client/dive-common/review/statistics.spec.ts | 104 +++++++++++++++ client/dive-common/review/statistics.ts | 121 +++++++++++++++++ client/dive-common/use/useReview.ts | 10 ++ docs/Review.md | 28 ++++ 9 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 client/dive-common/components/Review/ReviewStatisticsPanel.vue create mode 100644 client/dive-common/review/statistics.spec.ts create mode 100644 client/dive-common/review/statistics.ts diff --git a/client/dive-common/components/Review/ReviewDatasetsPanel.vue b/client/dive-common/components/Review/ReviewDatasetsPanel.vue index 54ad9ce05..70ae7767d 100644 --- a/client/dive-common/components/Review/ReviewDatasetsPanel.vue +++ b/client/dive-common/components/Review/ReviewDatasetsPanel.vue @@ -54,7 +54,7 @@ export default defineComponent({ function statusText(status: string) { if (status === 'ready') return 'Ready'; - if (status === 'queued') return 'Loads when Results opens'; + if (status === 'queued') return 'Loads when Results or Statistics opens'; return 'Loading…'; } diff --git a/client/dive-common/components/Review/ReviewPage.spec.ts b/client/dive-common/components/Review/ReviewPage.spec.ts index 4c66d5ea2..3980d5a51 100644 --- a/client/dive-common/components/Review/ReviewPage.spec.ts +++ b/client/dive-common/components/Review/ReviewPage.spec.ts @@ -3,7 +3,7 @@ import Vue, { ComponentOptions, CreateElement, nextTick } from 'vue'; import type { NavigationGuard } from 'vue-router'; import type { DatasetConfig } from 'dive-common/apispec'; import type { ReviewService } from 'dive-common/use/useReview'; -import { takeReviewSession } from 'dive-common/review/reviewSession'; +import { holdReviewSession, takeReviewSession } from 'dive-common/review/reviewSession'; import ReviewPage from './ReviewPage.vue'; const mocks = vi.hoisted(() => ({ @@ -46,7 +46,8 @@ interface PageState { review: ReviewService; grid: { page: { value: number }; goToPage(page: number): void }; deleteEntry(entry: ReviewService['entries']['value'][number]): void; - view: 'results' | 'datasets'; + view: 'results' | 'datasets' | 'statistics'; + setView(view: 'statistics'): void; resolveLeave(choice: 'save' | 'discard' | 'cancel'): void; leaveDialog: boolean; } @@ -259,3 +260,41 @@ it('repopulates a cleared list when returning from the same sequence that starte expect(page.view).toBe('results'); second.destroy(); }); + +it('loads queued datasets on Statistics and updates counts after edits, deletion, and removal', async () => { + const wrapper = mountPage({ retainSession: false }); + const page = wrapper.vm as unknown as PageState; + await page.review.addDataset('stats', undefined, { defer: true }); + expect(page.review.statistics.value.trackCount).toBe(0); + page.setView('statistics'); + await page.review.ensureLoaded('stats'); + expect(page.view).toBe('statistics'); + expect(page.review.statistics.value.categories[0]).toMatchObject({ name: 'fish', count: 1 }); + const item = page.review.items.value[0]; + page.review.assignType(item, 'shark'); + expect(page.review.statistics.value.categories[0]).toMatchObject({ name: 'shark', count: 1 }); + page.review.query.threshold = 1; + page.review.query.type = 'absent'; + page.review.runQuery(); + expect(page.review.statistics.value.trackCount).toBe(1); + page.review.deleteTrack(item); + expect(page.review.statistics.value.trackCount).toBe(0); + page.review.removeDataset('stats'); + expect(page.review.statistics.value.timelines).toHaveLength(0); + wrapper.destroy(); +}); + +it('returns to Statistics after visiting a sequence viewer', async () => { + const first = mountPage(); + const page = first.vm as unknown as PageState; + await page.review.addDataset('stats'); + page.setView('statistics'); + holdReviewSession({ + review: page.review, view: 'statistics', page: 0, datasetKey: '', + }); + first.destroy(); + const second = mountPage({ retainSession: false }); + expect((second.vm as unknown as PageState).view).toBe('statistics'); + await nextTick(); + second.destroy(); +}); diff --git a/client/dive-common/components/Review/ReviewPage.vue b/client/dive-common/components/Review/ReviewPage.vue index fecc106c7..cdc482c31 100644 --- a/client/dive-common/components/Review/ReviewPage.vue +++ b/client/dive-common/components/Review/ReviewPage.vue @@ -17,6 +17,7 @@ import { import { ReviewEntry, ReviewSortOrder } from 'dive-common/review/types'; import type { ViewerFocus } from 'dive-common/review/viewerNavigation'; import UserSettingsDialog from 'dive-common/components/UserSettingsDialog.vue'; +import ReviewStatisticsPanel from './ReviewStatisticsPanel.vue'; import ReviewDatasetsPanel from './ReviewDatasetsPanel.vue'; import ReviewGrid from './ReviewGrid.vue'; import ReviewGridControls from './ReviewGridControls.vue'; @@ -27,7 +28,7 @@ const TYPE_LIST_ID = 'reviewTypeOptions'; /** Base footer height of a cell at scale 1 (type field plus caption). */ const CELL_FOOTER_BASE_PX = 48; -type ReviewView = 'results' | 'datasets'; +type ReviewView = 'results' | 'datasets' | 'statistics'; const SORT_OPTIONS: { value: ReviewSortOrder; text: string }[] = [ { value: 'confidence-desc', text: 'Confidence, high first' }, @@ -51,7 +52,7 @@ const SCOPE_OPTIONS = [ export default defineComponent({ name: 'ReviewPage', components: { - ReviewDatasetsPanel, ReviewGrid, ReviewGridControls, ReviewCell, UserSettingsDialog, + ReviewStatisticsPanel, ReviewDatasetsPanel, ReviewGrid, ReviewGridControls, ReviewCell, UserSettingsDialog, }, props: { sessionOwner: { type: String, default: '' }, @@ -85,6 +86,7 @@ export default defineComponent({ // loaded data opens Results. const hasReady = review.datasets.value.some((d) => d.status === 'ready'); const view = ref(hasReady || initialIds.length > 0 ? 'results' : 'datasets'); + if (resumed?.view === 'statistics') view.value = 'statistics'; const resuming = ref(!!resumed); // Covers the gap before the initial selection starts loading. const opening = ref(!hasReady && initialIds.length > 0); @@ -181,7 +183,7 @@ export default defineComponent({ function setView(next: ReviewView) { view.value = next; // Datasets picked on the Datasets view load only now, when results are wanted. - if (next === 'results') review.loadQueued(); + if (next !== 'datasets') review.loadQueued(); } /** Open the viewer on the frame the chip is showing (its first frame otherwise). */ @@ -483,6 +485,12 @@ export default defineComponent({ Datasets ({{ review.datasets.value.length }}) + + + mdi-chart-timeline-variant + + Statistics +