diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bb32f7c4..c46700813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,9 @@ jobs: working-directory: packages/hydroserver-ts run: npm run coverage - - name: Build TypeScript client + - name: Verify packed TypeScript client working-directory: packages/hydroserver-ts - run: npm run build + run: npm run test:package data-management: name: Data Management App 80% Coverage @@ -162,10 +162,6 @@ jobs: working-directory: apps/qc-app run: npm ci --workspaces=false - - name: Install local QC utils package - working-directory: apps/qc-app - run: npm install ../../packages/qc-utils --no-save --workspaces=false - - name: Run QC app typecheck working-directory: apps/qc-app run: npm run typecheck @@ -393,10 +389,6 @@ jobs: working-directory: apps/qc-app run: npm ci --workspaces=false - - name: Install local QC utils package - working-directory: apps/qc-app - run: npm install ../../packages/qc-utils --no-save --workspaces=false - - name: Install Playwright browsers working-directory: apps/qc-app run: npx playwright install --with-deps chromium firefox diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b25aa3ecd..b44e3b731 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,18 +29,37 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + # Legacy v1.x docs live on the docs-v1 branch and are served at /v1/ + # while users migrate. Remove this checkout and the v1 steps below to + # retire them. + - name: Checkout v1.x docs + uses: actions/checkout@v4 + with: + ref: docs-v1 + path: v1-src + sparse-checkout: apps/docs - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm - cache-dependency-path: apps/docs/package-lock.json + cache-dependency-path: | + apps/docs/package-lock.json + v1-src/apps/docs/package-lock.json - name: Install dependencies working-directory: apps/docs run: npm ci - name: Build working-directory: apps/docs run: npm run docs:build + - name: Install v1.x dependencies + working-directory: v1-src/apps/docs + run: npm ci + - name: Build v1.x into /v1 + working-directory: v1-src/apps/docs + run: | + npx vitepress build docs --base /v1/ + cp -r docs/.vitepress/dist "$GITHUB_WORKSPACE/apps/docs/docs/.vitepress/dist/v1" - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact diff --git a/apps/data-management/e2e/specs/sites.spec.ts b/apps/data-management/e2e/specs/sites.spec.ts index 372ab1958..e9651ce3a 100644 --- a/apps/data-management/e2e/specs/sites.spec.ts +++ b/apps/data-management/e2e/specs/sites.spec.ts @@ -451,6 +451,41 @@ test.describe('sites and workspaces', () => { await expect(updatedSiteRow).toHaveCount(0) }) + test('canceling or dismissing site edits discards unsaved changes', async ({ + page, + }) => { + const stamp = Date.now() + const unsavedProvince = `Unsaved Province ${stamp}` + const unsavedCounty = `Unsaved County ${stamp}` + + await authenticateSession(page, users.owner.email, users.owner.password) + await page.goto(`/sites/${fixtures.monitoringSites.mutablePublic.id}`) + + await page.getByTestId('edit-site-button').click() + await page.getByLabel('State/Province/Region').fill(unsavedProvince) + await page.getByRole('button', { name: 'Cancel' }).click() + + await expect(page.getByText(unsavedProvince, { exact: true })).toHaveCount(0) + + await page.getByTestId('edit-site-button').click() + await expect(page.getByLabel('State/Province/Region')).not.toHaveValue( + unsavedProvince + ) + await page.getByRole('button', { name: 'Cancel' }).click() + + await page.getByTestId('edit-site-button').click() + await page.getByLabel('County/District').fill(unsavedCounty) + await page.keyboard.press('Escape') + + await expect(page.getByText(unsavedCounty, { exact: true })).toHaveCount(0) + + await page.getByTestId('edit-site-button').click() + await expect(page.getByLabel('County/District')).not.toHaveValue( + unsavedCounty + ) + await page.getByRole('button', { name: 'Cancel' }).click() + }) + test('owner can edit, toggle privacy for, and delete a site with datastream CRUD', async ({ page, browser, diff --git a/apps/data-management/e2e/specs/visualization.spec.ts b/apps/data-management/e2e/specs/visualization.spec.ts index f6cf39459..459fb4932 100644 --- a/apps/data-management/e2e/specs/visualization.spec.ts +++ b/apps/data-management/e2e/specs/visualization.spec.ts @@ -308,20 +308,22 @@ test.describe("visualization", () => { await plotPublicDatastream(page); - // Date preset chips use abbreviated labels: 1m, 6m, YTD, 1y, all + // Date preset chips use abbreviated labels: 1w, 1m, 6m, YTD, 1y, all + await expect(page.getByText("1w").first()).toBeVisible(); await expect(page.getByText("1m").first()).toBeVisible(); await expect(page.getByText("6m").first()).toBeVisible(); await expect(page.getByText("1y").first()).toBeVisible(); + await page.getByText("1w").first().click(); await page.getByText("1m").first().click(); await page.getByText("6m").first().click(); await page.getByText("1y").first().click(); // Quick-range selection is mirrored into the route query. - await expect(page).toHaveURL(/selectedDateBtnId=3/); + await expect(page).toHaveURL(/selectedDateBtnId=4/); const copiedUrl = new URL(page.url()); - expect(copiedUrl.searchParams.get("selectedDateBtnId")).toBe("3"); + expect(copiedUrl.searchParams.get("selectedDateBtnId")).toBe("4"); expect(copiedUrl.searchParams.has("beginDate")).toBe(false); expect(copiedUrl.searchParams.has("endDate")).toBe(false); expect(copiedUrl.searchParams.has("xStart")).toBe(false); diff --git a/apps/data-management/package-lock.json b/apps/data-management/package-lock.json index 60ecfb9d9..33c4563d7 100644 --- a/apps/data-management/package-lock.json +++ b/apps/data-management/package-lock.json @@ -71,7 +71,7 @@ }, "../../packages/hydroserver-ts": { "name": "@hydroserver/client", - "version": "1.0.0", + "version": "2.0.0", "license": "BSD-3-Clause", "dependencies": { "oidc-client-ts": "^3.5.0", @@ -82,6 +82,8 @@ "@vitest/coverage-v8": "^4.1.0", "jsdom": "^22.1.0", "openapi-zod-client": "^1.18.3", + "rollup": "^4.61.0", + "rollup-plugin-dts": "^6.5.1", "tsx": "^4.16.2", "typescript": "^5.8.3", "vite": "^7.3.1", diff --git a/apps/data-management/src/App.vue b/apps/data-management/src/App.vue index 7ff4edf13..5dce54061 100644 --- a/apps/data-management/src/App.vue +++ b/apps/data-management/src/App.vue @@ -1,30 +1,52 @@ diff --git a/apps/data-management/src/config/settings.ts b/apps/data-management/src/config/settings.ts index 6dc8da120..e13e5eda0 100644 --- a/apps/data-management/src/config/settings.ts +++ b/apps/data-management/src/config/settings.ts @@ -26,6 +26,8 @@ const defaultSettings: AppSettings = { analyticsConfiguration: { enableClarityAnalytics: false, clarityProjectId: null, + enableGoogleAnalytics: false, + googleAnalyticsMeasurementId: null, }, extensionsConfiguration: { fileUploadEnabled: false, diff --git a/apps/data-management/src/main.ts b/apps/data-management/src/main.ts index 2ab2097b4..bcaa07206 100644 --- a/apps/data-management/src/main.ts +++ b/apps/data-management/src/main.ts @@ -10,55 +10,38 @@ import { createApp } from 'vue' import App from './App.vue' import router from './router/router' import vuetify from '@hydroserver/design-system/vue/vuetify' -import { createPinia, storeToRefs } from 'pinia' +import { createPinia } from 'pinia' import { injectClarity } from '@/plugins/clarity' +import { injectGoogleAnalytics } from '@/plugins/googleAnalytics' import { settings } from '@/config/settings' -import hs, { createHydroServer, User } from '@hydroserver/client' -import { useVocabularyStore } from './composables/useVocabulary' -import { useWorkspaceStore } from '@/store/workspaces' -import { useUserStore } from './store/user' +import { startAppInitialization } from '@/bootstrap/appInitialization' const app = createApp(App) const pinia = createPinia() -const hydroServerHost = import.meta.env.VITE_APP_PROXY_BASE_URL || '' -async function initializeApp() { +function initializeApp() { app.use(pinia) - // The session must be initialized before the router because some of the routes depend on the session state for access control - await createHydroServer({ host: hydroServerHost }) - - const vocabularyStore = useVocabularyStore() - await Promise.all([vocabularyStore.fetchAllVocabularies()]) - - const { user } = storeToRefs(useUserStore()) - - // Avoid spamming the console with an expected 401 before a user logs in. - // The session snapshot already tells us whether an authenticated user exists. - user.value = new User() - if (hs.session.isAuthenticated) { - const res = await hs.user.get() - user.value = res.ok && res.status !== 401 ? res.data : new User() - } - - if (hs.session.isAuthenticated) { - try { - const workspacesResponse = await hs.workspaces.listAllItems({ - isAssociated: true, - }) - const { setWorkspaces } = useWorkspaceStore() - setWorkspaces(workspacesResponse) - } catch (error) { - console.error('Error fetching workspaces', error) - } - } + // Start client initialization before the router's first navigation. The + // router guard waits for the client, while the rest of the bootstrap can + // finish after the app mounts. + const initialization = startAppInitialization() app.use(router) app.use(vuetify) settings.analyticsConfiguration.enableClarityAnalytics && settings.analyticsConfiguration.clarityProjectId && injectClarity(settings.analyticsConfiguration.clarityProjectId) + settings.analyticsConfiguration.enableGoogleAnalytics && + settings.analyticsConfiguration.googleAnalyticsMeasurementId && + injectGoogleAnalytics( + settings.analyticsConfiguration.googleAnalyticsMeasurementId + ) app.mount('#app') + + return initialization } -initializeApp() +void initializeApp().catch((error) => { + console.error('Error initializing app', error) +}) diff --git a/apps/data-management/src/models/settings.ts b/apps/data-management/src/models/settings.ts index 4dc02778d..07511a712 100644 --- a/apps/data-management/src/models/settings.ts +++ b/apps/data-management/src/models/settings.ts @@ -58,6 +58,8 @@ interface MapConfiguration { interface AnalyticsConfiguration { enableClarityAnalytics: boolean clarityProjectId?: string | null + enableGoogleAnalytics: boolean + googleAnalyticsMeasurementId?: string | null } interface ExtensionsConfiguration { diff --git a/apps/data-management/src/pages/Browse.vue b/apps/data-management/src/pages/Browse.vue index ba994c539..ea83b8e5e 100644 --- a/apps/data-management/src/pages/Browse.vue +++ b/apps/data-management/src/pages/Browse.vue @@ -112,6 +112,10 @@ import SiteAccessControl from '@/components/Site/SiteAccessControl.vue' import hs, { PermissionAction, PermissionResource } from '@hydroserver/client' import type { MonitoringSite, MonitoringSiteMapSummary } from '@hydroserver/client' import { useWorkspaceStore } from '@/store/workspaces' +import { + hasBootstrappedWorkspaces, + startAppInitialization, +} from '@/bootstrap/appInitialization' import { useWorkspacePermissions } from '@/composables/useWorkspacePermissions' import { useMonitoringSiteStore } from '@/store/monitoringSite' import { useTagStore } from '@/store/tags' @@ -218,7 +222,14 @@ const loadMonitoringSites = async () => { } const loadAssociatedWorkspaces = async () => { - if (!hs.session.isAuthenticated || workspaces.value.length) return + // App startup loads them; fetch here only if it couldn't. + await startAppInitialization() + if ( + !hs.session.isAuthenticated || + hasBootstrappedWorkspaces.value || + workspaces.value.length + ) + return try { const associatedWorkspaces = await hs.workspaces.listAllItems({ diff --git a/apps/data-management/src/pages/SiteDetails.vue b/apps/data-management/src/pages/SiteDetails.vue index 0e7e74012..53a28d71e 100644 --- a/apps/data-management/src/pages/SiteDetails.vue +++ b/apps/data-management/src/pages/SiteDetails.vue @@ -96,8 +96,9 @@ :monitoring-site-id="monitoringSiteId" /> - + ({ + hasBootstrappedWorkspacesState: { value: false }, + startAppInitializationMock: vi.fn(), + listAllItemsMock: vi.fn(), + listSiteSummariesMock: vi.fn(), + session: { isAuthenticated: true }, +})) + +vi.mock('@hydroserver/client', () => ({ + User: class User { + email = '' + }, + PermissionAction: { Create: 'create', Edit: 'edit', Delete: 'delete' }, + PermissionResource: { MonitoringSite: 'MonitoringSite' }, + default: { + session, + workspaces: { listAllItems: listAllItemsMock }, + monitoringSites: { listSiteSummaries: listSiteSummariesMock }, + }, +})) + +vi.mock('@/bootstrap/appInitialization', () => ({ + hasBootstrappedWorkspaces: hasBootstrappedWorkspacesState, + startAppInitialization: startAppInitializationMock, +})) + +vi.mock('@/components/Maps/OpenLayersMap.vue', () => ({ + default: { name: 'OpenLayersMap', template: '
' }, +})) +vi.mock('@/components/Browse/BrowseFilterTool.vue', () => ({ + default: { name: 'BrowseFilterTool', template: '
' }, +})) +vi.mock('@/components/Site/SiteForm.vue', () => ({ + default: { name: 'SiteForm', template: '
' }, +})) +vi.mock('@/components/Site/SiteDeleteModal.vue', () => ({ + default: { name: 'SiteDeleteModal', template: '
' }, +})) +vi.mock('@/components/Site/SiteAccessControl.vue', () => ({ + default: { name: 'SiteAccessControl', template: '
' }, +})) +vi.mock('@hydroserver/design-system/vue', () => ({ + HsFullScreenLoader: { name: 'HsFullScreenLoader', template: '
' }, +})) +vi.mock('@/composables/useWorkspacePermissions', () => ({ + useWorkspacePermissions: () => ({ hasPermission: () => true }), +})) + +import Browse from '../Browse.vue' + +describe('Browse page', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.stubGlobal('matchMedia', () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + hasBootstrappedWorkspacesState.value = false + session.isAuthenticated = true + startAppInitializationMock.mockReset().mockResolvedValue(undefined) + listAllItemsMock.mockReset().mockResolvedValue([]) + listSiteSummariesMock.mockReset().mockResolvedValue({ ok: true, data: [] }) + }) + + it('uses the workspaces app startup loaded instead of fetching them again', async () => { + hasBootstrappedWorkspacesState.value = true + + shallowMount(Browse) + await flushPromises() + + expect(startAppInitializationMock).toHaveBeenCalled() + expect(listAllItemsMock).not.toHaveBeenCalled() + }) + + it('fetches the workspaces itself when app startup could not', async () => { + const workspaces = [{ id: 'ws-1', name: 'Workspace 1' }] + listAllItemsMock.mockResolvedValue(workspaces) + + shallowMount(Browse) + await flushPromises() + + expect(listAllItemsMock).toHaveBeenCalledWith({ isAssociated: true }) + expect(useWorkspaceStore().workspaces).toEqual(workspaces) + }) + + it('waits for app startup before deciding whether to fetch', async () => { + let finishStartup = () => {} + startAppInitializationMock.mockReturnValue( + new Promise((resolve) => { + finishStartup = () => { + hasBootstrappedWorkspacesState.value = true + resolve() + } + }) + ) + + shallowMount(Browse) + await flushPromises() + expect(listAllItemsMock).not.toHaveBeenCalled() + + finishStartup() + await flushPromises() + expect(listAllItemsMock).not.toHaveBeenCalled() + }) + + it('does not fetch workspaces for an anonymous visitor', async () => { + session.isAuthenticated = false + + shallowMount(Browse) + await flushPromises() + + expect(listAllItemsMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts b/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts new file mode 100644 index 000000000..11f960e9e --- /dev/null +++ b/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { injectGoogleAnalytics } from '../googleAnalytics' + +afterEach(() => { + document.head.innerHTML = '' + delete window.dataLayer + delete window.gtag +}) + +describe('Google Analytics', () => { + it('loads the Google tag asynchronously and queues GA4 initialization', () => { + injectGoogleAnalytics('G-TEST123456') + + const script = document.head.querySelector('script')! + expect(script.async).toBe(true) + expect(script.src).toBe( + 'https://www.googletagmanager.com/gtag/js?id=G-TEST123456' + ) + expect(window.dataLayer?.map((entry) => Array.from(entry))).toEqual([ + ['js', expect.any(Date)], + ['config', 'G-TEST123456'], + ]) + }) + + it('preserves an existing data layer and safely encodes the measurement ID', () => { + const dataLayer: IArguments[] = [] + window.dataLayer = dataLayer + const measurementId = 'G-TEST&extra="value"' + + injectGoogleAnalytics(measurementId) + + expect(window.dataLayer).toBe(dataLayer) + const script = document.head.querySelector('script')! + const url = new URL(script.src) + expect(Array.from(url.searchParams)).toEqual([['id', measurementId]]) + expect(document.head.querySelectorAll('script')).toHaveLength(1) + expect(Array.from(dataLayer[1])).toEqual(['config', measurementId]) + }) +}) diff --git a/apps/data-management/src/plugins/googleAnalytics.ts b/apps/data-management/src/plugins/googleAnalytics.ts new file mode 100644 index 000000000..d668ab9d0 --- /dev/null +++ b/apps/data-management/src/plugins/googleAnalytics.ts @@ -0,0 +1,21 @@ +declare global { + interface Window { + dataLayer?: IArguments[] + gtag?: (...args: unknown[]) => void + } +} + +export function injectGoogleAnalytics(measurementId: string) { + const dataLayer = (window.dataLayer = window.dataLayer || []) + window.gtag = function (..._args: unknown[]) { + dataLayer.push(arguments) + } + window.gtag('js', new Date()) + // GA4 enhanced measurement tracks subsequent browser history changes. + window.gtag('config', measurementId) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}` + document.head.appendChild(script) +} diff --git a/apps/data-management/src/router/router.ts b/apps/data-management/src/router/router.ts index 7b5db6287..8322a3e3e 100644 --- a/apps/data-management/src/router/router.ts +++ b/apps/data-management/src/router/router.ts @@ -4,6 +4,10 @@ import { createRouter, createWebHistory, } from 'vue-router' +import { + isHydroServerReady, + waitForHydroServerInitialization, +} from '@/bootstrap/appInitialization' import { routes } from '@/router/routes' import hs from '@hydroserver/client' @@ -29,6 +33,10 @@ function updateDocumentTitle(matched: RouteRecordNormalized[]): void { } router.beforeEach(async (to: RouteLocationNormalized) => { + // The app mounts before the client is ready; the session decides access. + await waitForHydroServerInitialization() + if (!isHydroServerReady.value) return false + if (!hs.session.isAuthenticated && to.meta.requiresAuth) { await hs.session.login(to.fullPath) return false diff --git a/apps/data-management/src/store/dataVisualization.ts b/apps/data-management/src/store/dataVisualization.ts index 0dad7d389..29592874a 100644 --- a/apps/data-management/src/store/dataVisualization.ts +++ b/apps/data-management/src/store/dataVisualization.ts @@ -64,8 +64,8 @@ export const useDataVisStore = defineStore('dataVisualization', () => { ]) const endDate = ref(new Date()) - const oneMonth = 30 * 24 * 60 * 60 * 1000 - const beginDate = ref(new Date(endDate.value.getTime() - oneMonth)) + const oneWeek = 7 * 24 * 60 * 60 * 1000 + const beginDate = ref(new Date(endDate.value.getTime() - oneWeek)) const selectedDateBtnId = ref(0) const dataZoomStart = ref(0) const dataZoomEnd = ref(100) @@ -130,7 +130,7 @@ export const useDataVisStore = defineStore('dataVisualization', () => { tableSearch.value = '' summaryStatisticsArray.value = [] endDate.value = new Date() - beginDate.value = new Date(new Date().getTime() - oneMonth) + beginDate.value = new Date(new Date().getTime() - oneWeek) selectedDateBtnId.value = 0 dataZoomStart.value = 0 dataZoomEnd.value = 100 @@ -227,6 +227,14 @@ export const useDataVisStore = defineStore('dataVisualization', () => { const dateOptions = ref([ { id: 0, + label: '1w', + calculateBeginDate: () => { + const now = endDate.value + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) + }, + }, + { + id: 1, label: '1m', calculateBeginDate: () => { const now = endDate.value @@ -234,7 +242,7 @@ export const useDataVisStore = defineStore('dataVisualization', () => { }, }, { - id: 1, + id: 2, label: '6m', calculateBeginDate: () => { const now = endDate.value @@ -242,7 +250,7 @@ export const useDataVisStore = defineStore('dataVisualization', () => { }, }, { - id: 2, + id: 3, label: 'YTD', calculateBeginDate: () => { const now = endDate.value @@ -250,7 +258,7 @@ export const useDataVisStore = defineStore('dataVisualization', () => { }, }, { - id: 3, + id: 4, label: '1y', calculateBeginDate: () => { const now = endDate.value @@ -258,7 +266,7 @@ export const useDataVisStore = defineStore('dataVisualization', () => { }, }, { - id: 4, + id: 5, label: 'all', calculateBeginDate: () => { return getOldestBeginTime() diff --git a/apps/data-management/src/store/photos.ts b/apps/data-management/src/store/photos.ts index 1a2430b3c..5c6bcd7a1 100644 --- a/apps/data-management/src/store/photos.ts +++ b/apps/data-management/src/store/photos.ts @@ -10,6 +10,12 @@ export const usePhotosStore = defineStore('photos', () => { const photosToDelete = ref([]) const loading = ref(false) + const resetPendingPhotos = () => { + newPhotos.value = [] + newLinks.value = [] + photosToDelete.value = [] + } + const uploadNewPhotos = async (monitoringSiteId: string) => { if (!newPhotos.value.length && !newLinks.value.length) return @@ -76,9 +82,7 @@ export const usePhotosStore = defineStore('photos', () => { Snackbar.error('Unable to update photos.') } finally { loading.value = false - newPhotos.value = [] - newLinks.value = [] - photosToDelete.value = [] + resetPendingPhotos() } } @@ -88,6 +92,7 @@ export const usePhotosStore = defineStore('photos', () => { newLinks, photosToDelete, loading, + resetPendingPhotos, updatePhotos, } }) diff --git a/apps/data-management/src/utils/plotting/plotly.ts b/apps/data-management/src/utils/plotting/plotly.ts index 1a5a6a06d..5dcc23339 100644 --- a/apps/data-management/src/utils/plotting/plotly.ts +++ b/apps/data-management/src/utils/plotting/plotly.ts @@ -46,6 +46,12 @@ const rangeSelectorOptions = { yanchor: 'top', y: -0.15, buttons: [ + { + step: 'day', + stepmode: 'backward', + count: 7, + label: '1w', + }, { step: 'month', stepmode: 'backward', diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts index 79da34836..87ab77965 100644 --- a/apps/docs/docs/.vitepress/config.ts +++ b/apps/docs/docs/.vitepress/config.ts @@ -280,6 +280,10 @@ export default defineConfig({ text: "Building the Docker Image from Source", link: "/hosting-and-deployment/how-to/building-the-docker-image-from-source", }, + { + text: "Migrating from HydroServer v1 to v2", + link: "/hosting-and-deployment/how-to/migrating-from-v1-to-v2", + }, ], }, ], diff --git a/apps/docs/docs/hosting-and-deployment/how-to/index.md b/apps/docs/docs/hosting-and-deployment/how-to/index.md index 3e9066302..48d326278 100644 --- a/apps/docs/docs/hosting-and-deployment/how-to/index.md +++ b/apps/docs/docs/hosting-and-deployment/how-to/index.md @@ -5,3 +5,4 @@ In this section, we provide documentation for organizations wanting to deploy an * **[Setting Up a Production Deployment](/hosting-and-deployment/how-to/setting-up-a-production-deployment.md)**: Learn how to deploy a HydroServer instance in a production environment. * **[Using the Django Admin Dashboard](/hosting-and-deployment/how-to/using-the-administrator-dashboard.md)**: Learn how to use the Django admin dashboard to set up, manage, and customize your HydroServer instance. * **[Building the Docker Image from Source](/hosting-and-deployment/how-to/building-the-docker-image-from-source.md)**: Learn how to build HydroServer's Docker image yourself instead of using the published release image. +* **[Migrating from HydroServer v1 to v2](/hosting-and-deployment/how-to/migrating-from-v1-to-v2.md)**: Learn what to fix in your v1 data before upgrading to HydroServer v2, and what the upgrade changes. diff --git a/apps/docs/docs/hosting-and-deployment/how-to/migrating-from-v1-to-v2.md b/apps/docs/docs/hosting-and-deployment/how-to/migrating-from-v1-to-v2.md new file mode 100644 index 000000000..a6dee1351 --- /dev/null +++ b/apps/docs/docs/hosting-and-deployment/how-to/migrating-from-v1-to-v2.md @@ -0,0 +1,279 @@ +# Migrating from HydroServer v1 to v2 + +HydroServer v2 restructures much of the v1 data model. Upgrading runs a set of database migrations that convert your +v1 data to the v2 schema. This guide covers what you need to do to your data **before** upgrading, and what the +migrations will change, drop, or transform along the way. + +These migrations apply to deployments running **HydroServer v1.12**. If you are on an earlier v1 release, upgrade to +v1.12 first. + +::: danger Back up your database first +The v2 migrations are one-way. They cannot be reversed, and some v1 data is dropped or rewritten. Take a full backup of +your database (and your media storage if you host file attachments) before upgrading. Restoring that backup is the +only way to roll back to v1. +::: + +## Before You Upgrade + +1. Back up your database and media storage. +2. While still running v1, work through [Blocking Changes](#blocking-changes) and fix any data that doesn't meet the + new requirements. The fixes described below use the v1 Django admin dashboard + (`https:///admin`), which you can still access before you upgrade. +3. Review [Data Loss and Transformations](#data-loss-and-transformations) and save copies of anything listed there + that you need to keep. +4. Upgrade to v2 and run the migrations. + +The first v2 migration checks your v1 data for every blocking problem listed below before it changes anything. If it +finds problems, the upgrade stops with an error that lists each problem, the affected table and field, and up to ten +example record IDs, for example: + +``` +RuntimeError: The HydroServer v2 migrations cannot run until the following v1 data problems are fixed. +None of the HydroServer v2 migrations have been applied. + - sta_location: every Thing must have a Location. Things without one: '0190a1b2-...' + - sta_observedproperty.code: values must be at most 255 characters. Rows over the limit: '0190a1b3-...' +``` + +When this happens, your HydroServer data has not been modified. Switch back to your v1 deployment, fix the listed +problems, and run the upgrade again. + +## Blocking Changes + +The migrations will not run until all the following are fixed. + +### Every Thing must have a Location + +**Table:** `sta_location` (`thing` field) + +In v2, sites store their coordinates directly, so a site's location is required. Every Thing must have at least one +Location before upgrading. + +**To fix:** In the admin dashboard, go to **Measurement Data** > **Locations** and add a Location for each Thing listed +in the error. Alternatively, delete Things that are no longer needed from **Measurement Data** > **Things**. + +### Observed property codes and types are limited to 255 characters + +**Table:** `sta_observedproperty` (`code` and `observed_property_type` fields) + +Both fields were previously limited to 500 characters. + +**To fix:** In the admin dashboard, go to **Measurement Data** > **Observed properties** and shorten the **Code** and +**Observed property type** of each listed observed property to 255 characters or fewer. + +### Definitions are limited to 2,000 characters + +**Tables:** `sta_observedproperty` (`definition` field), `sta_unit` (`definition` field), and `sta_processinglevel` +(`definition` field) + +Definitions were unlimited text fields in v1. In v2 they are URL fields limited to 2,000 characters. + +For processing levels, this only applies when the definition is a URL. Non-URL processing level definitions are moved +to other fields instead (see [Processing levels are restructured](#processing-levels-are-restructured)). + +**To fix:** In the admin dashboard, go to **Measurement Data** > **Observed properties**, **Units**, or +**Processing levels** and shorten each listed definition to 2,000 characters or fewer. + +### File attachments must reference a file + +**Tables:** `sta_thingfileattachment` and `sta_datastreamfileattachment` (`file_attachment` field) + +In v2, each linked resource must have either a file or a URL. A v1 file attachment with an empty file path can't be +migrated. HydroServer doesn't create these records, so you should only see this error if attachments were created or +edited outside HydroServer. + +**To fix:** In the admin dashboard, go to **Measurement Data** > **Thing file attachments** or +**Datastream file attachments** and upload a file to, or delete, each listed attachment. + +### Role names must be unique + +**Table:** `iam_role` (`name` field) + +Global roles (roles without a workspace) must have unique names, and roles within the same workspace must have unique +names. + +**To fix:** In the admin dashboard, go to **Identity and Access Management** > **Roles** and rename or delete the +duplicate roles listed in the error. + +### API keys must use HydroServer's key format + +**Table:** `iam_apikey` (`hashed_key` field) + +In v2, API keys become service accounts and are identified by the 12-character prefix of their key. API keys created by +HydroServer always use this format. An API key with a different format can only come from data loaded outside + HydroServer and can't be used to authenticate anyway. + +**To fix:** In the admin dashboard, go to **Identity and Access Management** > **API Keys** and delete the listed API +keys. + +### A datastream can be the target of only one ETL mapping + +**Table:** `etl_etlmapping` (`target_datastream` field) + +**To fix:** In the admin dashboard, go to **ETL Orchestration** > **Etl mappings** and delete or re-target mappings so +that each datastream listed in the error is the target of at most one mapping. + +### Placeholder variables must be unique per data connection + +**Table:** `etl_placeholdervariable` (`name` and `variable_type` fields) + +Each combination of name and variable type can only be used once per data connection. + +**To fix:** In the admin dashboard, go to **ETL Orchestration** > **Placeholder variables** and rename or delete the +listed duplicates. + +### Rating curve input values must be unique + +**Table:** `products_ratingcurvepoint` (`input_value` field) + +Each input value can only appear once in a rating curve. + +**To fix:** In the admin dashboard, go to **Derived Data Products** > **Rating curve points** and delete or change the +duplicate points listed in the error. + +## Data Loss and Transformations + +The migrations make the following changes without stopping the upgrade. Review them before upgrading, and save copies +of any data you need to keep. + +### Locations are merged into sites + +**Tables:** `sta_location` (dropped), `sta_thing` (renamed to `sta_monitoringsite`) + +Things are renamed to monitoring sites, and each site's Location is merged into the site. + +- **Data loss:** If a Thing has more than one Location, only the Location with the lowest ID is kept. All others are + deleted. +- **Data loss:** Location **name**, **description**, and **encoding type** are dropped. Coordinates, elevation, + elevation datum, administrative areas, and country are kept. +- Location IDs no longer exist. Anything that referenced a Location by ID should use the monitoring site instead. + +### Sampling feature types are dropped + +**Tables:** `sta_thing` (`sampling_feature_type` field), `sta_samplingfeaturetype` (dropped) + +- **Data loss:** Each Thing's **sampling feature type** is dropped, along with the sampling feature type vocabulary. +- **Sampling feature code** becomes the site's **code**, and **site type** becomes the site's **type**. + +### Sensors become methods + +**Tables:** `sta_sensor` (renamed to `sta_method`), `sta_sensorencodingtype` (dropped) + +Sensors are renamed to methods. **Method code**, **method type**, and **method link** become **code**, **type**, and +**definition**. **Manufacturer** and **sensor model link** become **sensor model manufacturer** and +**sensor model definition**. + +- **Data loss:** Each sensor's **encoding type** is dropped, along with the sensor encoding type vocabulary. + +### Tags are consolidated + +**Tables:** `sta_thingtag` and `sta_datastreamtag` (dropped) + +Tags are now stored directly on each site and datastream as a set of key/value pairs, and each key can only appear +once. + +- **Data loss:** If a Thing or datastream has more than one tag with the same key, only the most recently created tag's + value is kept. +- Tags with an empty value are migrated, but v2 requires tag values to be non-empty. You'll need to fix those tags + before you can edit the site or datastream they belong to. To avoid this, fill in or delete empty tag values in the + admin dashboard under **Measurement Data** > **Thing tags** and **Datastream tags** before upgrading. + +### File attachments become linked resources + +**Tables:** `sta_thingfileattachment` and `sta_datastreamfileattachment` (renamed to +`sta_monitoringsitelinkedresource` and `sta_datastreamlinkedresource`), `sta_fileattachmenttype` (renamed to +`sta_linkedresourcetype`) + +File attachments are renamed to linked resources, which can now point to either an uploaded file or an external URL. +Existing files are kept. + +- File attachments get new IDs. Their v1 numeric IDs are replaced with UUIDs, so anything that referenced an attachment + by its v1 ID will no longer work. + +### Vocabulary tables get new IDs + +**Tables:** `sta_sampledmedium`, `sta_datastreamaggregation` (renamed to `sta_aggregationstatistic`), +`sta_datastreamstatus`, `sta_methodtype`, `sta_unittype`, `sta_variabletype` (renamed to `sta_observedpropertytype`), +`sta_sitetype` (renamed to `sta_monitoringsitetype`), and `sta_fileattachmenttype` (renamed to `sta_linkedresourcetype`) + +- Vocabulary terms get new IDs. Their v1 numeric IDs are replaced with UUIDs. Term names are unchanged, and records that + use a term (for example, a datastream's status) store the name, so they aren't affected. + +### Processing levels are restructured + +**Table:** `sta_processinglevel` + +v1 processing levels have a **code**, **definition**, and **explanation**. v2 processing levels have a **code**, +**name**, **definition** (a URL), and **description**. Each processing level is converted as follows: + +- **Explanation** becomes **description**. An empty explanation becomes an empty description. +- If the **definition** is a URL, it stays the definition, and the **code** becomes the **name**. +- If the **definition** is 255 characters or fewer and isn't a URL, it becomes the **name**, and the definition is + cleared. +- If the **definition** is longer than 255 characters and isn't a URL, it's added to the start of the **description** + (followed by a blank line and the explanation), the **code** becomes the **name**, and the definition is cleared. To + control the name and description yourself, shorten these definitions before upgrading. + +When a processing level has no definition or code to use as its name, its ID is used as its name. You can rename these +processing levels after upgrading. + +### Result qualifier codes become names + +**Table:** `sta_resultqualifier` + +Each result qualifier's **code** becomes its **name**. Observations that reference result qualifiers are unchanged. + +### Definitions that aren't URLs are kept but no longer valid + +**Tables:** `sta_observedproperty`, `sta_unit`, and `sta_sensor` (`definition`, `method_link`, and `sensor_model_link` +fields) + +Observed property, unit, and method definitions (and method sensor model definitions) must be URLs in v2. Existing +values that aren't URLs are migrated unchanged, but you'll need to replace or clear them before you can edit those +records. To avoid this, update them in the admin dashboard before upgrading. + +### API keys become service accounts + +**Tables:** `iam_apikey` (renamed to `iam_serviceaccount`), `iam_collaborator` + +Each API key becomes a service account. Existing keys keep working. + +- In v1, an API key held a role directly. In v2, the role is assigned through a workspace collaborator record, which + the migration creates for each API key. +- Each service account gets a generated email address in the form + `@service-accounts.`. + +### Permissions are consolidated + +**Tables:** `iam_permission`, `iam_role` + +In v1, each permission row granted one action (view, create, edit, delete, or all) on one resource type. In v2, each +role has a single permission row per resource type, with a flag for each action. Duplicate rows for the same role and +resource type are merged, and the granted actions are combined, so no access is lost. + +- The resource types **Thing**, **Sensor**, and **APIKey** are renamed to **MonitoringSite**, **Method**, and + **ServiceAccount**. +- The role **is API key role** and **is user role** flags are dropped. Any role can now be assigned to both users and + service accounts. + +### Workspace ownership settings are converted + +**Table:** `iam_user` (`is_ownership_allowed` field) + +The **is ownership allowed** flag is replaced by an **owned workspace limit**. Users who were allowed to own workspaces, +and all superusers, get an unlimited limit. All other users get a limit of `0`. + +### Pending workspace confirmations + +**Tables:** `iam_workspacedeleteconfirmation` (dropped), `iam_workspacetransferconfirmation` + +- **Data loss:** Pending workspace delete confirmations are dropped. +- **Data loss:** The **initiated** timestamp of pending workspace transfers is dropped. The transfers themselves are + kept. + +### Audit log tables are no longer used + +**Tables:** `easyaudit_crudevent`, `easyaudit_loginevent`, and `easyaudit_requestevent` + +HydroServer v2 no longer includes the Django Easy Audit package. Its tables and data are left in your database, but +HydroServer no longer reads or writes them. Export any audit history you need before upgrading. You can drop these +tables after upgrading. diff --git a/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md b/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md index a98df34c2..f0fba34b1 100644 --- a/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md +++ b/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md @@ -38,8 +38,10 @@ The **Identity and Access Management** section also exposes the following admin | Model | Purpose | | --- | --- | -| **Users** | Manage user profile fields, account status, staff/superuser access, and workspace ownership limits. | -| **Organizations** | Manage organizations that can be associated with users and workspaces. | +| **Users** | Manage user profile fields (including user type, which accepts any text), account status, staff/superuser access, and workspace ownership limits. Superusers created with `createsuperuser` get the user type `Admin`. | +| **User Types** | Manage the account types users can choose from when signing up or editing their profile. Only types marked **Public** are offered. If there are none, users are given the type `Other`. Use **Load Default Data** to add HydroServer's default types. | +| **Organizations** | Review or edit the organizations users are affiliated with. | +| **Organization Types** | Manage the organization types users can choose from for their organization. Only types marked **Public** are offered. If there are none, organizations are given the type `Other`. Use **Load Default Data** to add HydroServer's default types. | | **Workspaces** | Review or edit workspace ownership, privacy, and workspace metadata. | | **Roles** | Configure reusable permission bundles for user and service-account collaborators. | | **Permissions** | Review the resource-level permissions that belong to roles. | @@ -56,7 +58,7 @@ The following website settings can be configured under **Website Configuration** | Model | Purpose | | --- | --- | | **Instance Configuration** | Configure instance-wide website content and legal links. | -| **Analytics Configuration** | Enable or disable Microsoft Clarity analytics. | +| **Analytics Configuration** | Enable or disable Microsoft Clarity and Google Analytics independently. | | **Map Configuration** | Configure the default map view, default map layers, and map service providers. | | **Map Layers** | Create the map layers available to users in HydroServer map controls. | | **Contact Information** | Add contact cards shown on the About page. | @@ -78,12 +80,26 @@ Use **Website Configuration** > **Instance Configuration** to manage general web ### Analytics Configuration Use **Website Configuration** > **Analytics Configuration** to optionally enable -[Microsoft Clarity](https://clarity.microsoft.com/) analytics: +[Microsoft Clarity](https://clarity.microsoft.com/) and/or Google Analytics for the Data Management app. +Both are disabled by default. Each service only loads when its enable checkbox is selected and its ID is provided. | Field | Purpose | | --- | --- | | **Enable clarity analytics** | Turns Microsoft Clarity tracking on or off. | | **Clarity project id** | Stores the project ID from the Microsoft Clarity project settings. | +| **Enable google analytics** | Turns Google Analytics tracking on or off, independently of Clarity. | +| **Google analytics measurement id** | Stores the GA4 web stream measurement ID, such as `G-XXXXXXXXXX`. | + +For Google Analytics, create a GA4 web data stream for your HydroServer URL and copy its measurement ID +from **Admin** > **Data streams**. In the stream's **Enhanced measurement** settings, enable **Page views**, +including **Page changes based on browser history events** in the advanced settings. This tracks navigation +within the app using Google's [automatic page-view measurement](https://developers.google.com/analytics/devguides/collection/ga4/single-page-applications). +After saving the HydroServer settings, allow up to ten minutes for the cached page settings to expire, reload +the app, and verify visits and navigation in Google Analytics Realtime or DebugView. + +Set **Instance Configuration** > **Privacy policy link** to your organization's analytics/cookie notice; +the app already displays this link in its footer. +This integration tracks browser usage of the Data Management app; it does not add API request tracking. ### Map Configuration @@ -152,19 +168,16 @@ below. | Model | Purpose | | --- |-------------------------------------------------------------------------| -| **Things** | Manage monitoring sites and their workspace/privacy metadata. | -| **Locations** | Review or edit site location records. | +| **Monitoring Sites** | Manage monitoring sites, including their location, tags, and workspace/privacy metadata. | | **Methods** | Manage reusable method metadata, including optional instrument details. | | **Observed Properties** | Manage reusable observed property metadata. | | **Units** | Manage reusable unit metadata. | | **Processing Levels** | Manage reusable processing level metadata. | -| **Datastreams** | Review datastream metadata and run selected maintenance actions. | -| **Result Qualifiers** | Manage result qualifier codes. | +| **Datastreams** | Review datastream metadata and tags, and run selected maintenance actions. | +| **Result Qualifiers** | Manage result qualifiers. | | **Linked Resource Types** | Manage linked resource type vocabulary values. | | **Monitoring Site Linked Resources** | Review linked resources attached to monitoring sites. | | **Datastream Linked Resources** | Review linked resources associated with datastreams. | -| **Monitoring Site Tags** | Review key/value tags associated with monitoring sites. | -| **Datastream Tags** | Review key/value tags attached to datastreams. | The **Datastreams** admin page includes two actions for selected datastreams: @@ -189,12 +202,11 @@ models below and click its **Load Default ...** button in the upper-right corner | **Measurement Data** | **Observed Properties** | Default observed property metadata. | | **Measurement Data** | **Processing Levels** | Default processing level metadata. | | **Measurement Data** | **Units** | Default unit metadata. | -| **Measurement Data** | **Site Types** | Default monitoring site type vocabulary values. | -| **Measurement Data** | **Sampling Feature Types** | Default sampling feature type vocabulary values. | +| **Measurement Data** | **Monitoring Site Types** | Default monitoring site type vocabulary values. | | **Measurement Data** | **Method Types** | Default method type vocabulary values. | -| **Measurement Data** | **Variable Types** | Default variable type vocabulary values. | +| **Measurement Data** | **Observed Property Types** | Default observed property type vocabulary values. | | **Measurement Data** | **Unit Types** | Default unit type vocabulary values. | -| **Measurement Data** | **Datastream Aggregations** | Default aggregation statistic vocabulary values. | +| **Measurement Data** | **Aggregation Statistics** | Default aggregation statistic vocabulary values. | | **Measurement Data** | **Datastream Statuses** | Default datastream status vocabulary values. | | **Measurement Data** | **Sampled Mediums** | Default sampled medium vocabulary values. | | **Measurement Data** | **Linked Resource Types** | Default linked resource type vocabulary values. | @@ -210,7 +222,7 @@ see in metadata forms: | Section | Models | | --- | --- | | **Identity and Access Management** | **User Types**, **Organization Types**, **Roles** | -| **Measurement Data** | **Datastream Aggregations**, **Datastream Statuses**, **Linked Resource Types**, **Method Types**, **Sampled Mediums**, **Sampling Feature Types**, **Site Types**, **Unit Types**, **Variable Types** | +| **Measurement Data** | **Aggregation Statistics**, **Datastream Statuses**, **Linked Resource Types**, **Method Types**, **Monitoring Site Types**, **Observed Property Types**, **Sampled Mediums**, **Unit Types** | Workspace-scoped metadata such as observed properties, units, processing levels, methods, and result qualifiers can also be reviewed in the admin dashboard. When a record has no workspace, it is available as system-level metadata. diff --git a/apps/qc-app/.env.example b/apps/qc-app/.env.example new file mode 100644 index 000000000..ba096f755 --- /dev/null +++ b/apps/qc-app/.env.example @@ -0,0 +1,7 @@ +# Backend the dev server proxies /api to (e.g. a HydroServer instance). +VITE_APP_PROXY_BASE_URL= + +# Set to 1 to drop COOP/COEP headers when talking to a backend that doesn't +# serve Cross-Origin-Resource-Policy. Disables SharedArrayBuffer-backed +# workers (they fall back to inline execution). +VITE_APP_DISABLE_COOP= diff --git a/apps/qc-app/README.md b/apps/qc-app/README.md index cb0042628..e947938cd 100644 --- a/apps/qc-app/README.md +++ b/apps/qc-app/README.md @@ -14,11 +14,11 @@ This web app facilitates QC/QA for time series observations stored in a HydroSer The app is the operator's view of HydroServer's QC pipeline: -1. **Browse** — pick a workspace, filter datastreams by site / observed property / processing level, and plot up to five at once on a synchronized multi-axis chart. -2. **QC one stream at a time** — the first plotted stream is the QC target. The other plotted traces are read-only context. -3. **Filter / edit / add** — every operation (Value Threshold, Find Gaps, Persistence, Interpolate, Drift Correction, Fill Gaps, Add Points, etc.) commits a `HistoryItem` to a replayable edit history backed by [`@uwrl/qc-utils`](https://www.npmjs.com/package/@uwrl/qc-utils). -4. **Save / load a QC History** — export the history as a JSON document, replay it on the same datastream a week later, or templatize across stations. -5. **Submit** — push the quality-controlled observations back to HydroServer in `replace` mode. +1. **Browse**: pick a workspace, filter datastreams by site / observed property / processing level, and plot up to four at once on a synchronized multi-axis chart, plus the one you edit. +2. **QC one stream at a time**: the Edit button on a datastream row picks what to edit, separately from the plot checkboxes. The editor draws that stream over its raw source, with the plotted datastreams as read-only context. +3. **Filter / edit / add**: every operation (Value Threshold, Find Gaps, Persistence, Interpolate, Drift Correction, Fill Gaps, Add Points, etc.) commits a `HistoryItem` to a replayable edit history backed by [`@uwrl/qc-utils`](https://www.npmjs.com/package/@uwrl/qc-utils). +4. **Save / load a QC History**: export the history as a JSON document, replay it on the same datastream a week later, or templatize across stations. +5. **Save and commit**: save the edits to a QC session as a draft, then commit the session to push the quality-controlled observations to its managed datastream in `replace` mode. The heavy lifting (worker-parallelized typed-array kernels, calibration, history replay, save / load wire format) lives in `@uwrl/qc-utils`. This repo is the Vue / Vuetify / Pinia / Plotly UI plus the orchestration around it. @@ -39,7 +39,7 @@ During normal HydroServer development, open QC through the Data Management app a | Var | Required | Purpose | |----------------------------------|----------|-------------------------------------------------------------------------| -| `VITE_APP_DISABLE_COOP` | no | Drop the `Cross-Origin-Opener-Policy` + `Cross-Origin-Embedder-Policy` headers. Use only when the backend you're hitting doesn't serve `Cross-Origin-Resource-Policy` (older HydroServer deployments). The `qc-utils` worker layer falls back to inline kernels when SAB isn't available, so the app still works — just slower on large edits. | +| `VITE_APP_DISABLE_COOP` | no | Drop the `Cross-Origin-Opener-Policy` + `Cross-Origin-Embedder-Policy` headers. Use only when the backend you're hitting doesn't serve `Cross-Origin-Resource-Policy` (older HydroServer deployments). The `qc-utils` worker layer falls back to inline kernels when SAB isn't available, so the app still works (just slower on large edits). | | `VITE_APP_E2E_HOOKS` | no | Set to `1` to expose `window.__vbwTestHooks` for Playwright. CI sets this automatically. | Cross-origin isolation is on by default (`vite.config.ts` sends `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`) so `SharedArrayBuffer` is available to the qc-utils worker pool. @@ -48,7 +48,7 @@ Cross-origin isolation is on by default (`vite.config.ts` sends `Cross-Origin-Op ``` src/ -├─ pages/ Top-level routed views (Visualize, Edit, Submit, Auth). +├─ pages/ Routed views: Home (the Select and Edit views) and Workspaces. ├─ components/ │ ├─ Navigation/ NavigationRail, EditDrawer, SelectDrawer. │ ├─ FilterPoints/ One panel per filter op (ValueThreshold, GapFinder, …). @@ -57,22 +57,29 @@ src/ ├─ composables/ │ ├─ useDataSelection.ts The bridge between Plotly's selectedpoints and the Pinia store. │ ├─ useFilterDispatch.ts Shared "open panel → run op → highlight result" sequence. -│ └─ useQcHistory.ts Wiring around qc-utils' serializeHistory / applyHistory. +│ ├─ useEditEntry.ts Entering and leaving the editor. +│ ├─ useEditSession.ts Open, save and commit a QC session. +│ ├─ useLeaveSession.ts The one question asked before any exit. +│ └─ useQcHistory.ts Download the QC history (qc-utils' serializeHistory). +├─ services/qualityControl/ The QC API workflow: sessions, saving, commit, past-session replay. ├─ store/ │ ├─ plotly.ts Plot ref, edit history, redraw, suppressedEchoSelection sentinel. │ ├─ dataVisualization.ts Selected datastream, plotted streams, selectedData. │ ├─ userInterface.ts Drawer state, operator + filter inputs, persisted prefs. +│ ├─ qcSession.ts The open history's sessions, which one is viewed, saved baseline. +│ ├─ workingCopies.ts Each in-progress session's replayed record, shared by both views. +│ ├─ observations.ts Per-datastream observation cache. │ └─ workspaces.ts Workspace list + selected workspace (persisted). ├─ utils/plotting/ │ ├─ events.ts plotly_click / plotly_relayout / mousemove handlers. │ ├─ relayout.ts Debounced viewport recomputation, tick alignment. -│ ├─ selected.ts handleSelected — translates Plotly selection into a SELECTION dispatch. +│ ├─ selected.ts handleSelected: translates Plotly selection into a SELECTION dispatch. │ ├─ staging.ts Ghost-fill markers + drag-resizable stage shape. -│ └─ plotly.ts Trace builders + low-level setSelectedPoints / clearSelection. +│ └─ plotly.ts Barrel over the modules above, plus options (trace builders) and operations. └─ router/ vue-router 5 setup with workspace + auth guards. ``` -The data flow for an edit is always: panel collects args → `useFilterDispatch` / `useQcHistory` calls `selectedSeries.data.dispatch(...)` (qc-utils) → qc-utils mutates typed arrays + appends a `HistoryItem` → `redraw()` pushes the new x / y into Plotly. The UI never touches the typed arrays directly. +The data flow for an edit is always: panel collects args → `useFilterDispatch` calls `selectedSeries.data.dispatch(...)` (qc-utils) → qc-utils mutates typed arrays + appends a `HistoryItem` → `redraw()` pushes the new x / y into Plotly. The UI never touches the typed arrays directly. ## Working with `@uwrl/qc-utils` locally @@ -100,7 +107,7 @@ Specs live next to source under `src/**/__tests__/`. Plotly is mocked at the mod ### End-to-end tests (Playwright) -End-to-end specs cover the QC golden path: load a datastream, apply a filter, apply an edit, submit. Browser matrix: **chromium** and **firefox**. WebKit is intentionally excluded — `SharedArrayBuffer` + COOP / COEP behaviour differs in Safari and needs separate validation. +End-to-end specs cover the QC golden path: load a datastream, apply a filter, apply an edit, save and commit the session. Browser matrix: **chromium** and **firefox**. WebKit is intentionally excluded: `SharedArrayBuffer` + COOP / COEP behaviour differs in Safari and needs separate validation. One-time setup: @@ -111,14 +118,14 @@ npx playwright install chromium firefox Run modes: ```bash -npm run e2e # headless — Chromium + Firefox (CI mode, fast for local) +npm run e2e # headless: Chromium + Firefox (CI mode, fast for local) npm run e2e:live # same-origin smoke via Data Management ``` For interactive debugging, append Playwright flags, e.g. `npm run e2e -- --ui` or `npm run e2e -- --headed`. -The Playwright config in `playwright.config.ts` auto-starts the QC Vite dev server at `http://127.0.0.1:15173` and reuses an existing one outside CI. The dev server is what serves the COOP / COEP headers `SharedArrayBuffer` needs — running e2e against a static `file://` build won't work. +The Playwright config in `playwright.config.ts` auto-starts the QC Vite dev server at `http://127.0.0.1:15173` and reuses an existing one outside CI. The dev server is what serves the COOP / COEP headers `SharedArrayBuffer` needs; running e2e against a static `file://` build won't work. The mocked specs intercept HydroServer routes via `page.route()` and serve fixture JSON, so most runs need no backend. The live golden-path spec expects both frontends to be running and enters QC through `http://127.0.0.1:1203/qc/`. diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index 8539acd6e..9d80ed8f4 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # APIs & Interoperability -This is a reference for the surfaces a developer integrates against — +This is a reference for the surfaces a developer integrates against: both the in-app TypeScript surfaces (Pinia stores, composables, plotting utils) and the external HydroServer REST endpoints the app consumes. @@ -12,7 +12,7 @@ utils) and the external HydroServer REST endpoints the app consumes. and the QC History JSON file format. - **JSON over the wire, typed arrays in memory.** The app fetches observations in HydroServer's columnar JSON format and inflates them - to `Float64Array` + `Float32Array` inside `ObservationRecord`. + to `Float64Array`s inside `ObservationRecord`. - **History is the contract.** The QC History file format is the durable record of edits. It is not pinned to a datastream id, so the same script can be replayed against many datastreams. @@ -75,20 +75,26 @@ hs.datastreams.createObservations( fields: ['phenomenonTime', 'result'], data: dataX.map((ts, i) => [new Date(ts).toISOString(), dataY[i]]), }, - { mode: 'replace' } + { + mode: 'replace', + phenomenonTimeStart: session.phenomenonTimeStart, + phenomenonTimeEnd: session.phenomenonTimeEnd, + } ) ``` -Replace mode tells HydroServer to overwrite any observation inside the -posted window. The QC App always submits the full edited window — -incremental submission is not implemented today. +Replace mode deletes every observation between `phenomenonTimeStart` and +`phenomenonTimeEnd`, then inserts the posted ones. The QC App always sends +the session window as that range, so points deleted at the window's edges are +removed too, and a session that deleted every point commits an empty body. +Without the range, the backend falls back to the span of the posted +observations. Incremental submission is not implemented today. ### Result qualifier codes -Listed via `fetchWorkspaceResultQualifiers`. The QC App tracks selected -qualifiers in `store/qualifiers.ts`, but **does not yet serialize them on -submit** (see [QUALITY.md](./QUALITY.md) tech-debt section and the TODO -in `useQcSubmission.ts:42`). +Listed via `fetchWorkspaceResultQualifiers`. Applying qualifiers is hidden +behind `QUALIFIER_TOOL_ENABLED`, and commits **do not serialize qualifier +codes** (see [QUALITY.md](./QUALITY.md) tech-debt section). ## Internal: composables @@ -117,32 +123,299 @@ await setSelected([0, 1, 2, 5]) // dispatches SELECTION await clearSelected({ recordHistory: false }) // skip history append on cleanup ``` +### `useEditEntry()` + +```ts +const { enterEdit, startSessionOver, openEditor, leaveEdit, closeEditor } = + useEditEntry() + +const result = await enterEdit(managedId, window) +// result: 'editing' | 'needs-window' | 'not-managed' | 'superseded' | 'kept' +``` + +Sets the edit target and switches to the Edit view. Shared by the row Edit +flow (`StartEditingFlow.vue`), reload resume (`useResumeEditSession()` +below), and share-link hydration (the `ed` query param). + +- `enterEdit(managedId, window?, view?)`: sets the edit target + (`setEditTarget`), resolves its QC history via `beginEditing()`, and + resumes an in-progress session. With no session and no `window`, returns + `'needs-window'` so the caller opens `SessionWindowDialog.vue` (the + session-window step) and calls back in with the chosen window, or calls + `startSessionOver` instead; with a `window`, starts the session + immediately. `view` is the layout to land on, `Edit` by default; a share + link made from the Select view passes `Select`, so the session reopens + behind it. The view shows as soon as the entry starts, with + `qcSession.openingDatastreamId` set until the session has loaded, so the + plot shows a loading state; a view the user switches to meanwhile is kept. + Called on the target already open, with no `window`, it only + shows that view: nothing about the session is re-entered. Taking over from + another open session first asks `useLeaveSession().requestLeave()`, and + returns `'kept'` when the user keeps the open one. The create step in + `StartEditingFlow.vue` asks earlier, through `closeEditor()`: a created + managed datastream is always a new target, so the takeover is certain and + the question must come before anything reaches the server. + When the load is superseded it returns `'superseded'`. It shows the + `ResumeSupersededError` message only when the same target is still open; a + newer entry taking over, or the editor being closed mid-load, ends it + quietly, since the user asked for that. +- `startSessionOver(window)`: starts a new session on the current edit + target over `window` (a `utils/timeRangePresets.ts` `TimeWindow`); backs + **Start new session** and the editor footer's **New session**. +- `openEditor()`: shows the Edit layout for the current target. Pure + navigation, used by the nav rail's Edit button and the Select view's + **Open editor**. A datastream picked from a row enters with + `view = DrawerType.Select`, so it lands on that preview first. +- `leaveEdit()`: returns to the Select view, clears the resume pointer and + the edit target (`clearEditTarget`). The user has already been asked by + then, or there was nothing to ask about. Switching views is not an exit and + does not use it. +- `closeEditor()`: what a user's exit calls. Runs the leave flow + (`useLeaveSession().requestLeave()`) and, once it resolves true, ends the + session with `leaveEdit()`. Returns false when the user chose to stay, so + the caller abandons its own exit. Backs the editor footer's **Close** and + the nav rail's Home and Log out. + +Entries can overlap (a reload resume and a click land close together); +whichever call sets the edit target last owns the view: a call that finds +the target changed after an `await` stands down without touching view +state, the target, or the resume pointer. + +### `useLeaveSession()` + +```ts +const { requestLeave } = useLeaveSession() +if (!(await requestLeave())) return // the user stayed; nothing changed +``` + +The one decision point for leaving an open edit session, used by every exit: +the editor footer's **Close**, the nav rail's Home, workspace switch and log +out, the row Edit button on a different datastream, and in-app navigation +(`leaveSessionGuard` in `router/guards.ts`). Switching between the Select and +Edit views is not an exit, so it never asks. The prompt state is module-wide +and `LeaveSessionDialog.vue`, mounted once in `App.vue`, shows it. + +- `leaveCase()`: which of the four situations applies right now. + `'none'` (nothing being edited, no session, or committed history being + viewed) leaves silently; `'unsaved'` has edits that never reached the + session; `'empty'` is a session holding no operations at all; `'saved'` is + a session whose work is all saved. +- `requestLeave()`: decides the case, shows the matching prompt and carries + the answer out. Resolves true when the caller may go on, false when the + user stays. A second request supersedes the first, whose caller stays put; + once an answer is being carried out (`leaveWork`), a second request is + refused outright rather than inheriting that answer. +- `saveAndLeave()` / `discardEditsAndLeave()`: the `'unsaved'` answers. + Saving is refused with no session open. Discarding that empties the session + falls through to the `'empty'` prompt rather than leaving silently. +- `keepSession()` / `discardSessionAndLeave()`: the `'empty'` answers. + `keepSession()` is also the `'saved'` answer, keeping the session as it is. + Discarding deletes the session through + `useManagedDatastreams().deleteSession`. A failed delete keeps the user in + the session. +- `cancelLeave()`: stay, with the zoom, staged band and unsaved edits intact. +- `forgetSession()`: drop the resume pointer only. For exits that unmount the + editor, where clearing the edit target would have the editor's URL writer + replace the route mid-navigation. +- `leavePrompt` / `leaveWork`: what the dialog renders (the case, the name of + the datastream being left, the unsaved count and whether saving is + possible), and which answer is currently running. + +### `useResumeEditSession()` + +```ts +const { settled } = useResumeEditSession(async (id) => { + await startEditing.value?.resume(id) // StartEditingFlow +}) +``` + +`settled` turns true once the resume has finished (or failed), or the catalog +landed with nothing to resume. `VisualizeData.vue` holds the share URL as it +came in until then. It also holds it from the leave guard asking the user +until that navigation lands (`isLeavingPage` in `router/navigationState.ts`): +replacing the route then would cancel the navigation being answered for, +such as a workspace switch. If the user stays, the URL catches up. + +Reopens the editor after a page reload, using the persisted +`qcSession.resumeDatastreamId`: waits for the workspace catalog to arrive +(it's empty at mount), then calls the supplied callback with the managed +datastream id (normally wired to `StartEditingFlow`'s exposed `resume`, which +enters through `useEditEntry()` and opens the session-window step when there +is no session to continue). Resumes at +most once, and the chance closes as soon as the catalog lands: a pointer set +after that belongs to an entry already navigating on its own, and a later +catalog refresh (a commit rewrites the managed datastream) must not re-enter +behind the user. A pointer to a datastream missing from the catalog (deleted, +or another workspace) is dropped rather than retried. + +Note the watcher must not use Vue's `once` together with `immediate`: the +immediate call fires on the initial empty catalog and stops the watcher, so +the resume would never run on the cold reload it exists for. + +### `useUnsavedChangesWarning()` + +```ts +useUnsavedChangesWarning(hasUnsavedChanges) // Ref +``` + +Asks the browser for its native "leave site?" confirmation while the ref is +true, so a reload mid-session can't silently drop edits that never reached +the server. Registers on mount and removes the listener on unmount. Browsers +ignore any custom message and only honour the prompt once the user has +interacted with the page. + ### `useQcHistory()` ```ts -const { exportHistory, importHistory } = useQcHistory() +const { exportHistory } = useQcHistory() + +await exportHistory() // downloads qc-history--.json +``` + +Serializes the edit record's history with the session window (the viewed or +in-progress session, or the loaded range when there is none). Download only; +the app has no import. + +### `useEditLock()` + +`src/composables/useEditLock.ts`. The one answer to "can the edit record take +a new operation right now?", read by every surface that starts an edit: the +Operations drawer, `OperationPanel`, plot selections (`handleSelected`, +`clearSelected`) and the data table's cells and Save button. + +| Name | Kind | Type | Notes | +|------------|----------|---------------------------------------|-------| +| `editLock` | computed | `'readOnly' \| 'preview' \| null` | `readOnly` while a committed session is on screen (`useQcSessionStore().isReadOnly`), else `preview` while an earlier history step is shown (`usePlotlyStore().previewIndex`), else `null`. | + +### `useEditSession()` + +Orchestrates the server-backed QC session workflow against the +`services/qualityControl/` glue: + +```ts +const { + beginEditing, startSession, saveDraft, discardUnsavedEdits, commit, + hasUnsavedChanges, unsavedEditCount, +} = useEditSession() +``` + +- `beginEditing()`: resolves the QC history for the QC datastream, loads + its sessions and resumes the in-progress one. Returns `'resumed'`, + `'needs-session'` when there is none to resume, or `'not-managed'` when + the datastream isn't QC-managed yet. Resuming rebuilds the shared working + copy; if that rebuild is superseded it throws `ResumeSupersededError` + rather than asking for a session, and `enterEdit` returns to the Select + view. Entries can overlap, so it also throws + `ResumeSupersededError` when the edit target changes during any of its + fetches, and it never writes the session store (source, sessions) for a + target it no longer owns: sessions are fetched with `fetchSessions` and + applied only while still the owner. +- `startSession(spec)`: creates a session and copies the source window in. + When the history already has an in-progress session it resumes that one + through the same replay instead (throwing `ResumeSupersededError` the same + way), so saved draft operations are never dropped. The same ownership rule + guards its session reload. +- `saveDraft()`: persists the record's edit operations to the session + (append-only reconcile). +- `discardUnsavedEdits()`: drops edits made since the last save and restores + edited comments. +- `commit()`: saves, checks that the source window's checksum + (`datastreams.getObservationsChecksum`) still matches the one the session + started with, pushes observations (`mode: 'replace'` over the session + window), then locks the session and reloads the sessions. A failed push + throws before the session is locked, so it stays in progress. If + another target took over the editor while they reloaded, it leaves the + session store and the saved-edits baseline alone and still resolves, since + the commit itself went through. +- `hasUnsavedChanges` / `unsavedEditCount`: the working copy compared with + the saved-edits baseline (`qcSession.savedEdits` / `savedComments`). The + baseline lives in the store, so every caller agrees: the editor footer and + the leave flow both read it. + +### `useCreateManagedDatastream()` + +```ts +const { create } = useCreateManagedDatastream() +const { managedDatastream, history } = await create({ + source, + processingLevelId, + name, + description, + status, // omitted to create the datastream without one + methodId, +}) +``` + +`description`, `status` and `methodId` are writable on a datastream create +(see the client's `writableKeys`). They are passed as overrides, so they win +over the values copied from the source. + +Delegates to the tested `createManagedDatastream` orchestration with the +live client (`hs.datastreams` + `hs.qualityControlHistories`). A create +answers with the new id only, so both are read back: the datastream with +`expand_related: true`, so `managedDatastream` comes back in the same +`Datastream & DatastreamExtended` shape as the `datastreams` catalog and can be +appended to it directly. Without the flag the read is the flat model (FK ids +only) and catalog consumers reading `ds.processingLevel.id` would break. + +### `useManagedDatastreams()` + +```ts +const { loadForSource } = useManagedDatastreams() +const options = await loadForSource(sourceDatastreamId) +// options: [{ historyId, managed, sessions }] +``` + +Resolves a source datastream's managed (QC) datastreams from the loaded QC +histories and fetches each one's sessions. Feeds the row Edit button's +chooser (`StartEditingFlow.vue`), which lists managed datastreams with their +in-progress/committed sessions. + +- `deleteManaged(historyId, managedId)`: removes the QC history, then the + managed datastream. +- `deleteSession(historyId, sessionId)`: removes the in-progress session and + throws the server's message when it refuses. The server refuses a committed + session, since its edits are already on the managed datastream. + +### `useDatastreamMetadata()` -await exportHistory() // downloads qc-history--.json -const report = await importHistory(file) -// report = { applied: 12, failed: [{ index, method, error }, ...] } +```ts +const { methods, statuses, load } = useDatastreamMetadata() +await load() ``` -`exportHistory` reads the current wall-clock window from -`useDataVisStore`. `importHistory` fetches the script's authored window -into the active datastream **before** replay (selection-coupled ops -reference indices against this windowed dataset). +Reference data for the create-datastream form: the active workspace's methods +(`hs.methods`, every page, including the system-level ones) and the datastream +status vocabulary (`hs.datastreamStatuses`). Loaded on demand when the form +opens rather than with the workspace catalog in `App.vue`, since nothing else +needs it. Neither request throws: a list that fails to load stays empty and +the form falls back to the source datastream's own value. + +### `useWorkspacePermissions()` -### `useQcSubmission()` +Synchronous, reactive role/permission checks for gating UI. The role +travels with the `Workspace` object (`collaboratorRole.permissions`; owners +have a null role; `accountType === 'admin'` overrides), so no separate +endpoint is needed. ```ts -const { submitQcEdits } = useQcSubmission() -await submitQcEdits() // POST observations with mode=replace, clear history +const { canEdit, canCommit, canCreateDatastream, roleName } = + useWorkspacePermissions() +canEdit() // selected workspace: can work in QC sessions? +canCommit() // ...and commit them? +canCreateDatastream(ws) // can create a managed datastream here? +roleName(ws) // 'Owner' | | 'Admin' | 'Read-only' ``` -Single-shot: guards on (selectedSeries + qcDatastream + non-empty -history), serializes `[phenomenonTime, result]` rows, POSTs with -`mode: 'replace'`, surfaces a Snackbar, and clears the history in place -on success. +Each check is what the API enforces for that step: + +| Check | Needs | Gates | +|---|---|---| +| `canEdit` | edit Datastream (QC sessions and operations) | row Edit, New session, Save | +| `canCommit` | `canEdit` and create Observation (the push to the managed datastream) | Commit | +| `canCreateDatastream` | `canEdit` and create Datastream (its QC history needs edit) | the create-datastream form | + +`roleName` labels each workspace's role on the picker. ### `useResizable()` @@ -151,7 +424,7 @@ and the plot's table-vs-chart splitter. ### `useBufferedNumber()` -Debounced numeric input wrapper for filter panels — avoids dispatching +Debounced numeric input wrapper for filter panels. Avoids dispatching on every keystroke when a user is typing a threshold. ## Internal: Pinia stores @@ -168,79 +441,135 @@ Columns in the per-store tables: `T` inside `Ref`; actions list the call signature. The "Persistence" line at the top of each store cites the -`pinia-plugin-persistedstate` config — the storage key and the +`pinia-plugin-persistedstate` config: the storage key and the specific slice picked. Stores with no Persistence line are session-only. -### `useDataVisStore()` — `src/store/dataVisualization.ts` +Persisting a store deep-watches all of its state (pinia's `$subscribe`), +whatever `pick` says, so every change walks the whole store. Stores that +hold series, records or selections are therefore never persisted: their +preferences live in small persisted stores (`qcPreferences`, `editResume`) +and the data stores expose those same refs. + +### `useDataVisStore()` (`src/store/dataVisualization.ts`) Catalog data (sites, datastreams, taxonomy), sidebar filters, plotted -set + QC target, time-range window. The orchestrator for everything in +set + edit target, time-range window. The orchestrator for everything in the Select drawer and the rebuild pipeline that owns `rebuildPlot()`. -Persistence: `selectedDateBtnId` only (so the user's preset choice -survives reload); catalogs, filters, and in-flight maps refetch cleanly -on boot. +Not persisted. `selectedDateBtnId`, `contextPresetId` and +`showSourceContext` are `qcPreferences` refs, so they survive a reload +there; catalogs, filters, and in-flight maps refetch cleanly on boot. | Name | Kind | Type / signature | Notes | |-------------------------------------|----------|---------------------------------------------------|-------| | `monitoringSites` | state | `MonitoringSite[]` | Sites in the active workspace; fetched once on workspace mount. | | `datastreams` | state | `(Datastream & DatastreamExtended)[]` | All visible datastreams (with `expand_related` nested objects). | +| `qcHistories` | state | `QualityControlHistory[]` | Workspace QC histories (each links a managed datastream to its source); loaded with the catalog. | +| `managedDatastreamIds` | computed | `Set` | Ids of every managed (QC) datastream; hidden from the catalog (reached via the row Edit chooser). | +| `historiesBySource` | computed | `Map` | `sourceDatastreamId` -> its QC histories; drives the row Edit chooser. | +| `addQcHistory` | action | `(history: QualityControlHistory) => void` | Register a newly-created history so its managed datastream hides from the catalog and shows in the chooser without a reload. | +| `removeManagedDatastream` | action | `(historyId: string, managedId: string) => void` | Drop a deleted managed datastream + its history from local state (chooser/catalog) after deleting it server-side. | +| `replaceDatastream` | action | `(ds: Datastream & DatastreamExtended) => void` | Swap a fresh copy into the catalog and plotted set (used after a commit moves a managed datastream's phenomenon times). | | `observedProperties` | state | `ObservedProperty[]` | Taxonomy for the filter chips. | | `processingLevels` | state | `ProcessingLevel[]` | Taxonomy for the filter chips. | | `selectedMonitoringSites` | state | `MonitoringSite[]` | Site filter selection (sidebar). | | `selectedObservedPropertyNames` | state | `string[]` | Observed-property filter selection. | | `selectedProcessingLevelNames` | state | `string[]` | Processing-level filter selection. | -| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections. | -| `plottedDatastreams` | state | `Datastream[]` | Up to 5 streams currently on the chart. | -| `qcDatastreamId` | state | `string \| null` | Storage form of the QC target; survives plotted-list mutations. | -| `qcDatastream` | computed | `Datastream \| null` | Live lookup of `qcDatastreamId` in `plottedDatastreams`. | -| `qualifierSet` | state | `Set` | Qualifier codes seen on the QC target's loaded points. | +| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections, with managed (QC) datastreams excluded. | +| `plottedDatastreams` | state | `Datastream[]` | Up to 4 streams the user chose to plot. Editing never adds to or removes from it (snapshots are the exception, dropped on leave); the 4-stream cap doesn't count the edit target or its source, so the plot holds at most 6 series while editing. | +| `qcDatastreamId` | state | `string \| null` | The edit target's id. Set only by the edit flow (`setEditTarget` / `clearEditTarget`); null in the Select view. | +| `qcDatastream` | computed | `Datastream \| null` | Live catalog lookup of `qcDatastreamId` in `datastreams`, not `plottedDatastreams`, since the edit target isn't a plotted entry. | +| `sourceContextDatastream` | computed | `Datastream \| null` | The source drawn around the edit target as context (light grey, cut around the session window), with a `ctx:` id so the user can also plot the source as an ordinary series beside it. Null without an edit target or with context switched off. | +| `seriesDatastreams` | computed | `Datastream[]` | What the plot actually draws, in order: `[edit target, its source, ...plotted minus the source]` while editing, otherwise `plottedDatastreams` unchanged. A plotted edit target is drawn under its `ctx:` id, from its committed data, beside the edit record. `toggleDatastream` takes either id. Refresh, colour assignment, series ordering, working-copy invalidation, `PlottedDatastreams`, the share watcher, and snapshots all iterate this instead of `plottedDatastreams`. | +| `qualifierSet` | state | `Set` | Qualifier codes seen on the edit target's loaded points. | | `selectedQualifier` | state | `string` | Active qualifier in the picker. | | `selectedData` | state | `number[] \| null` | Index list of the active selection (lasso, box, click). | | `hasSelectionShape` | state | `boolean` | True while a box/lasso shape exists, even when it captured zero points. | -| `loadingStates` | state | `Map` | Per-datastream in-flight observation fetches. | -| `beginDate` / `endDate` | state | `Date` | Active loaded time-range window (the date pickers' source of truth). | -| `dateOptions` | state | `Array<{ id, icon, label, title, calculateBeginDate }>` | Preset definitions (1w, 1m, 6m, 1y, YTD, All). | -| `selectedDateBtnId` | state | `number` | Active preset id; `-1` when the user picked dates manually. | +| `loadingStates` | state | `Map` | Per-datastream in-flight observation fetches. Only the latest request for a datastream clears its flag. | +| `isEditorReady` | computed | `boolean` | True while editing once the session window is known, the edit record is on the plot, no session is opening (`isSwitchingSession`), and no plot work is pending: no queued or running plot load (including the context re-anchor around the session window) and no tracked draw. Bound to `data-editor-ready` on the edit view's `edit-plot-column`; e2e `waitForEditorReady` waits on it. | +| `trackPlotWork` | action | `(work: () => Promise) => Promise` | Run `work` counted as pending plot work for `isEditorReady`. Plot loads and `setEditRecord` use it, and `Plot.vue` counts its first draw from the moment the plot element appears. | +| `beginDate` / `endDate` | state | `Date` | Active loaded window. A preset re-resolves it on every plot rebuild: around the edit session's window while one is set (`presetAroundWindow`), otherwise back from the context data's (`seriesDatastreams` minus the edit target) end (`presetWindow`). It also re-resolves when the edit session's window loads or changes. A custom range stays fixed. | +| `selectedDateBtnId` | state | `number` | The Select view's Time range preset id (default `1`, 1m); `-1` (`CUSTOM_PRESET_ID`) for a manual range. Presets are defined in `utils/timeRangePresets.ts`. | +| `contextPresetId` | state | `number` | The editor's Context range preset id, remembered apart from `selectedDateBtnId` so neither moves the other. Persisted by `qcPreferences`. | +| `activePresetId` | computed | `number` (writable) | The preset the loaded range follows: `contextPresetId` while an edit target is set, else `selectedDateBtnId`. `onDateBtnClick` and a custom `setDateRange` write it. | +| `editSessionWindow` | computed | `TimeWindow \| null` | The viewed session's window, else the in-progress one's; null without an edit target. | +| `editSourceDatastream` | computed | `Datastream \| null` | The edit target's source datastream, however it is drawn. | +| `showSourceContext` | state | `boolean` | Whether the edit target's source is drawn around it as context (default `true`). Persisted by `qcPreferences`. | +| `setShowSourceContext` | action | `(show: boolean) => Promise` | Turn the source context on or off from the Context menu; rebuilds the plot while editing. | | `matchesSelectedMonitoringSite` | action | `(ds) => boolean` | Filter predicate; exposed so the table can reuse it on row updates. | | `matchesSelectedObservedProperty` | action | `(ds) => boolean` | Same shape as above. | | `matchesSelectedProcessingLevel` | action | `(ds) => boolean` | Same shape as above. | -| `setDateRange` | action | `({ begin?, end?, update?, custom? }) => Promise` | No-ops when neither bound moves; clears zoom history when it does. | -| `onDateBtnClick` | action | `(id: number) => void` | Anchors `endDate` to today, recomputes `beginDate`, applies the preset. | -| `syncRangeToPreset` | action | `() => void` | Re-derives `beginDate`/`endDate` from `selectedDateBtnId`. Run on `afterHydrate` so the restored preset's window applies to the first load. | -| `refreshGraphSeriesArray` | action | `() => Promise` | Reconciles `graphSeriesArray` against `plottedDatastreams` (fetch deltas + reorder + recolor). | -| `resetState` | action | `() => void` | Clears filters + plotted set on a workspace swap; preserves the preset preference. | +| `setDateRange` | action | `({ begin?, end?, update?, custom? }) => Promise` | No-ops when neither bound moves. Otherwise sets the bounds at once and queues a range reload as a plot load (see `rebuildPlot`), which joins a queued rebuild instead of loading on its own and is skipped when an earlier load already caught up with the range. While editing it reloads context only and preserves the zoom (`redraw(false, true)`), since the edit target's data isn't fetched here. Otherwise it clears zoom history and applies the new window. | +| `onDateBtnClick` | action | `(id: number) => Promise` | Selects the preset and applies the window the internal `resolvePresetWindow` gives it: around the edit session's window while editing one, otherwise back from the context data's end. With nothing to anchor to, only the selection changes. | +| `refreshGraphSeriesArray` | action | `() => Promise` | Reconciles `graphSeriesArray` against `seriesDatastreams` (fetch deltas + reorder + recolor), skipping the edit target (its data is owned by the edit session, not this refresh). A managed datastream with a loaded working copy (`useWorkingCopiesStore`) uses it instead of fetching. Invalidates the working copy of any managed datastream no longer in `seriesDatastreams`, except the edit target. | +| `resetState` | action | `() => void` | Clears filters, the plotted set, and the edit target, and drops every working copy, on a workspace swap; preserves the preset preference. | | `toggleDatastream` | action | `(ds: Datastream) => Promise` | Plot if absent, unplot if present. | -| `plotDatastream` | action | `(ds: Datastream) => Promise` | Add to plot; promotes to QC when nothing's there yet. | -| `unplotDatastream` | action | `(id: string) => Promise` | Remove; promotes the previous plotted entry to QC if removing the QC target. | +| `plotDatastream` | action | `(ds: Datastream) => Promise` | Add to plot. Plotting never picks or changes the edit target. | +| `unplotDatastream` | action | `(id: string) => Promise` | Remove from the plotted set. Never touches the edit target. | | `clearPlottedDatastreams` | action | `() => Promise` | Drop the entire plotted set. | -| `setPlottedDatastreams` | action | `(items: Datastream[], qcId?: string \| null) => Promise` | Wholesale replace; used by URL hydration. | -| `setQcDatastream` | action | `(id: string \| null) => Promise` | Change QC target; preserves the current zoom. | -| `rebuildPlot` | action | `() => Promise` | Serialized rebuild (drop zoom history, refresh series, regenerate options, render). Coalesces concurrent callers. | - -### `usePlotlyStore()` — `src/store/plotly.ts` +| `sourceGroupIds` | action | `(sourceId: string) => string[]` | The source datastream plus every managed (QC) datastream derived from it. | +| `plotSourceSelection` | action | `(sourceId: string, ids: string[]) => Promise` | Apply a whole "what to plot for this source" choice at once: `ids` is the complete set wanted from that source's group. Group members absent from `ids` are unplotted, additions are appended in `ids` order. One rebuild for the whole selection; never touches the edit target. | +| `addSnapshotSeries` | action | `(id: string, record: ObservationRecord, meta: SnapshotMeta) => Promise` | Add a frozen history snapshot as an extra comparison line under the synthetic id `snap::`. Never touches the edit target; `refreshGraphSeriesArray` skips its fetch. | +| `removeSnapshotSeries` | action | `(id: string) => Promise` | Drop one snapshot line. Leaves the edit target alone. | +| `setPlottedDatastreams` | action | `(items: Datastream[]) => Promise` | Replace the plotted set wholesale (used by URL hydration). Doesn't touch the edit target. Hydrate that separately with `setEditTarget`. | +| `setEditTarget` | action | `(managedId: string) => Promise` | Begin editing `managedId`: drops snapshots, resets the `qcSession` store (not `resumeDatastreamId`) and clears zoom history when the target changes, sets `qcDatastreamId`, rebuilds the plot. Its data arrives later via `setEditRecord`. This action doesn't fetch it. | +| `setEditRecord` | action | `(record: ObservationRecord) => Promise` | Put the edit target's record on the plot: updates its graph series in place if one exists, otherwise adds it (`buildGraphSeries`), then re-plots keeping the x view and refitting the target's y axis (`handleNewPlot` with `refitSeriesIds`). The only path by which the edit target's data reaches the plot. Counted as plot work until drawn. | +| `clearEditTarget` | action | `() => Promise` | Stop editing: drops snapshots, invalidates the edit target's working copy, clears `qcDatastreamId`, removes its graph series, and rebuilds. Plotted datastreams are left exactly as they were. | +| `rebuildPlot` | action | `() => Promise` | Rebuild (refresh series, regenerate options, render) as a plot load. Plot loads (rebuilds and `setDateRange` range reloads) run one at a time; requests made while one runs share one queued follow-up, which is a rebuild if any of them was, and every caller resolves only after a load that started after its change. A load whose range moves while it loads drops those responses and loads again before drawing. Drops zoom history in the Select view; keeps the zoom while an edit target is set. | + +### `useWorkingCopiesStore()` (`src/store/workingCopies.ts`) + +One working copy per managed datastream, keyed by its in-progress +session: the Select-view plot and the editor share it so the preview +shows exactly what editing opens. Not persisted, not reactive (records +hold large typed arrays that must not be proxied). + +Concurrency: a per-managed-id generation counter, bumped by `invalidate`, +`set`, `clear`, and every new build, guards each build's cache write: a +build whose generation is no longer current is discarded rather than +resurrecting a stale copy. Concurrent `load()` calls for the same managed +id share one in-flight build and resolve to the same record, and a `load()` +that meets an in-flight `rebuild()` joins it instead of starting its own +build. A superseded `rebuild()` or `load()` never returns the record it +discarded: it resolves to the newer in-flight `rebuild()`'s result +(whichever fetch finishes first), otherwise to whatever is now cached +(`null` after `invalidate` or `clear`). `invalidate`, `set`, and `clear` +also drop the in-flight load they supersede, so the next `load()` starts +fresh. + +| Name | Kind | Type / signature | Notes | +|--------------|--------|-------------------------------------------------------------------------------------------------------|-------| +| `get` | action | `(managedId: string) => WorkingCopy \| undefined` | Current cached copy for a managed datastream, if any. | +| `load` | action | `(managed: Datastream, source: Datastream, historyId: string) => Promise` | Returns the cached copy when it matches the history's in-progress session; rebuilds and caches otherwise; `null` (and evicts any stale cache entry) when the history has no in-progress session. Concurrent calls for the same managed id dedupe onto one build, and a call made while a `rebuild()` is in flight joins it. If superseded mid-build, resolves to the superseding `rebuild()`'s result or the now-current cache entry (or `null`) instead of the discarded build. | +| `rebuild` | action | `(managed: Datastream, source: Datastream, historyId: string, session: SessionWindow) => Promise` | Always reconstructs from the session window and replays its operations, even when a cached copy already matches; supersedes any in-flight `load()` build or earlier `rebuild()` for this managed id and overwrites the cache. If superseded itself while awaiting, never returns the discarded build: resolves to a later in-flight `rebuild()`'s result, or reads the cache after a synchronous `invalidate`/`set`/`clear`, which is `null` when nothing ended up cached. | +| `set` | action | `(managedId: string, sessionId: string, record: ObservationRecord, begin: Date, end: Date) => void` | Insert or replace a cache entry directly (used by `startSession` to store a new session's fresh base); supersedes any in-flight build or load for this managed id. | +| `invalidate` | action | `(managedId: string) => void` | Drop a managed datastream's cached copy; supersedes any in-flight build or load for this managed id. | +| `clear` | action | `() => void` | Drop every cached copy and supersede every in-flight build and load; called by `useDataVisStore().resetState()` on a workspace reset. | +| `extents` | action | `(managedIds: string[]) => { phenomenonBeginTime: string; phenomenonEndTime: string }[]` | The cached copies' windows for the given managed ids, in ISO form; ids with no cached copy are omitted. | + +### `usePlotlyStore()` (`src/store/plotly.ts`) Owns the Plotly DOM ref, the per-series array driving the chart, viewport state (tooltips, crosshair, hover, zoom history), and the redraw / restyle plumbing. -Persistence: `tooltipsMaxDataPoints`, `tooltipsMode`, and -`tooltipsManualEnabled` (key `qc.plot.tooltipsMaxDataPoints`) — the -user's data-points-mode preference. Everything else is ephemeral (DOM -handles, live chart caches). +Not persisted. `tooltipsMaxDataPoints`, `tooltipsMode`, and +`tooltipsManualEnabled` are `qcPreferences` refs, so the user's +data-points-mode preference survives a reload there. Everything else is +ephemeral (DOM handles, live chart caches). | Name | Kind | Type / signature | Notes | |----------------------------|----------|---------------------------------------------------|-------| | `graphSeriesArray` | state | `GraphSeries[]` | Per-series state driving traces, colors, and axis chips. | | `plotlyOptions` | state | `PlotlyChartOptions` | Cached `createPlotlyOption` output; seeded empty so consumers can read without null-guards. | | `plotlyRef` | state | `AppPlotlyHTMLElement \| null` | Live Plotly DOM element; populated by `handleNewPlot`. | -| `mainPlotEpoch` | state | `number` | Monotonic counter — bumped per `handleNewPlot` so listeners can re-attach. | -| `selectedSeriesIndex` | computed | `number` | Index of the QC target in `graphSeriesArray` (`-1` when none). | +| `mainPlotEpoch` | state | `number` | Monotonic counter, bumped per `handleNewPlot` so listeners can re-attach. | +| `selectedSeriesIndex` | computed | `number` | Index of the edit target in `graphSeriesArray` (`-1` when none). | | `selectedSeries` | computed | `GraphSeries` | Convenience for `graphSeriesArray[selectedSeriesIndex]`. | -| `editHistory` | state | `HistoryItem[]` | Mirrors `selectedSeries.data.history` (mutated in place — never reassign). | +| `editHistory` | state | `HistoryItem[]` | Mirrors `selectedSeries.data.history` (mutated in place; never reassign). | +| `previewIndex` | computed | `number \| null` | The earlier history step the edit target shows (`-1` for the starting state), or null for the whole history. While set, edits wait: operations, plot selections and table saves are held. | | `suppressedEchoSelection` | state | `number[] \| null` | Sentinel armed by programmatic Plotly writes to suppress the echo SELECTION dispatch. | | `isUpdating` | state | `boolean` | Surfaced in the nav rail while a redraw runs. | -| `isSubmitting` | state | `boolean` | True during a QC submit POST. | | `showLegend` | state | `boolean` | Drives Plotly's legend visibility. | | `showTooltip` | state | `boolean` | Legacy flag; tooltip control routes through the auto/manual mode below. | | `tooltipsMaxDataPoints` | state | `number` | Auto-mode cutoff (default 10 000); user-tunable from the data-points menu. | @@ -254,28 +583,29 @@ handles, live chart caches). | `hiddenAxisIds` | state | `Set` | Datastream ids whose right-side y-axis chrome is hidden. | | `hiddenTraceIds` | state | `Set` | Datastream ids whose trace is fully hidden (eye toggle). | | `activeTab` | state | `'plot' \| 'table'` | Center-column tab; captured by the share URL. | -| `tableScrollRequest` | state | `{ time: number; seq: number } \| null` | Signal from the "zoom to range" presets; `DataTable` scrolls to the first row at/after `time`. `seq` re-triggers on repeats. | +| `tableScrollRequest` | state | `{ time: number; seq: number } \| null` | Set when the plot zooms to the session window; `DataTable` scrolls to the first row at/after `time`. `seq` re-triggers on repeats. | | `requestTableScroll` | action | `(time: number) => void` | Publish a `tableScrollRequest` for the given epoch-ms range start (bumps `seq`). | | `axisChips` | state | `AxisChip[]` | Horizontal axis title chips (replaces Plotly's rotated titles). | -| `previewMode` | state | `boolean` | Strips select/lasso/etc when the chart is rendered in the Select view's preview slot. | | `zoomUndoStack` | state | `ZoomState[]` | Captured viewports for the modebar's Undo zoom button. | | `zoomRedoStack` | state | `ZoomState[]` | Cleared on every new user-initiated zoom. | | `suppressZoomHistory` | state | `boolean` | Flipped on during programmatic restores so the recorder doesn't double-capture. | -| `pendingShareZoom` | state | `ZoomState \| null` | URL-hydrated zoom; applied once on mount then cleared. | +| `pendingShareZoom` | state | `ZoomState \| null` | URL-hydrated zoom; applied once by the first draw with data, recorded in the zoom history, then cleared. The share URL keeps writing it until then. | +| `shareZoomEditTarget` | state | `string \| null` | The `ed` target of that link, when it carried one: the one session window the share zoom outranks. | | `canUndoZoom` | computed | `boolean` | `zoomUndoStack.length > 1`. | | `canRedoZoom` | computed | `boolean` | `zoomRedoStack.length > 0`. | | `currentZoom` | computed | `ZoomState \| null` | Top of the undo stack; what the share URL writer subscribes to. | | `updateOptions` | action | `() => void` | Rebuild `plotlyOptions` from `graphSeriesArray`. | -| `redraw` | action | `(recomputeXaxisRange?: boolean, preserveZoom?: boolean) => Promise` | Push typed-array updates + restyle; preserves the user's zoom by default. | +| `redraw` | action | `(recomputeXaxisRange?: boolean, preserveZoom?: boolean) => Promise` | Push typed-array updates + restyle; preserves the user's zoom by default, except on a y axis that had no points drawn. Leaves `layout.shapes` (the staging band) alone. | | `clearChartState` | action | `() => void` | Drop all series + zoom history (used on workspace swap). | -| `fetchGraphSeries` | action | `(ds, start: Date, end: Date) => Promise` | Build a fresh `GraphSeries` from observations; colour is filled later by `assignSeriesColors`. | +| `fetchGraphSeries` | action | `(ds, start: Date, end: Date) => Promise` | Fetch observations for `ds` over `[start, end]` and build a `GraphSeries` via `buildGraphSeries`. | +| `buildGraphSeries` | action | `(ds: Datastream, data: ObservationRecord) => GraphSeries` | Build a `GraphSeries` from an already-loaded record, no fetch. Used directly for a managed datastream's working copy. | | `assignSeriesColors` | action | `(orderedIds: string[]) => void` | Stable per-id colour assignment over the legend order. | -| `colorForDatastream` | action | `(id?: string) => string` | Resolve the line colour for a datastream (QC is always black). | +| `colorForDatastream` | action | `(id?: string) => string` | Resolve the line colour for a datastream (QC is always dark grey, `#3f3f3f`). | | `labelColorForDatastream` | action | `(id?: string) => string` | Darker companion for legend text. | | `clearZoomHistory` | action | `() => void` | Empty both stacks. | | `pushZoomState` | action | `(state: ZoomState) => void` | Called by the debounced recorder in `utils/plotting/zoom.ts`. | -### `useObservationStore()` — `src/store/observations.ts` +### `useObservationStore()` (`src/store/observations.ts`) Fetches + caches observation windows and inflates them into `ObservationRecord` instances. @@ -283,12 +613,14 @@ Fetches + caches observation windows and inflates them into | Name | Kind | Type / signature | Notes | |----------------------------|----------|---------------------------------------------------|-------| | `observations` | state | `Record` | Per-datastream record; reused across rebuilds. | -| `observationsRaw` | state | `Record` | Typed-array cache (`Float64Array` datetimes + `Float32Array` values). | -| `fetchObservationsInRange` | action | `(ds: Datastream, b: Date, e: Date) => Promise` | Extends the cached range minimally; only fetches segments outside the existing window. | +| `observationsRaw` | state | `Record` | Typed-array cache (`Float64Array` datetimes and values). | +| `fetchObservationsInRange` | action | `(ds: Datastream, b: Date, e: Date, exclude?: { begin: Date; end: Date }) => Promise` | Fetches only the parts of `[b, e]` never asked for before, skipping `exclude`, and returns the shared record windowed to `[b, e]`. Requests for one datastream run in order, so the record ends on the latest requested window; a request matching the last queued one shares its promise. | +| `fetchDetachedRecord` | action | `(ds: Datastream, b: Date, e: Date) => Promise` | Fills the same cache through the same per-datastream queue, but returns a record of its own windowed to `[b, e]`. For working copies and snapshots: never re-windows the shared record the plot draws. | +| `forget` | action | `(datastreamId: string) => void` | Drops a datastream's cached observations so the next load asks the server again. Called by `commit()` after its upload; a load already in flight discards its result and retries. | -### `useWorkspaceStore()` — `src/store/workspaces.ts` +### `useWorkspaceStore()` (`src/store/workspaces.ts`) -Workspace selection + role-derived edit permission. +Workspace selection. Permissions come from `useWorkspacePermissions()`. Persistence: `selectedWorkspace` only (key `qc:selectedWorkspace:v1`) so the router's `hasWorkspaceGuard` sees a @@ -301,16 +633,15 @@ restored selection on the first navigation. | `isLoading` | state | `boolean` | True while `loadWorkspaces` is in flight. | | `selectedWorkspaceId` | computed | `string \| null` | Shortcut for `selectedWorkspace?.id`. | | `hasSelection` | computed | `boolean` | True iff `selectedWorkspace` is non-null. | -| `canEditSelected` | computed | `boolean` | True for workspace owners; for collaborators, true when their role includes an Observation create/edit permission. | | `loadWorkspaces` | action | `() => Promise` | Refetch + reconcile against the stored selection (drops the selection if the user lost access). | | `selectWorkspace` | action | `(id: string \| null) => Workspace \| null` | Pick by id from `availableWorkspaces`. | -| `applyWorkspaceById` | action | `(id: string) => Workspace \| null` | Falls back to a placeholder `{ id }` when the list isn't loaded yet — used by URL hydration. | +| `applyWorkspaceById` | action | `(id: string) => Workspace \| null` | Falls back to a placeholder `{ id }` when the list isn't loaded yet. Used by URL hydration. | | `clearSelection` | action | `() => void` | Drop the selection. | -### `useUIStore()` — `src/store/userInterface.ts` +### `useUIStore()` (`src/store/userInterface.ts`) Drawer / view chrome state plus the per-operation form fields read -by every filter / edit panel. Mostly a flat bag — the panel +by every filter / edit panel. Mostly a flat bag: the panel components own the validation; this store just keeps the values reactive between mounts. @@ -320,12 +651,13 @@ defaults are reseeded from the datastream on each mount. | Name | Kind | Type / signature | Notes | |-----------------------------------|--------|---------------------------------------------------|-------| -| `selectedDrawer` | state | `DrawerType` | `Edit`, `Select`, or `None` — which left drawer is active. | +| `selectedDrawer` | state | `DrawerType` | `Edit`, `Select`, or `None`: which left drawer is active. | | `isDrawerOpen` | state | `boolean` | Drawer open/collapsed. | | `currentView` | state | `'Edit' \| 'Select'` | Current main view (drives the nav rail's active state). | +| `isPlotPreview` | computed | `boolean` | True only in the Select view with nothing being edited; an open edit target keeps the full plot chrome in both views. | | `selectedOperation` | state | `string \| null` | Open operation panel id; `null` when nothing is open. | | `cardHeight` / `tableHeight` | state | `number` | Select-view top/bottom split. | -| `operators` | state | `string[]` | `Object.keys(Operator)` — Change-values operator choices. | +| `operators` | state | `string[]` | `Object.keys(Operator)`: Change-values operator choices. | | `selectedOperator` | state | `number` | Index into `operators`. | | `operationValue` | state | `number` | Change-values numeric operand. | | `interpolateValues` | state | `boolean` | Fill-gaps: interpolate vs constant. | @@ -342,13 +674,15 @@ defaults are reseeded from the datastream on each mount. | `filterRangeActive` | state | `boolean` | Toggles the shared filter-window UX; the only persisted field. | | `filterRangeFromTs` / `filterRangeToTs` | state | `number \| null` | Filter-window epoch bounds; reseed on each panel mount. | | `onRailItemClicked` | action | `(title: DrawerType) => void` | Nav-rail click handler: toggles open/closed on repeat, switches view on first click. | +| `showView` | action | `(view: View) => void` | Switch layouts (view + drawer). Layout only: an open edit session is untouched. | -### `useQualifierStore()` — `src/store/qualifiers.ts` +### `useQualifierStore()` (`src/store/qualifiers.ts`) Workspace-scoped qualifier dictionary plus the per-observation -applications added via the Qualifying Comments panel. +applications added via the Qualifying Comments panel. The panel and every +view of the applications are off while `QUALIFIER_TOOL_ENABLED` is `false`. -Persistence: `applied` only — the dictionary is reloaded on every +Persistence: `applied` only; the dictionary is reloaded on every workspace change. | Name | Kind | Type / signature | Notes | @@ -359,12 +693,12 @@ workspace change. | `qualifierById` | computed | `Record` | Lookup map for the chips. | | `loadQualifiers` | action | `() => Promise` | Fetch dictionary for the active workspace; triggers a plot refresh so the qualifier band materialises. | | `createQualifier` | action | `(name: string, description: string) => Promise` | Server-side create with a local-only fallback when no workspace is active. | -| `applyQualifiers` | action | `(datastreamId, indices, qualifierIds, appliedBy) => void` | Idempotent merge — already-applied (qualifier, index) pairs are skipped. | +| `applyQualifiers` | action | `(datastreamId, indices, qualifierIds, appliedBy) => void` | Idempotent merge: already-applied (qualifier, index) pairs are skipped. | | `removeQualifier` | action | `(datastreamId, index, qualifierId) => void` | Drops a single (qualifier, index) application. | | `getApplicationsForDatastream` | action | `(datastreamId) => Array<{ index, qualifierId, appliedAt, appliedBy }>` | Flat list suitable for plotting. | | `getApplicationsAtIndex` | action | `(datastreamId, index) => QualifierApplication[]` | Per-point lookup. | -### `useUiLayoutStore()` — `src/store/uiLayout.ts` +### `useUiLayoutStore()` (`src/store/uiLayout.ts`) Persisted drawer / splitter geometry. A bag of values keyed by strings the calling composable supplies, so new resizable components @@ -381,7 +715,7 @@ Persistence: both `sizes` and `flags` (key `qc:uiLayout:v1`). | `getFlag` | action | `(key: string) => boolean \| null` | `null` lets callers distinguish "unset" from "explicit false". | | `setFlag` | action | `(key: string, value: boolean) => void` | Same fresh-object pattern. | -### `useOperationParamsStore()` — `src/store/operationParams.ts` +### `useOperationParamsStore()` (`src/store/operationParams.ts`) Per-datastream remembered slots for Find Gaps / Fill Gaps parameters. `useUIStore` reads these to seed defaults; otherwise @@ -395,7 +729,7 @@ Persistence: `byDatastream` (key `qc:opParams:v1`). | `load` | action | `(id?: string \| null) => PersistedOpParams \| null` | `null` when nothing's stored. | | `save` | action | `(id?: string \| null, patch: PersistedOpParams) => void` | Merges; partial patches don't clobber unrelated fields. | -### `useUserStore()` — `src/store/user.ts` +### `useUserStore()` (`src/store/user.ts`) The signed-in user. Persisted in full. @@ -404,10 +738,10 @@ The signed-in user. Persisted in full. | `user` | state | `User` | Defaults to a fresh `new User()` until auth resolves. | | `setUser` | action | `(u: User) => void` | Replace wholesale (called by the session resolver in `main.ts`). | -### `useHydroServer()` — `src/store/hydroserver.ts` +### `useHydroServer()` (`src/store/hydroserver.ts`) Holds the `@hydroserver/client` instance. Initialized in `main.ts` -after settings load — every other store reaches `hs.value` through +after settings load; every other store reaches `hs.value` through `storeToRefs(useHydroServer())`. Not persisted (the client carries ephemeral connection state). @@ -415,29 +749,185 @@ ephemeral connection state). |------|-------|--------------------|-------| | `hs` | state | `Ref` | Non-null after `main.ts` finishes settings load; type-asserted as non-null for ergonomic consumer code. | +### `useQcSessionStore()` (`src/store/qcSession.ts`) + +View-mode state for QC sessions: which session is editable (the single +in-progress one) and which is being viewed. Viewing a committed session +puts the editor in read-only mode. + +| Name | Kind | Type / signature | Notes | +|---------------------|----------|-----------------------------------------|-------| +| `historyId` | state | `string \| null` | The managed datastream's QC history being navigated. | +| `resumeDatastreamId`| state | `string \| null` | Managed datastream the editor was last open on. The `editResume` store's ref, persisted there under `qc:editResume:v1`: a page reload replots it and resumes its session from the last save. Set on entering the editor, cleared on exit. | +| `sessions` | state | `QualityControlSession[]` | Committed + in-progress sessions for the history. | +| `currentSessionId` | state | `string \| null` | The single in-progress (editable) session. | +| `viewedSessionId` | state | `string \| null` | The session currently being viewed. | +| `isSwitchingSession`| state | `boolean` | True while another session's data and operations load. The operations panel renders a loading state instead of the outgoing session's entries, which would otherwise linger and read as the incoming session's. | +| `openingDatastreamId`| state | `string \| null` | Managed datastream whose editor is loading its history, sessions and working copy, set by `enterEdit`. `DataVisualization.vue` shows "Opening the edit session…" over the plot while it matches the edit target. Keyed by id since entries can overlap. | +| `savedEdits` | state | `HistoryItem[]` | Edit history entries (by reference) at the last load or save. `useEditSession` compares the working copy against it for `hasUnsavedChanges`; kept in the store so the editor footer and the leave flow agree. | +| `savedComments` | state | `string[]` | Comment text of `savedEdits`, since comments are edited in place. | +| `isReadOnly` | computed | `boolean` | True when sessions exist and the viewed one isn't the in-progress session. Guarded on `sessions.length` so plain editing outside the session workflow isn't treated as read-only. | +| `inProgressSession` | computed | `QualityControlSession \| null` | The editable session, if any. | +| `viewedSession` | computed | `QualityControlSession \| null` | The session for `viewedSessionId`. | +| `hasSessionOperations` | computed | `boolean` | True when the in-progress session holds any work: the operations the server returned with it, plus anything saved since (a save leaves them in `savedEdits` before the sessions are re-fetched). The leave flow tells an untouched session from one worth keeping with it. | +| `fetchSessions` | action | `(historyId: string) => Promise` | Fetch a history's sessions with their operations without writing any state, so a caller can drop a result that went stale (see `useEditSession`). | +| `applySessions` | action | `(historyId: string, sessions: QualityControlSession[]) => void` | Adopt fetched sessions; default the view to the in-progress one, else the one committed last (by `committedAt`), so a commit leaves the session it just made on screen. | +| `viewSession` | action | `(sessionId: string) => void` | View a session read-only (no-op for an unknown id). | +| `returnToCurrent` | action | `() => void` | Return to the editable in-progress session. | +| `reset` | action | `() => void` | Clear all state. | + +### `useQcPreferencesStore()` (`src/store/qcPreferences.ts`) + +Persisted QC preferences. Persistence: key `qc:preferences:v1`, the whole +store. A preset id that isn't a real preset (Custom, or stale) is reset to +the default on hydration, since it comes back with no window to resolve +against. `main.ts` creates the store at startup so the zone is restored +before anything is formatted. + +| Name | Kind | Type / signature | Notes | +|---------------------|-------|------------------|-------| +| `processingLevelId` | state | `string \| null` | Last-used processing level for the Create-Datastream-for-Editing form; null on first use (no assumed default). | +| `displayZone` | state | `DisplayZone` | The time zone dates are shown in; the same ref `utils/timeZone.ts` exports. Defaults to the browser's IANA zone. | +| `tooltipsMaxDataPoints` / `tooltipsMode` / `tooltipsManualEnabled` | state | `number` / `'auto' \| 'manual'` / `boolean` | Data-points mode; exposed by `usePlotlyStore`. | +| `selectedDateBtnId` / `contextPresetId` | state | `number` | Select view and editor Context range presets; exposed by `useDataVisStore`. | +| `showSourceContext` | state | `boolean` | Source context toggle; exposed by `useDataVisStore`. | + +### `useEditResumeStore()` (`src/store/editResume.ts`) + +Persistence: key `qc:editResume:v1`, the whole store. Holds only +`resumeDatastreamId`, which `useQcSessionStore` exposes; a store of its own +so `qcSession`, with its sessions and saved edits, is never persisted. + ## Internal: utilities ### `src/utils/plotting/plotly.ts` (barrel) -Re-exports: +Re-exports `options`, `zoom`, `relayout`, `selected`, `events`, +`interaction` and `operations`. `internal.ts` stays private. The ones most +callers reach for: + +| Function | From | Purpose | +|--------------------------|-----------------|------------------------------------------------------------------| +| `handleNewPlot(...)` | `events.ts` | First draw: build traces, wire events, set the initial range. | +| `createPlotlyOption(...)`| `options.ts` | Build the traces and layout for the plotted series. | +| `setSelectedPoints(...)` | `operations.ts` | Programmatic selection update via `Plotly.restyle`. | +| `clearSelection(...)` | `operations.ts` | Drop the selection shape + restyle. | -| Function | Purpose | -|--------------------------|------------------------------------------------------------------| -| `handleNewPlot(...)` | First-mount: build traces, wire events, set initial range. | -| `setSelectedPoints(...)` | Programmatic selection update via `Plotly.restyle`. | -| `clearSelection(...)` | Drop the selection shape + restyle. | -| `redrawTraces(...)` | Push new typed-array x/y into all plotted traces. | -| `updateOptions(...)` | Apply axis label / range / tick-formatting changes. | +`updateOptions()` is on the plotly store (`store/plotly.ts`), not here. ### `src/utils/observations.ts` -`fetchObservationsSync(datastream, startTime?, endTime?)` — paged +`fetchObservationsSync(datastream, startTime?, endTime?)`: paged columnar fetch, returns `{ datetimes: number[]; dataValues: number[] }`. +### `src/utils/timeZone.ts` + +The time zone dates are shown and typed in. `displayZone` holds a +`DisplayZone`, `{ mode: 'utc' | 'fixedOffset' | 'iana', zone }`, where +`zone` is a fixed offset like `-0700` or an IANA name, the same choice a +data connection's timestamps offer (the lists are qc-utils' +`FIXED_OFFSET_TIMEZONES` and `DST_AWARE_TIMEZONES`). `browserZone()` is the +default. + +Instants stay epoch ms everywhere. A **wall** value is an instant moved by +the zone's offset, so its UTC fields read as the zone's clock: + +- `offsetMs(ms)`, `toWall(ms)`, `fromWall(wall)`, `toWallArray(xs)`: the + conversion, on qc-utils' zone math (see its Time zones section) with the + chosen zone filled in. +- `zoneId()`: the chosen zone as qc-utils takes it (`UTC`, `-0700`, or an + IANA name). The shift dialog passes it to `SHIFT_DATETIMES`, which saves + it with the step. +- `wallParts(ms)` / `fromWallParts(y, m, d, h, mi, s)`: the zone's clock + fields, for pickers and calendar arithmetic. +- `zoneAbbreviation(ms)`, `zoneDescription(ms)`, `zoneName()`: labels. + +`utils/time.ts` formats through it, and `dateMath.ts`, the YTD presets, +`DatePickerField` and the table's datetime input all work on the zone's +clock. Change the zone with `useDisplayZone().setZone(zone)`: it reads the +plot's view as real instants, switches the zone, rebuilds the plot, puts +the view back and redraws the stage band. + +### `src/utils/plotting/plotTime.ts` + +Plotly has no time zones. Its own frame on a date axis is a clock's fields +read as UTC, and that frame holds the chosen zone's wall values, so its +axis, ticks and `%{x}` hover read in that zone. Ranges, shapes and tick +values are written as Plotly date strings (`toPlotDate`, +`plotCoordToDate`), which it reads as written. A trace's numeric x it reads +as the browser's local clock (`new Date(ms)`), so `toPlotX` hands it the +instant whose browser-local clock shows the wall time: in the browser's own +zone the instants go in untouched, with no copy. `plotX` reads trace x back +as Plotly draws it; `traceXAsNumbers` uses it, so the visible-points +counter and Fit X / Fit Y compare against the points on screen. A wall time +the browser's clock skips (its daylight saving gap) has no such instant, so +with another zone chosen a point there draws beside it. `plotCoord` reads a +range or shape value in Plotly's frame, and `fromPlot` turns any Plotly x +back into an instant. Values the app keeps (zoom history, +share-link zoom, the stage band, hover readout) are real instants. + ### `src/utils/dateMath.ts` `subtractDays`, `subtractMonths`, `subtractYears` for the time-range -preset buttons. +preset buttons, on the chosen zone's clock. + +### `src/utils/timeRangePresets.ts` + +Preset definitions (`TIME_RANGE_PRESETS`, ids stable for the share URL) +and their pure resolution: + +- `dataExtent(datastreams)`: earliest begin to latest end over the given + phenomenon times; `null` when none has observations. +- `presetWindow(id, extent)`: the preset counted back from `extent.end` + (All is the whole extent, YTD starts Jan 1 of the end year). Used in the + Select view and while editing without a session window. +- `presetAroundWindow(id, window, extent)`: the editor's rule while a + session window is known. 1w / 1m / 6m / 1y return + `[window.begin - span, window.end + span]`, not clipped to the data. + All returns `extent` widened to cover `window` (or `window` when `extent` + is `null`); YTD resolves the same as All. `null` for an unknown id. +- `EDITOR_PRESETS`: the presets the editor's Context menu offers, without + YTD and titled for the session window. +- `shownPresetId(selectedId, presets)`: the chip that shows the selection + among `presets`. YTD shows as All where it is not offered; `null` for + Custom or an unknown id. Display only: `DataVisTimeFilters` highlights it + and `selectedDateBtnId` is unchanged. + +### `src/utils/sessionWindow.ts` + +Pure validation for the session-window step (`SessionWindowDialog.vue`, +opened from the row Edit chooser or the editor footer's **New session**): + +- `defaultSessionWindow(source, sessions)`. The step's default window, + where the last session left off: from the committed history's end to the + source's end (the **Since commit** preset). The source's full extent when + nothing is committed, when no data arrived after the history, or when the + history ends outside the source. `null` when the source has no + observations. The rules below accept it whenever the committed history + lies inside the source's extent; the dialog reports the rare case where it + does not. +- `sessionWindowIssue(window, source, sessions)`: `null` when `window` is + valid, otherwise `{ kind, message, fix }` for the first broken rule. The + window must lie inside the source's extent, and it can't leave a gap + before or after the committed history. + Touching an edge or overlapping is fine. `fix` carries only the + endpoints at fault, set to the nearest valid value (source start or end, + committed end for a gap after, committed start for a gap before), plus + button copy naming the date. It is `null` when the start is not before + the end, or when the correction would itself break a rule. The dialog + shows it as a button on the warning. +- `NO_SOURCE_DATA_ISSUE`: the issue for a source without observations. +- `sessionWindowPresets(source, sessions)`: the dialog's preset chips. + `All` (the whole record), then `1y`, `6m`, `1m` counting back from the + source's last observation through `presetWindow`, and, when something is + committed, `Since commit` (committed end to source end). The default + window is `defaultSessionWindow` above: `Since commit` when there is + committed work after which data arrived, else `All`. + Each carries its `window` and a short `disabledReason` when that window + breaks a rule; the dialog disables it and shows the reason as its + tooltip. Empty when the source has no observations. +- `committedExtent(sessions)`: the earliest committed start to the latest + committed end, or `null` with nothing committed. ### `src/utils/rules.ts` @@ -446,7 +936,7 @@ Vuetify validation rules used across forms (required, numeric, range). ## Test hooks When `VITE_APP_E2E_HOOKS=1` (Playwright sets this), `src/testHooks.ts` -attaches `window.__vbwTestHooks` with the handles E2E specs need — +attaches `window.__vbwTestHooks` with the handles E2E specs need: selecting a datastream programmatically, reading the current edit history, asserting the plot is ready. These are e2e plumbing, not a public surface; treat the names as unstable. @@ -473,8 +963,8 @@ Wire shape: ## Integrating from outside -If you want to reuse the QC engine in a non-Vue context — a Jupyter -notebook driven by Pyodide, a Node CLI, another web app — depend on +If you want to reuse the QC engine in a non-Vue context (a Jupyter +notebook driven by Pyodide, a Node CLI, another web app), depend on `@uwrl/qc-utils` directly and skip the QC App entirely. The QC App is a UI shell; the engine is independent. diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index a61a8d219..6cb7d4c75 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -12,34 +12,36 @@ the end-user perspective, see [USER_GUIDE.md](./USER_GUIDE.md). | UI framework | Vue 3 (Composition API, ` diff --git a/apps/qc-app/src/components/EditData/DriftCorrection.vue b/apps/qc-app/src/components/EditData/DriftCorrection.vue index 643f68988..357d775a9 100644 --- a/apps/qc-app/src/components/EditData/DriftCorrection.vue +++ b/apps/qc-app/src/components/EditData/DriftCorrection.vue @@ -99,13 +99,12 @@ import { storeToRefs } from 'pinia' import { useDataVisStore } from '@/store/dataVisualization' import { EnumEditOperations } from '@uwrl/qc-utils' import { computed } from 'vue' -import { formatDate } from '@uwrl/qc-utils' +import { formatDateTime } from '@/utils/time' import { usePlotlyStore } from '@/store/plotly' -import type { PlotData } from 'plotly.js-dist' import { useFilterDispatch } from '@/composables/useFilterDispatch' import { useUIStore, DriftCorrectionMethods } from '@/store/userInterface' const { recordPostActionSelection } = useFilterDispatch() -const { selectedSeries, plotlyRef, isUpdating } = storeToRefs(usePlotlyStore()) +const { selectedSeries, isUpdating } = storeToRefs(usePlotlyStore()) const { driftGapWidth, selectedDriftCorrectionMethod } = storeToRefs(useUIStore()) const { redraw } = usePlotlyStore() @@ -152,12 +151,12 @@ const onDriftCorrection = async () => { }) } +// Group indices are the edit record's, so its own times say where they start. const getGroupStart = (group: number[]) => { - const trace = plotlyRef.value?.data[0] as Partial | undefined - const xData = trace?.x as number[] | undefined const firstIdx = group[0] - if (!xData || firstIdx === undefined) return '' - return formatDate(new Date(xData[firstIdx] as number)) + const x = + firstIdx === undefined ? undefined : selectedSeries.value?.data.dataX[firstIdx] + return x === undefined ? '' : formatDateTime(x) } diff --git a/apps/qc-app/src/components/EditData/EditHistory.vue b/apps/qc-app/src/components/EditData/EditHistory.vue index 84802f5aa..ae518085f 100644 --- a/apps/qc-app/src/components/EditData/EditHistory.vue +++ b/apps/qc-app/src/components/EditData/EditHistory.vue @@ -38,7 +38,7 @@ variant="text" density="comfortable" icon="mdi-undo-variant" - :disabled="isUpdating || !canUndo" + :disabled="isUpdating || isReadOnly || !canUndo" @click.stop="onUndo" /> @@ -54,7 +54,7 @@ variant="text" density="comfortable" icon="mdi-redo-variant" - :disabled="isUpdating || !canRedo" + :disabled="isUpdating || isReadOnly || !canRedo" @click.stop="onRedo" /> @@ -76,30 +76,6 @@ - - - - - @@ -342,8 +482,15 @@ import { formatDuration } from '@uwrl/qc-utils' import { useDataVisStore } from '@/store/dataVisualization' import { useUIStore } from '@/store/userInterface' import { iconForMethod, colorForMethod } from '@/components/EditData/operations' +import SessionList from '@/components/EditData/SessionList.vue' +import EditHistoryStepDetails from '@/components/EditData/EditHistoryStepDetails.vue' import { useQcHistory } from '@/composables/useQcHistory' +import { useQcSessionStore } from '@/store/qcSession' +import { useHistorySnapshots } from '@/composables/useHistorySnapshots' +import { SNAPSHOT_BASELINE_INDEX } from '@/utils/snapshotId' import { Snackbar } from '@uwrl/qc-utils' +import type { HistoryItem } from '@uwrl/qc-utils' +import { formatDateRange, formatStamp } from '@/utils/time' const props = withDefaults( defineProps<{ @@ -358,9 +505,15 @@ const props = withDefaults( } ) +defineSlots<{ + /** Session actions pinned below the history, shown collapsed or not. */ + footer?: () => any +}>() + const emit = defineEmits<{ (e: 'update:collapsed', value: boolean): void (e: 'pop-out'): void + (e: 'view-session', sessionId: string): void }>() const isCollapsed = computed(() => props.collapsible && !!props.collapsed) @@ -379,17 +532,38 @@ const onHeaderClick = (e: MouseEvent) => { toggleCollapsed() } -const { editHistory, selectedSeries, isUpdating } = +const { editHistory, selectedSeries, isUpdating, previewIndex } = storeToRefs(usePlotlyStore()) const { selectedOperation } = storeToRefs(useUIStore()) const { redraw } = usePlotlyStore() const { clearSelected, setPlotSelection } = useDataSelection() -const { exportHistory, importHistory } = useQcHistory() -const fileInputRef = ref(null) +const { exportHistory } = useQcHistory() const openIndex = ref(null) -const isDev = import.meta.env.DEV +/** The step the plot reflects: the one previewed, else the last entry. */ +const shownStepIndex = computed(() => { + const last = editHistory.value.length - 1 + if (last < 0) return null + return previewIndex.value ?? last +}) + +// Committed sessions are immutable server-side, so their comments are shown +// but not editable. +const { isReadOnly, isSwitchingSession, viewedSessionId } = + storeToRefs(useQcSessionStore()) + +const { toggleSnapshot, isSnapshotPlotted, isBuilding } = useHistorySnapshots() + +// Not gated on isReadOnly: plotting a comparison line is a read action. +const onToggleSnapshot = async (opIndex: number) => { + const sessionId = viewedSessionId.value + if (!sessionId) return + await toggleSnapshot(sessionId, opIndex) +} + +const snapshotShown = (opIndex: number) => + !!viewedSessionId.value && isSnapshotPlotted(viewedSessionId.value, opIndex) const editCount = computed(() => editHistory.value?.length ?? 0) @@ -400,10 +574,45 @@ const canRedo = computed( () => (selectedSeries.value?.data.redoStack?.length ?? 0) > 0 ) +/** Stepping needs something to replay and a settled dispatch. */ +const canStepTo = computed(() => !isUpdating.value && editCount.value > 0) + +/** Row click / Enter / Space: preview that step. */ +const onRowReload = (index: number) => { + if (!canStepTo.value) return + if (index >= 0 && editHistory.value[index]?.execution?.inFlight) return + onReloadHistory(index) +} + function toggle(index: number) { openIndex.value = openIndex.value === index ? null : index } +/** Steps past the one on screen were not replayed, so their execution + * record describes a run that no longer holds in this view. */ +const isApplied = (index: number) => + shownStepIndex.value === null || index <= shownStepIndex.value + +const stepTitle = (index: number) => + isApplied(index) + ? 'Preview this step' + : 'Not applied in the step currently shown. Click to preview it.' + +/** The period a step touched and how many points, from the datetimes qc-utils + * read when it ran. Indices would drift with later edits. */ +function stepExtent(entry: HistoryItem): string { + const extent = entry.execution?.extent + if (!extent) return '' + const begin = new Date(extent.begin) + const end = new Date(extent.end) + const period = + extent.begin === extent.end + ? formatStamp(begin) + : formatDateRange(begin.toISOString(), end.toISOString()) + const n = entry.execution?.selectionSize + return n ? `${period} · ${n.toLocaleString()} pt${n === 1 ? '' : 's'}` : period +} + function formatMethod(method: string) { if (!method) return '' return method @@ -413,27 +622,8 @@ function formatMethod(method: string) { .join(' ') } -function formatArg(arg: unknown): string { - if (Array.isArray(arg)) { - const len = arg.length - if (!len) return '[]' - const preview = arg - .slice(0, 5) - .map((v) => (typeof v === 'number' ? v : JSON.stringify(v))) - .join(', ') - return len <= 5 ? `[${preview}]` : `[${preview}, … (${len} items)]` - } - if (arg && typeof arg === 'object') { - try { - return JSON.stringify(arg) - } catch { - return String(arg) - } - } - return String(arg) -} - const onReload = async () => { + if (isReadOnly.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() @@ -446,6 +636,8 @@ const onReload = async () => { selectedSeries.value.data.redoStack.length = 0 } await refreshGraphSeriesArray() + // Restores the raw values when the refetch above failed and left the + // edited record in place. await selectedSeries.value?.data.reload() // reload() already wiped history; don't push an empty SELECTION. await clearSelected({ recordHistory: false }) @@ -454,20 +646,26 @@ const onReload = async () => { }) } +// Previewing only shows a step: every step stays in the history, and only +// undo and redo change it. The last step is the whole history again. const onReloadHistory = async (index: number) => { - if (index < editHistory.value.length) { - isUpdating.value = true - closeStaleStagingPanel() - setTimeout(async () => { - const newSelection = await selectedSeries.value?.data.reloadHistory(index) - + if (index >= editHistory.value.length) return + const record = selectedSeries.value?.data + if (!record) return + isUpdating.value = true + closeStaleStagingPanel() + setTimeout(async () => { + try { + await applyReplayedSelection(await record.previewHistory(index)) + } finally { isUpdating.value = false - await redraw() - if (newSelection) { - setPlotSelection(newSelection) - } - }) - } + } + }) +} + +const onBackToLatest = () => { + const last = editHistory.value.length - 1 + if (last >= 0) void onReloadHistory(last) } const onSaveHistory = async () => { @@ -480,39 +678,6 @@ const onSaveHistory = async () => { } } -const onLoadHistoryClick = () => { - fileInputRef.value?.click() -} - -const onLoadHistoryFile = async (e: Event) => { - const input = e.target as HTMLInputElement - const file = input.files?.[0] - // Reset early so re-picking the same file fires change again. - input.value = '' - if (!file) return - - isUpdating.value = true - try { - const report = await importHistory(file) - if (report.failed.length === 0) { - Snackbar.success( - `Loaded ${report.applied} operation${report.applied === 1 ? '' : 's'}.` - ) - } else { - Snackbar.warn( - `Loaded ${report.applied} operation${report.applied === 1 ? '' : 's'}; ` + - `${report.failed.length} failed (see history badges).` - ) - } - await redraw() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - Snackbar.error(`Couldn't load QC history: ${msg}`) - } finally { - isUpdating.value = false - } -} - // Pass recordHistory: false on the clear path because the replay is // authoritative; dispatching an empty SELECTION could pop a filter the // replay just restored. @@ -535,7 +700,8 @@ const closeStaleStagingPanel = () => { } const onUndo = async () => { - if (!canUndo.value || isUpdating.value) return + // Also guards the Ctrl+Z shortcut, which bypasses the disabled button. + if (isReadOnly.value || !canUndo.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() setTimeout(async () => { @@ -549,7 +715,7 @@ const onUndo = async () => { } const onRedo = async () => { - if (!canRedo.value || isUpdating.value) return + if (isReadOnly.value || !canRedo.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() setTimeout(async () => { @@ -616,6 +782,32 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(var(--v-theme-primary), 0.04); } +.edit-history__row--clickable { + cursor: pointer; +} + +/* Size and weight come from the typography utility classes. */ +.edit-history__step { + min-width: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font-family: inherit; + text-align: start; + cursor: inherit; +} + +.edit-history__step:focus-visible { + outline: 2px solid rgb(var(--v-theme-primary)); + outline-offset: 2px; +} + +.edit-history__row--loaded { + background-color: rgba(var(--v-theme-primary), 0.1); + box-shadow: inset 3px 0 0 0 rgb(var(--v-theme-primary)); +} + .edit-history__row--open { background-color: rgba(var(--v-theme-primary), 0.06); } @@ -628,6 +820,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) opacity: 0.75; } +/* Steps past the one on screen were not replayed, so they are dimmed to + separate what the plot reflects from what is merely recorded. */ +.edit-history__preview { + background-color: rgba(var(--v-theme-primary), 0.06); +} + +.edit-history__row--unapplied { + opacity: 0.45; +} + .edit-history__expand { width: 20px; height: 20px; @@ -641,26 +843,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(0, 0, 0, 0.05); } -.edit-history__method { - font-size: 0.8125rem; +.edit-history__title { min-width: 0; + line-height: inherit; } -.edit-history__args { - background-color: rgba(var(--v-theme-primary), 0.03); - border-left: 2px solid rgb(var(--v-theme-primary)); -} - -.edit-history__args-list { - list-style: none; - max-height: 12rem; +.edit-history__method { + font-size: 0.8125rem; + min-width: 0; } -.edit-history__mode-chip { - font-size: 0.625rem; - letter-spacing: 0.05em; - text-transform: uppercase; - height: 16px; - padding-inline: 6px; +.edit-history__extent { + font-size: 0.6875rem; + line-height: 1.2; } diff --git a/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue b/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue new file mode 100644 index 000000000..a5a9d64ff --- /dev/null +++ b/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/EditTargetPanel.vue b/apps/qc-app/src/components/EditData/EditTargetPanel.vue new file mode 100644 index 000000000..50ce5c32c --- /dev/null +++ b/apps/qc-app/src/components/EditData/EditTargetPanel.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/FillGaps.vue b/apps/qc-app/src/components/EditData/FillGaps.vue index c7672c81d..4865c2a61 100644 --- a/apps/qc-app/src/components/EditData/FillGaps.vue +++ b/apps/qc-app/src/components/EditData/FillGaps.vue @@ -358,7 +358,7 @@ const fillSnapChips = computed(() => { return [0.5, 1, 2].map((m) => { const amount = i.amount * m return { - label: `${m}× intended (${amount} ${i.unit.toLowerCase()})`, + label: `${m}× intended (${amount} ${i.unit.toLowerCase()})`, amount, unit: i.unit, active: diff --git a/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue b/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue new file mode 100644 index 000000000..9a4134098 --- /dev/null +++ b/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue @@ -0,0 +1,155 @@ + + + diff --git a/apps/qc-app/src/components/EditData/OperationPanel.vue b/apps/qc-app/src/components/EditData/OperationPanel.vue index fafa87f9f..e003cfd4f 100644 --- a/apps/qc-app/src/components/EditData/OperationPanel.vue +++ b/apps/qc-app/src/components/EditData/OperationPanel.vue @@ -37,7 +37,31 @@
+ +
+ This session is committed and read-only. Return to the current + session, or start a new one, to keep editing. +
+
+ +
+ +
+ An earlier history step is being previewed. Go back to the latest + step in the edit history to continue editing. +
+
+ +
selectedOperation.value ? operationsById[selectedOperation.value] : null diff --git a/apps/qc-app/src/components/EditData/SessionList.vue b/apps/qc-app/src/components/EditData/SessionList.vue new file mode 100644 index 000000000..929d0450a --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionList.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/SessionTimelineItem.vue b/apps/qc-app/src/components/EditData/SessionTimelineItem.vue new file mode 100644 index 000000000..c4696d907 --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionTimelineItem.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/SessionWindowDialog.vue b/apps/qc-app/src/components/EditData/SessionWindowDialog.vue new file mode 100644 index 000000000..345d4cb8f --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionWindowDialog.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/ShiftDatetimes.vue b/apps/qc-app/src/components/EditData/ShiftDatetimes.vue index cbfafff6a..2ae66499c 100644 --- a/apps/qc-app/src/components/EditData/ShiftDatetimes.vue +++ b/apps/qc-app/src/components/EditData/ShiftDatetimes.vue @@ -24,10 +24,10 @@ v-model.number="shiftAmount" density="comfortable" variant="outlined" + :error="!amountValid" hide-details - @keyup.enter=" - !isUpdating && selectedData?.length && onShiftDatetimes() - " + data-testid="shift-amount" + @keyup.enter="canShift && onShiftDatetimes()" />
+
+ {{ + amountValid + ? `Months and years follow the calendar in ${zoneName()}, keeping the clock time. A day past the end of a month moves to its last day.` + : 'Months and years shift by whole numbers.' + }} +
+
Shift @@ -78,6 +92,7 @@ import { EnumEditOperations, TimeUnit } from '@uwrl/qc-utils' import { usePlotlyStore } from '@/store/plotly' import { useUIStore, timeSpacingUnitToTimeUnitKey } from '@/store/userInterface' import { useFilterDispatch } from '@/composables/useFilterDispatch' +import { zoneId, zoneName } from '@/utils/timeZone' const { selectedData, qcDatastream } = storeToRefs(useDataVisStore()) const { selectedSeries, isUpdating } = storeToRefs(usePlotlyStore()) @@ -104,7 +119,7 @@ const snapChips = computed(() => { return [0.5, 1, 2].map((m) => { const amount = n * m return { - label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, + label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, amount, unit: unitKey, active: @@ -119,12 +134,21 @@ const applySnap = (chip: SnapChip) => { selectedShiftUnit.value = chip.unit } +const isCalendarUnit = computed( + () => selectedShiftUnit.value === 'MONTH' || selectedShiftUnit.value === 'YEAR' +) +const amountValid = computed(() => { + const n = Number(shiftAmount.value) + return Number.isFinite(n) && (!isCalendarUnit.value || Number.isInteger(n)) +}) +const canShift = computed( + () => !isUpdating.value && !!selectedData.value?.length && amountValid.value +) + const emit = defineEmits(['close']) const onShiftDatetimes = async () => { - if (!selectedData.value?.length) { - return - } + if (!canShift.value) return isUpdating.value = true @@ -134,7 +158,8 @@ const onShiftDatetimes = async () => { EnumEditOperations.SHIFT_DATETIMES, +shiftAmount.value, // @ts-ignore - TimeUnit[selectedShiftUnit.value] + TimeUnit[selectedShiftUnit.value], + zoneId() )) as number[] | undefined) ?? [] isUpdating.value = false diff --git a/apps/qc-app/src/components/EditData/StartEditingDialog.vue b/apps/qc-app/src/components/EditData/StartEditingDialog.vue new file mode 100644 index 000000000..ee6339d27 --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartEditingDialog.vue @@ -0,0 +1,304 @@ + + + diff --git a/apps/qc-app/src/components/EditData/StartEditingFlow.vue b/apps/qc-app/src/components/EditData/StartEditingFlow.vue new file mode 100644 index 000000000..8cfbe2ce7 --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartEditingFlow.vue @@ -0,0 +1,385 @@ + + + diff --git a/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts new file mode 100644 index 000000000..ef889e6ee --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts @@ -0,0 +1,210 @@ +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { describe, it, expect, vi } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import CreateDatastreamForm from '@/components/EditData/CreateDatastreamForm.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const source = { + id: 's-1', + name: 'Raw Temp', + description: 'Raw temperature readings', + status: 'ongoing', + methodId: 'sn-1', + processingLevelId: 'pl-raw', +} as any + +const processingLevels = [ + { id: 'pl-raw', definition: 'Raw' }, + { id: 'pl-qc', definition: 'Quality Controlled' }, +] + +const methods = [ + { id: 'sn-1', name: 'Thermistor' }, + { id: 'sn-2', name: 'Weather station' }, +] + +const statuses = ['ongoing', 'complete'] + +const mountForm = (props: Record = {}) => + mount(CreateDatastreamForm, { + props: { source, processingLevels, methods, statuses, ...props }, + global: { plugins: [createTestVuetify()] }, + }) + +// Every field below is a Vuetify component carrying the test id. +const field = (w: ReturnType, testId: string) => + w.findComponent(`[data-testid="${testId}"]`) as VueWrapper + +const levelSelect = (w: ReturnType) => + field(w, 'create-processing-level') + +describe('CreateDatastreamForm', () => { + it('disables create until a different processing level is chosen', async () => { + const w = mountForm() + const confirm = () => w.find('[data-testid="create-confirm"]') + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-raw') // same as source + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + expect(confirm().attributes('disabled')).toBeUndefined() + }) + + it('emits the create spec on confirm, defaulting the fields from the source', async () => { + const w = mountForm() + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + await w.find('[data-testid="create-confirm"]').trigger('click') + + const spec = w.emitted('confirm')![0][0] as { + source: { id: string } + processingLevelId: string + name?: string + description: string + status?: string + methodId: string + } + expect(spec.source.id).toBe('s-1') + expect(spec.processingLevelId).toBe('pl-qc') + expect(spec.name).toBe('Raw Temp (QC)') + expect(spec.description).toBe('Raw temperature readings') + expect(spec.status).toBe('ongoing') + expect(spec.methodId).toBe('sn-1') + }) + + it('emits the edited description, status and method', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + await field(w, 'create-description').vm.$emit('update:modelValue', 'QC pass') + await field(w, 'create-status').vm.$emit('update:modelValue', 'complete') + await field(w, 'create-method').vm.$emit('update:modelValue', 'sn-2') + await w.find('[data-testid="create-confirm"]').trigger('click') + + const spec = w.emitted('confirm')![0][0] as { + description: string + status?: string + methodId: string + } + expect(spec.description).toBe('QC pass') + expect(spec.status).toBe('complete') + expect(spec.methodId).toBe('sn-2') + }) + + it('requires a description, and allows an empty status', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + const confirm = () => w.find('[data-testid="create-confirm"]') + + await field(w, 'create-status').vm.$emit('update:modelValue', null) + expect(confirm().attributes('disabled')).toBeUndefined() + + await field(w, 'create-description').vm.$emit('update:modelValue', ' ') + expect(confirm().attributes('disabled')).toBeDefined() + + await field(w, 'create-description').vm.$emit('update:modelValue', 'QC pass') + expect(confirm().attributes('disabled')).toBeUndefined() + await confirm().trigger('click') + const spec = w.emitted('confirm')![0][0] as { status?: string } + expect(spec.status).toBeUndefined() + }) + + it('keeps the source values when the lists could not be loaded', async () => { + const w = mountForm({ + defaultProcessingLevelId: 'pl-qc', + methods: [], + statuses: [], + }) + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { + status?: string + methodId: string + } + expect(spec.status).toBe('ongoing') + expect(spec.methodId).toBe('sn-1') + }) + + it('uses the provided default processing level', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + // Valid immediately since the default differs from the source's level. + expect(w.find('[data-testid="create-confirm"]').attributes('disabled')).toBeUndefined() + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { processingLevelId: string } + expect(spec.processingLevelId).toBe('pl-qc') + }) + + it('emits cancel', async () => { + const w = mountForm() + await w.find('[data-testid="create-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) + + it('ignores a remembered default not in this workspace, and stays invalid until a real level is picked', async () => { + // A level id persisted from another workspace/backend. + const w = mountForm({ defaultProcessingLevelId: 'pl-from-elsewhere' }) + // Stale default dropped -> confirm disabled (not submitted as-is). + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeDefined() + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeUndefined() + }) + + it('adds a processing level inline and selects the new one', async () => { + const created = { id: 'pl-new', name: 'Quality Controlled' } + const onCreateProcessingLevel = vi.fn().mockResolvedValue(created) + const w = mountForm({ onCreateProcessingLevel }) + + // Open the inline add panel and submit a new level. + await w.find('[data-testid="add-level-toggle"]').trigger('click') + await w.find('[data-testid="new-level-name"] input').setValue('Quality Controlled') + await w + .find('[data-testid="new-level-description"] textarea') + .setValue('Reviewed readings') + await w.find('[data-testid="new-level-save"]').trigger('click') + await flushPromises() + + expect(onCreateProcessingLevel).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Quality Controlled', + description: 'Reviewed readings', + }) + ) + // Parent appends the created level to the catalog. + await w.setProps({ processingLevels: [...processingLevels, created] }) + + // Panel collapsed, and the new level is selected -> confirm carries it. + expect(w.find('[data-testid="new-level-name"]').exists()).toBe(false) + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { processingLevelId: string } + expect(spec.processingLevelId).toBe('pl-new') + }) + + it('shows progress and blocks both buttons while creating', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc', loading: true }) + const confirm = w.find('[data-testid="create-confirm"]') + expect(confirm.classes()).toContain('v-btn--loading') + expect( + w.find('[data-testid="create-cancel"]').attributes('disabled') + ).toBeDefined() + + await field(w, 'create-confirm').vm.$emit('click') + expect(w.emitted('confirm')).toBeUndefined() + }) + + it('blocks create and shows a warning when permissionError is set', async () => { + const w = mountForm({ + defaultProcessingLevelId: 'pl-qc', // otherwise valid + permissionError: 'You cannot create datastreams here.', + }) + expect(w.find('[data-testid="create-permission-error"]').exists()).toBe(true) + expect(w.text()).toContain('You cannot create datastreams here.') + // Confirm stays disabled despite a valid processing level. + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeDefined() + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts b/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts index 375be2852..cff9f7633 100644 --- a/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts +++ b/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts @@ -1,19 +1,50 @@ -import { mount, flushPromises } from '@vue/test-utils' -import { ref } from 'vue' -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { computed, reactive, ref } from 'vue' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { createTestPinia } from '@/utils/test/pinia' import { createTestVuetify } from '@/utils/test/vuetify' +// EditHistory listens for Ctrl+Z/Y on window. A wrapper left mounted keeps +// handling shortcuts dispatched by later tests. +enableAutoUnmount(afterEach) +// The per-operation comment textarea (Vuetify auto-grow) observes resizes. +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + const editHistory = ref([]) const selectedSeries = ref(null) const isUpdating = ref(false) const redraw = vi.fn().mockResolvedValue(undefined) const refreshGraphSeriesArray = vi.fn().mockResolvedValue(undefined) +const previewIndex = computed(() => selectedSeries.value?.data?.previewIndex ?? null) + +// Reactive like a real store, so `storeToRefs` unwraps the computed. vi.mock('@/store/plotly', () => ({ - usePlotlyStore: () => ({ editHistory, selectedSeries, isUpdating, redraw }), + usePlotlyStore: () => + reactive({ + editHistory, + selectedSeries, + isUpdating, + redraw, + previewIndex, + }), })) +/** + * Stands in for `ObservationRecord.previewHistory`: shows step `index` + * without dropping the later ones, and previewing the last step returns to + * the whole history. Called on the record, so `this` is the series data. + */ +const previewing = (selection: number[] = []) => + vi.fn(async function (this: any, index: number) { + this.previewIndex = index >= this.history.length - 1 ? null : index + return selection + }) + const qcDatastream = ref(null) vi.mock('@/store/dataVisualization', () => ({ useDataVisStore: () => ({ refreshGraphSeriesArray, qcDatastream }), @@ -25,57 +56,78 @@ vi.mock('@/composables/useDataSelection', () => ({ useDataSelection: () => ({ clearSelected, setPlotSelection }), })) -vi.mock('@uwrl/qc-utils', () => ({ - formatDuration: (ms: number) => String(ms) + 'ms', - // operations.ts (transitively imported via EditHistory.vue's - // `iconForMethod` lookup) reads enum values to build its method → - // operation-id map. Stub the keys it actually consults; the test - // never inspects the icon output, only that the entry renders. - EnumEditOperations: { - ADD_POINTS: 'ADD_POINTS', - CHANGE_VALUES: 'CHANGE_VALUES', - ASSIGN_VALUES_BULK: 'ASSIGN_VALUES_BULK', - ASSIGN_DATETIMES_BULK: 'ASSIGN_DATETIMES_BULK', - DELETE_POINTS: 'DELETE_POINTS', - DRIFT_CORRECTION: 'DRIFT_CORRECTION', - INTERPOLATE: 'INTERPOLATE', - SHIFT_DATETIMES: 'SHIFT_DATETIMES', - FILL_GAPS: 'FILL_GAPS', - }, - EnumFilterOperations: { - FIND_GAPS: 'FIND_GAPS', - PERSISTENCE: 'PERSISTENCE', - CHANGE: 'CHANGE', - RATE_OF_CHANGE: 'RATE_OF_CHANGE', - VALUE_THRESHOLD: 'VALUE_THRESHOLD', - DATETIME_RANGE: 'DATETIME_RANGE', - SELECTION: 'SELECTION', - }, - Operator: { - ADD: 'ADD', - SUB: 'SUB', - MULT: 'MULT', - DIV: 'DIV', - ASSIGN: 'ASSIGN', - }, - TimeUnit: { - SECOND: 's', - MINUTE: 'm', - HOUR: 'h', - DAY: 'D', - WEEK: 'W', - MONTH: 'M', - YEAR: 'Y', - }, - LogicalOperation: { - LT: 'Less than', - LTE: 'Less than or equal to', - GT: 'Greater than', - GTE: 'Greater than or equal to', - E: 'Equal', - }, +const toggleSnapshot = vi.fn().mockResolvedValue(undefined) +const plottedSnapshots = ref([]) +const isBuilding = ref(false) +vi.mock('@/composables/useHistorySnapshots', () => ({ + useHistorySnapshots: () => ({ + toggleSnapshot, + isSnapshotPlotted: (sessionId: string, opIndex: number) => + plottedSnapshots.value.includes(`${sessionId}:${opIndex}`), + isBuilding, + }), })) +vi.mock('@uwrl/qc-utils', async (importOriginal) => { + // The real time zone math, which the app's date helpers use. + const { offsetMs, toWall, fromWall, toWallArray } = + await importOriginal() + return { + offsetMs, + toWall, + fromWall, + toWallArray, + formatDuration: (ms: number) => String(ms) + 'ms', + // operations.ts (transitively imported via EditHistory.vue's + // `iconForMethod` lookup) reads enum values to build its method → + // operation-id map. Stub the keys it actually consults; the test + // never inspects the icon output, only that the entry renders. + EnumEditOperations: { + ADD_POINTS: 'ADD_POINTS', + CHANGE_VALUES: 'CHANGE_VALUES', + ASSIGN_VALUES_BULK: 'ASSIGN_VALUES_BULK', + ASSIGN_DATETIMES_BULK: 'ASSIGN_DATETIMES_BULK', + DELETE_POINTS: 'DELETE_POINTS', + DRIFT_CORRECTION: 'DRIFT_CORRECTION', + INTERPOLATE: 'INTERPOLATE', + SHIFT_DATETIMES: 'SHIFT_DATETIMES', + FILL_GAPS: 'FILL_GAPS', + }, + EnumFilterOperations: { + FIND_GAPS: 'FIND_GAPS', + PERSISTENCE: 'PERSISTENCE', + CHANGE: 'CHANGE', + RATE_OF_CHANGE: 'RATE_OF_CHANGE', + VALUE_THRESHOLD: 'VALUE_THRESHOLD', + DATETIME_RANGE: 'DATETIME_RANGE', + SELECTION: 'SELECTION', + }, + Operator: { + ADD: 'ADD', + SUB: 'SUB', + MULT: 'MULT', + DIV: 'DIV', + ASSIGN: 'ASSIGN', + }, + TimeUnit: { + SECOND: 's', + MINUTE: 'm', + HOUR: 'h', + DAY: 'D', + WEEK: 'W', + MONTH: 'M', + YEAR: 'Y', + }, + LogicalOperation: { + LT: 'Less than', + LTE: 'Less than or equal to', + GT: 'Greater than', + GTE: 'Greater than or equal to', + E: 'Equal', + }, + } +}) + import EditHistory from '@/components/EditData/EditHistory.vue' function makeSeries(overrides: Partial = {}) { @@ -88,7 +140,7 @@ function makeSeries(overrides: Partial = {}) { undo: vi.fn().mockResolvedValue([1, 2]), redo: vi.fn().mockResolvedValue([]), reload: vi.fn().mockResolvedValue(undefined), - reloadHistory: vi.fn().mockResolvedValue([3]), + previewHistory: previewing([3]), ...overrides, }, } @@ -113,6 +165,7 @@ function makeEntry( mode: 'worker' | 'inline' datasetSize: number selectionSize: number + extent: { begin: number; end: number } }> = {}, ) { return { @@ -126,9 +179,13 @@ function makeEntry( } } -function createWrapper(props: Record = {}) { +function createWrapper( + props: Record = {}, + slots: Record = {} +) { return mount(EditHistory, { props, + slots, global: { plugins: [createTestPinia(), createTestVuetify()] }, }) } @@ -141,6 +198,30 @@ describe('EditHistory.vue', () => { vi.clearAllMocks() }) + it('shows the period and point count a step touched', async () => { + const begin = new Date(2026, 2, 3, 12, 5) + const end = new Date(2026, 3, 1, 8, 0) + editHistory.value = [ + makeEntry('INTERPOLATE', [], { + extent: { begin: begin.getTime(), end: end.getTime() }, + selectionSize: 1200, + }), + makeEntry('ADD_POINTS', [], { + extent: { begin: end.getTime(), end: end.getTime() }, + }), + makeEntry('FIND_GAPS'), + ] + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('[data-testid="history-extent-0"]').text()).toBe( + 'Mar 3, 12:05 PM – Apr 1, 2026, 8:00 AM · 1,200 pts' + ) + expect(wrapper.find('[data-testid="history-extent-1"]').text()).toBe( + 'Apr 1, 2026, 8:00 AM' + ) + expect(wrapper.find('[data-testid="history-extent-2"]').exists()).toBe(false) + }) + it('disables undo/redo when history is empty', () => { const wrapper = createWrapper() expect(wrapper.find('[data-testid="history-undo-btn"]').attributes('disabled')).toBeDefined() @@ -218,6 +299,26 @@ describe('EditHistory.vue', () => { expect(wrapper.emitted('update:collapsed')).toBeFalsy() }) + it('renders no footer when the slot is not provided', () => { + const wrapper = createWrapper() + expect(wrapper.find('.edit-history__footer').exists()).toBe(false) + }) + + it('keeps the footer slot visible while collapsed', () => { + const wrapper = createWrapper( + { collapsible: true, collapsed: true }, + { footer: '' } + ) + const footer = wrapper.find('.edit-history__footer') + expect(footer.exists()).toBe(true) + expect(footer.isVisible()).toBe(true) + expect(wrapper.find('[data-testid="session-actions"]').isVisible()).toBe( + true + ) + // The body is what collapses (v-show), not the footer. + expect(wrapper.find('.edit-history__row--baseline').isVisible()).toBe(false) + }) + it('emits pop-out when pop-out button is clicked', async () => { const wrapper = createWrapper({ popOutEnabled: true }) const popBtn = wrapper.find('[aria-label="Open history in a modal window"]') @@ -275,27 +376,27 @@ describe('EditHistory.vue actions', () => { vi.useFakeTimers() const wrapper = createWrapper() await flushPromises() - const reloadBtn = wrapper.findAll('button').find((b) => b.html().includes('mdi-reload')) - expect(reloadBtn).toBeTruthy() - await reloadBtn!.trigger('click') + const reloadBtn = wrapper.find('[data-testid="history-reload-btn"]') + expect(reloadBtn.exists()).toBe(true) + await reloadBtn.trigger('click') await vi.runAllTimersAsync() expect(selectedSeries.value.data.reload).toHaveBeenCalled() expect(refreshGraphSeriesArray).toHaveBeenCalled() vi.useRealTimers() }) - it('per-step reload button calls reloadHistory with entry index', async () => { + it('clicking a step row previews it', async () => { vi.useFakeTimers() editHistory.value = [makeEntry('ADD_POINTS')] - selectedSeries.value.data.reloadHistory = vi.fn().mockResolvedValue([9]) + selectedSeries.value.data.previewHistory = vi.fn().mockResolvedValue([9]) const wrapper = createWrapper() await flushPromises() const entry = wrapper.find('[data-testid="history-item-0"]') - const reloadBtn = entry.findAll('button').find((b) => b.html().includes('mdi-reload')) + const reloadBtn = entry.find('.edit-history__row') expect(reloadBtn).toBeTruthy() await reloadBtn!.trigger('click') await vi.runAllTimersAsync() - expect(selectedSeries.value.data.reloadHistory).toHaveBeenCalledWith(0) + expect(selectedSeries.value.data.previewHistory).toHaveBeenCalledWith(0) expect(setPlotSelection).toHaveBeenCalledWith([9]) vi.useRealTimers() }) @@ -370,4 +471,730 @@ describe('EditHistory.vue actions', () => { await wrapper.find('.edit-history__header').trigger('keydown.enter') expect(wrapper.emitted('update:collapsed')).toBeTruthy() }) + + // A committed session's operations are a record of what happened, not a + // draft: nothing in the panel may rewrite them. + describe('read-only session', () => { + const readOnly = async () => { + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + // `createdAt` is required: SessionList orders on it. + store.sessions = [ + { + id: 'a', + status: 'committed', + createdAt: '2025-01-01T00:00:00Z', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', + }, + { + id: 'b', + status: 'in_progress', + createdAt: '2025-02-01T00:00:00Z', + phenomenonTimeStart: '2025-02-01T00:00:00Z', + phenomenonTimeEnd: '2025-03-01T00:00:00Z', + }, + ] as any + store.currentSessionId = 'b' + store.viewedSessionId = 'a' + return store + } + + /** + * Stands in for the real `previewHistory`: replace the steps up to + * `index` with freshly dispatched entries carrying the timings the replay + * just measured, and keep the later ones. Splices through the ref's + * proxy; mutating the raw array wouldn't trigger reactivity. + */ + const replayingPreview = () => + vi.fn(async function (this: any, index: number) { + const fresh = editHistory.value.slice(0, index + 1).map((h, i) => ({ + method: h.method, + args: h.args, + execution: { inFlight: false, status: 'success', durationMs: 900 + i }, + })) + editHistory.value.splice(0, index + 1, ...fresh) + this.previewIndex = index + return [] + }) + + // A preview keeps the later steps listed; they must not show the timings + // of a run that no longer holds in this view. + it('shows the timings the replay produced, not the originals', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20 }), + makeEntry('INTERPOLATE', [], { durationMs: 30 }), + ] + editHistory.value = history + selectedSeries.value = { + data: { + history: editHistory.value, + redoStack: [], + previewHistory: replayingPreview(), + }, + } + const w = createWrapper() + await readOnly() + await flushPromises() + + await w + .find('[data-testid="history-item-1"] .edit-history__row') + .trigger('click') + // Wait for the preview itself to settle. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + + // Replayed entries report the new measurement. + expect(w.find('[data-testid="history-duration-0"]').text()).toContain('900') + expect(w.find('[data-testid="history-duration-1"]').text()).toContain('901') + // The un-replayed tail survives on screen but did not run. + expect(w.findAll('[data-testid^="history-item-"]').length).toBe(3) + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(false) + }) + + it('hides the per-entry undo and disables the toolbar undo/redo', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [{}] } } + const w = createWrapper() + await readOnly() + await flushPromises() + + expect(w.find('[data-testid="history-undo-0"]').exists()).toBe(false) + expect( + w.find('[data-testid="history-undo-btn"]').attributes('disabled') + ).toBeDefined() + expect( + w.find('[data-testid="history-redo-btn"]').attributes('disabled') + ).toBeDefined() + }) + + it('hides reload-from-server, which would wipe the record', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await readOnly() + await flushPromises() + + expect(w.find('[data-testid="history-reload-btn"]').exists()).toBe(false) + }) + + it('shows reload-from-server on an editable session', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + + const btn = w.find('[data-testid="history-reload-btn"]') + expect(btn.exists()).toBe(true) + // Never gated on whether history exists, unlike the step reload. + expect(btn.attributes('disabled')).toBeUndefined() + }) + + }) + + describe('previewing a step', () => { + const threeSteps = () => [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + + it('keeps every step listed and offers the way back', async () => { + const history = threeSteps() + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(false) + + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(true) + ) + expect(previewHistory).toHaveBeenCalledWith(0) + expect(w.findAll('[data-testid^="history-item-"]').length).toBe(3) + expect(w.find('[data-testid="history-preview-banner"]').text()).toContain( + 'step 1 of 3' + ) + + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await w.find('[data-testid="history-back-to-latest-btn"]').trigger('click') + await vi.waitFor(() => expect(previewHistory).toHaveBeenLastCalledWith(2)) + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(false) + ) + }) + + it('names the starting state when previewing it', async () => { + const history = threeSteps() + editHistory.value = history + selectedSeries.value = { + data: { history, redoStack: [], previewHistory: previewing() }, + } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-reload-step-baseline"]').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').text()).toContain( + 'the starting state' + ) + ) + }) + }) + + describe('loaded step', () => { + it('marks the step the plot reflects, and clears it on undo', async () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + const undo = vi.fn(async function (this: any) { + this.previewIndex = null + return [] + }) + selectedSeries.value = { data: { history, redoStack: [], previewHistory, undo } } + + const w = createWrapper() + await flushPromises() + // With no step singled out the plot reflects the whole history, so the + // last entry carries the marker. + expect(w.find('[data-testid="history-loaded-1"]').exists()).toBe(true) + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + // Undoing changes the history, so "showing step 0" no longer holds and + // the marker returns to the end of the list. + // Wait for the step reload to settle: the button is disabled while + // `isUpdating`, and a click on a disabled button never reaches the handler. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await w.find('[data-testid="history-undo-btn"]').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + ) + expect(w.find('[data-testid="history-loaded-1"]').exists()).toBe(true) + // Drain undo's deferred replay here or it fires in the next test + // against a series with no `undo`. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + expect(undo).toHaveBeenCalled() + }) + + // The baseline row is step -1: the state the session started from, + // before any of its operations. + it('reloads to the session baseline from the Data loaded row', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20 }), + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + await w + .find('[data-testid="history-reload-step-baseline"]') + .trigger('click') + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + + expect(previewHistory).toHaveBeenCalledWith(-1) + // Nothing is applied at the baseline, so no row reports a run. + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(false) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(false) + }) + + it('marks the Data loaded row as the one being shown', async () => { + const history = [makeEntry('SELECTION', [], { durationMs: 10 })] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-loaded-baseline"]').exists()).toBe(false) + + await w + .find('[data-testid="history-reload-step-baseline"]') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-baseline"]').exists()).toBe(true) + ) + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + }) + + it('makes no row clickable when there is nothing to step back from', async () => { + editHistory.value = [] + const previewHistory = previewing() + selectedSeries.value = makeSeries({ previewHistory }) + + const w = createWrapper() + await flushPromises() + + const baseline = w.find('[data-testid="history-reload-step-baseline"]') + expect(baseline.classes()).not.toContain('edit-history__row--clickable') + expect( + w.find('[data-testid="history-step-btn-baseline"]').attributes('disabled') + ).toBeDefined() + await baseline.trigger('click') + expect(previewHistory).not.toHaveBeenCalled() + }) + + // Reloading from a step un-applies everything below it. A committed + // session keeps those rows on screen, so their telemetry would otherwise + // still advertise a run that no longer holds in this view. + it('drops execution info for steps after the one being shown', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20, status: 'failed' }), + makeEntry('INTERPOLATE', [], { durationMs: 30 }), + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(true) + expect(w.find('[data-testid="history-failed-1"]').exists()).toBe(true) + + await w + .find('[data-testid="history-item-0"] .edit-history__row') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + // The step you reloaded from did run, so it keeps its label. + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(false) + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(false) + // The failure badge is execution state too, so it goes as well. + expect(w.find('[data-testid="history-failed-1"]').exists()).toBe(false) + }) + }) + + describe('execution telemetry placement', () => { + // Duration stays on the row: it is the answer to "did that step run?", + // which you want without expanding. Only the dev-only mode chip moved. + it('keeps the duration on the row and the mode chip off it', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 1234, mode: 'inline' }), + ] + const w = createWrapper() + await flushPromises() + + const row = w.find('[data-testid="history-item-0"] .edit-history__row') + expect(row.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(row.text()).not.toContain('inline') + }) + + it('shows the mode chip in the expanded drawer', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 1234, mode: 'inline' }), + ] + const w = createWrapper() + await flushPromises() + + await w + .find('[data-testid="history-item-0"] .edit-history__expand') + .trigger('click') + await flushPromises() + + const detail = w.find('[data-testid="history-execution-0"]') + expect(detail.exists()).toBe(true) + expect(detail.text()).toContain('inline') + }) + + // A replayed step can measure well under a millisecond, which rounds to + // "0ms". That is still a real run and must not read as "never ran". + it('renders sub-millisecond and zero durations rather than hiding them', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 0.0054 }), + makeEntry('DELETE_POINTS', [], { durationMs: 0 }), + ] + const w = createWrapper() + await flushPromises() + + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(true) + }) + }) + + describe('session switch', () => { + it('shows a loading state instead of the outgoing session operations', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-item-0"]').exists()).toBe(true) + + const { useQcSessionStore } = await import('@/store/qcSession') + useQcSessionStore().isSwitchingSession = true + await flushPromises() + + expect(w.find('[data-testid="history-loading"]').exists()).toBe(true) + expect(w.find('[data-testid="history-item-0"]').exists()).toBe(false) + }) + }) + + describe('shown step fallback', () => { + it('marks nothing when the history is empty', async () => { + editHistory.value = [] + selectedSeries.value = { data: { history: [], redoStack: [] } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid^="history-loaded-"]').exists()).toBe(false) + }) + + it('falls back to the last entry when the chosen step is out of range', async () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-item-2"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-2"]').exists()).toBe(true) + ) + + // The history shrinks under the chosen step. Splice through the ref's + // proxy: mutating the raw array wouldn't trigger reactivity. + editHistory.value.splice(1) + await flushPromises() + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + }) + }) + + // A button can't contain other controls, so the row stays a plain click + // target and the step name is the control keyboards and screen readers use. + describe('step button', () => { + it('keeps the row out of the button role', async () => { + editHistory.value = [makeEntry('SELECTION')] + const w = createWrapper() + await flushPromises() + + const row = w.find('[data-testid="history-item-0"] .edit-history__row') + expect(row.attributes('role')).toBeUndefined() + expect(row.attributes('tabindex')).toBeUndefined() + const baseline = w.find('[data-testid="history-reload-step-baseline"]') + expect(baseline.attributes('role')).toBeUndefined() + + const step = w.find('[data-testid="history-step-btn-0"]') + expect(step.element.tagName).toBe('BUTTON') + expect(step.find('button').exists()).toBe(false) + expect(step.text()).toBe('Selection') + }) + + it('replays to its step once', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-step-btn-0"]').trigger('click') + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + expect(previewHistory).toHaveBeenCalledTimes(1) + expect(previewHistory).toHaveBeenCalledWith(0) + }) + + it('is disabled while a dispatch is running', async () => { + editHistory.value = [makeEntry('SELECTION')] + isUpdating.value = true + const w = createWrapper() + await flushPromises() + + expect( + w.find('[data-testid="history-step-btn-0"]').attributes('disabled') + ).toBeDefined() + expect( + w.find('[data-testid="history-step-btn-baseline"]').attributes('disabled') + ).toBeDefined() + }) + }) + + describe('row click does not swallow its buttons', () => { + it('expanding the args drawer does not step back', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w + .find('[data-testid="history-item-0"] .edit-history__expand') + .trigger('click') + expect(w.text()).toContain('Arguments') + expect(previewHistory).not.toHaveBeenCalled() + }) + + it('plotting a comparison line does not step back', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + expect(previewHistory).not.toHaveBeenCalled() + }) + + it('reload-from-server does not step back', async () => { + const history = [makeEntry('SELECTION')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { + data: { + history, + redoStack: [], + previewHistory, + reload: vi.fn().mockResolvedValue(undefined), + }, + } + const w = createWrapper() + await flushPromises() + + // onReload defers its work; drain it here or the history wipe lands + // in the next test. + vi.useFakeTimers() + await w.find('[data-testid="history-reload-btn"]').trigger('click') + await vi.runAllTimersAsync() + vi.useRealTimers() + expect(previewHistory).not.toHaveBeenCalled() + }) + }) + + describe('unapplied steps', () => { + const threeSteps = () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { + data: { history, redoStack: [], previewHistory }, + } + return history + } + + const rowOf = (w: any, index: number) => + w.find(`[data-testid="history-item-${index}"] .edit-history__row`) + + it('dims nothing while the plot reflects the whole history', async () => { + threeSteps() + const w = createWrapper() + await flushPromises() + for (const i of [0, 1, 2]) { + expect(rowOf(w, i).classes()).not.toContain( + 'edit-history__row--unapplied' + ) + } + }) + + it('dims the steps after the one being shown', async () => { + threeSteps() + const w = createWrapper() + await flushPromises() + + // Reload from step 0: steps 1 and 2 were not replayed. + await w + .find('[data-testid="history-item-0"] .edit-history__row') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + expect(rowOf(w, 0).classes()).not.toContain( + 'edit-history__row--unapplied' + ) + expect(rowOf(w, 1).classes()).toContain('edit-history__row--unapplied') + expect(rowOf(w, 2).classes()).toContain('edit-history__row--unapplied') + expect(rowOf(w, 2).attributes('title')).toContain( + 'Not applied in the step currently shown' + ) + }) + }) + + describe('attribution', () => { + it('shows who applied an operation in the expanded detail only', async () => { + editHistory.value = [ + { method: 'ADD_POINTS', args: [], execution: {}, performedBy: 'Ada Lovelace' }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + + // The collapsed row stays terse; attribution lives in the detail. + expect(w.find('[data-testid="history-item-0"]').text()).not.toContain( + 'Ada Lovelace' + ) + + await w.find('[data-testid="history-item-0"]').find('button').trigger('click') + await flushPromises() + expect(w.find('[data-testid="history-author-detail-0"]').text()).toContain( + 'Applied by Ada Lovelace' + ) + + // Unsaved operations have no server attribution yet. + await w.find('[data-testid="history-item-1"]').find('button').trigger('click') + await flushPromises() + expect(w.find('[data-testid="history-author-detail-1"]').exists()).toBe(false) + }) + }) + + describe('selection after loading a step', () => { + const stepBackTo = async (returned: number[] | undefined) => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = vi.fn(async () => returned as any) + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + // Item 0 isn't the trailing entry, so its buttons are expand + reload. + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => expect(previewHistory).toHaveBeenCalledWith(0)) + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + } + + it('applies the selection the replay produced', async () => { + await stepBackTo([3, 4, 5]) + expect(setPlotSelection).toHaveBeenCalledWith([3, 4, 5]) + expect(clearSelected).not.toHaveBeenCalled() + }) + + // Without this the previous selection stayed painted on the plot. + it('clears the selection when the replay produced none', async () => { + await stepBackTo(undefined) + expect(setPlotSelection).not.toHaveBeenCalled() + expect(clearSelected).toHaveBeenCalledWith({ recordHistory: false }) + }) + + it('clears the selection when the replay produced an empty one', async () => { + await stepBackTo([]) + expect(setPlotSelection).not.toHaveBeenCalled() + expect(clearSelected).toHaveBeenCalledWith({ recordHistory: false }) + }) + }) +}) + +describe('EditHistory.vue snapshot buttons', () => { + beforeEach(() => { + editHistory.value = [] + isUpdating.value = false + isBuilding.value = false + plottedSnapshots.value = [] + selectedSeries.value = makeSeries() + vi.clearAllMocks() + }) + + const makeSession = (id: string, createdAt = '2026-03-01T00:00:00Z') => ({ + id, + createdAt, + status: 'in_progress', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }) + + /** + * SessionList only renders the operations slot for a session it actually + * lists, or in its no-session fallback. Seed `sessions` accordingly or the + * history rows never mount. + */ + const mountWithSession = async ( + sessionId: string | null, + sessions: unknown[] = sessionId ? [makeSession(sessionId)] : [] + ) => { + const pinia = createTestPinia() + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + store.sessions = sessions as any + store.viewedSessionId = sessionId + return mount(EditHistory, { + props: {}, + global: { plugins: [pinia, createTestVuetify()] }, + }) + } + + it('renders an add-to-plot button on each operation row and the baseline', async () => { + editHistory.value = [makeEntry('FILL_GAPS'), makeEntry('DELETE_POINTS')] + + const w = await mountWithSession('sess-1') + + expect(w.find('[data-testid="history-snapshot-baseline"]').exists()).toBe(true) + expect(w.find('[data-testid="history-snapshot-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-snapshot-1"]').exists()).toBe(true) + }) + + it('toggles the snapshot for the clicked operation', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1') + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + + expect(toggleSnapshot).toHaveBeenCalledWith('sess-1', 0) + }) + + it('toggles the baseline snapshot at index -1', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1') + await w.find('[data-testid="history-snapshot-baseline"]').trigger('click') + + expect(toggleSnapshot).toHaveBeenCalledWith('sess-1', -1) + }) + + // Plotting a comparison line is a read action, so it stays available on a + // committed session, which is exactly when comparing matters most. + it('stays enabled while the viewed session is read-only', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1', [ + makeSession('sess-1'), + makeSession('sess-2', '2026-04-01T00:00:00Z'), + ]) + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + store.currentSessionId = 'sess-2' + await flushPromises() + + expect(store.isReadOnly).toBe(true) + expect( + w.find('[data-testid="history-snapshot-0"]').attributes('disabled') + ).toBeUndefined() + }) + + it('does nothing when no session is being viewed', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession(null) + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + + expect(toggleSnapshot).not.toHaveBeenCalled() + }) }) diff --git a/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts b/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts new file mode 100644 index 000000000..fc754d52f --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts @@ -0,0 +1,92 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const { qcDatastream, inProgressSession, viewedSession, unsavedEditCount, openEditor } = + vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + qcDatastream: r<{ id: string; name: string } | null>(null), + inProgressSession: r | null>(null), + viewedSession: r | null>(null), + unsavedEditCount: r(0), + openEditor: vi.fn(), + } + }) + +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ qcDatastream }), +})) + +vi.mock('@/store/qcSession', () => ({ + useQcSessionStore: () => ({ inProgressSession, viewedSession }), +})) + +vi.mock('@/composables/useEditSession', () => ({ + useEditSession: () => ({ unsavedEditCount }), +})) + +vi.mock('@/composables/useEditEntry', () => ({ + useEditEntry: () => ({ openEditor }), +})) + +import EditTargetPanel from '@/components/EditData/EditTargetPanel.vue' +import { formatDateRange } from '@/utils/time' + +const mountPanel = () => + mount(EditTargetPanel, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +beforeEach(() => { + vi.clearAllMocks() + qcDatastream.value = { id: 'mgd-1', name: 'Managed Temperature' } + inProgressSession.value = { + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', + } + viewedSession.value = null + unsavedEditCount.value = 0 +}) + +describe('EditTargetPanel', () => { + it('names what is being edited and the session window', () => { + const text = mountPanel().text() + expect(text).toContain('Managed Temperature') + expect(text).toContain( + formatDateRange('2025-01-05T00:00:00Z', '2025-02-01T00:00:00Z') + ) + }) + + it('reports unsaved edits, and says so when there are none', async () => { + expect(mountPanel().text()).toContain('All edits saved') + unsavedEditCount.value = 3 + expect(mountPanel().text()).toContain('3 unsaved edits') + unsavedEditCount.value = 1 + expect(mountPanel().text()).toContain('1 unsaved edit') + }) + + it('shows the window of the session being viewed over the live one', () => { + viewedSession.value = { + phenomenonTimeStart: '2024-03-01T00:00:00Z', + phenomenonTimeEnd: '2024-04-01T00:00:00Z', + } + expect(mountPanel().text()).toContain( + formatDateRange('2024-03-01T00:00:00Z', '2024-04-01T00:00:00Z') + ) + }) + + it('goes back to the editor without touching the session', async () => { + const wrapper = mountPanel() + await wrapper.find('[data-testid="open-editor-btn"]').trigger('click') + expect(openEditor).toHaveBeenCalledTimes(1) + }) + + it('renders nothing without an edit target', () => { + qcDatastream.value = null + expect(mountPanel().find('[data-testid="edit-target-panel"]').exists()).toBe( + false + ) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts new file mode 100644 index 000000000..842058f44 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts @@ -0,0 +1,173 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const { + leavePrompt, + leaveWork, + cancelLeave, + keepSession, + saveAndLeave, + discardEditsAndLeave, + discardSessionAndLeave, +} = vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + leavePrompt: r | null>(null), + leaveWork: r(null), + cancelLeave: vi.fn(), + keepSession: vi.fn(), + saveAndLeave: vi.fn(), + discardEditsAndLeave: vi.fn(), + discardSessionAndLeave: vi.fn(), + } +}) + +vi.mock('@/composables/useLeaveSession', () => ({ + useLeaveSession: () => ({ + leavePrompt, + leaveWork, + cancelLeave, + keepSession, + saveAndLeave, + discardEditsAndLeave, + discardSessionAndLeave, + }), +})) + +import LeaveSessionDialog from '@/components/EditData/LeaveSessionDialog.vue' + +const mountDialog = () => + mount(LeaveSessionDialog, { + attachTo: document.body, + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +const dialogText = () => + document.querySelector('[data-testid="leave-session-dialog"]')?.textContent ?? + '' + +const button = (testId: string) => + document.querySelector(`[data-testid="${testId}"]`) + +beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + leavePrompt.value = null + leaveWork.value = null +}) + +describe('LeaveSessionDialog', () => { + it('shows nothing while no exit is pending', () => { + mountDialog() + expect(button('leave-session-dialog')).toBeNull() + }) + + it('offers save, discard and cancel for unsaved edits', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 2, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + const text = dialogText() + expect(text).toContain('2 edits') + expect(text).toContain('Resume it') + expect(button('leave-save-btn')).not.toBeNull() + expect(button('leave-save-btn')?.getAttribute('disabled')).toBeNull() + expect(button('leave-discard-edits-btn')).not.toBeNull() + + button('leave-discard-edits-btn')?.click() + expect(discardEditsAndLeave).toHaveBeenCalled() + button('leave-save-btn')?.click() + expect(saveAndLeave).toHaveBeenCalled() + button('leave-cancel-btn')?.click() + expect(cancelLeave).toHaveBeenCalled() + }) + + it('disables saving when no session is open', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 1, + canSave: false, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(button('leave-save-btn')?.getAttribute('disabled')).not.toBeNull() + expect(dialogText()).toContain('No session is open') + }) + + it('offers keep or discard for a session with no edits', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'empty', + unsavedCount: 0, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(dialogText()).toContain('Your session on Temp (QC) has no edits') + expect(button('leave-save-btn')).toBeNull() + + button('leave-keep-btn')?.click() + expect(keepSession).toHaveBeenCalled() + button('leave-discard-session-btn')?.click() + expect(discardSessionAndLeave).toHaveBeenCalled() + }) + + it('says the session stays in progress when everything is saved', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'saved', + unsavedCount: 0, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(dialogText()).toContain('stays in progress') + expect(button('leave-discard-session-btn')).toBeNull() + + button('leave-close-btn')?.click() + expect(keepSession).toHaveBeenCalled() + }) + + it('locks the choices while one is running', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 1, + canSave: true, + } + leaveWork.value = 'save' + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(button('leave-cancel-btn')?.getAttribute('disabled')).not.toBeNull() + expect( + button('leave-discard-edits-btn')?.getAttribute('disabled') + ).not.toBeNull() + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts b/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts new file mode 100644 index 000000000..488c23a81 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts @@ -0,0 +1,70 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const editLock = ref<'readOnly' | 'preview' | null>(null) +vi.mock('@/composables/useEditLock', () => ({ + useEditLock: () => ({ editLock }), +})) + +const selectedOperation = ref('changeValues') +const filterRangeActive = ref(false) +vi.mock('@/store/userInterface', () => ({ + useUIStore: () => ({ selectedOperation, filterRangeActive }), +})) + +const selectedData = ref([1, 2]) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ selectedData }), +})) + +vi.mock('../operations', () => ({ + operationsById: { + changeValues: { + id: 'changeValues', + title: 'Change values', + description: '', + icon: 'mdi-pencil', + group: 'edit', + requiresSelection: true, + component: { template: '
' }, + }, + }, +})) + +import OperationPanel from '@/components/EditData/OperationPanel.vue' + +const mountPanel = () => + mount(OperationPanel, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +beforeEach(() => { + editLock.value = null +}) + +describe('OperationPanel edit lock', () => { + it('renders the operation when unlocked', async () => { + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="op-body"]').exists()).toBe(true) + }) + + it('blocks the operation on a committed session', async () => { + editLock.value = 'readOnly' + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="operation-read-only-blocked"]').exists()).toBe(true) + expect(w.find('[data-testid="op-body"]').exists()).toBe(false) + }) + + it('blocks the operation while previewing', async () => { + editLock.value = 'preview' + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="operation-preview-blocked"]').exists()).toBe(true) + expect(w.find('[data-testid="op-body"]').exists()).toBe(false) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts new file mode 100644 index 000000000..b870cdec5 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts @@ -0,0 +1,222 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, beforeEach } from 'vitest' +import type { Pinia } from 'pinia' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +import { useHydroServer } from '@/store/hydroserver' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { useQcSessionStore } from '@/store/qcSession' +import { unwrap } from '@/services/qualityControl/unwrap' +import SessionList from '@/components/EditData/SessionList.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const win = (start: string, end: string) => ({ + phenomenonTimeStart: start, + phenomenonTimeEnd: end, +}) + +let pinia: Pinia +let qc: ReturnType + +beforeEach(() => { + pinia = createTestPinia() + qc = makeQcFake() + useHydroServer().hs = { + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } as any +}) + +const mountList = () => + mount(SessionList, { + global: { plugins: [pinia, createTestVuetify()] }, + slots: { operations: '
ops
' }, + }) + +/** Seed a committed + an in-progress session and load them into the store. */ +async function seedAndLoad() { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const committed = unwrap( + await qc.sessions.create(h.id, win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z')) + ) + await qc.sessions.commit(h.id, committed.id) + const inProgress = unwrap( + await qc.sessions.create(h.id, win('2025-02-01T00:00:00Z', '2025-03-01T00:00:00Z')) + ) + const store = useQcSessionStore() + store.applySessions(h.id, await store.fetchSessions(h.id)) + return { store, committedId: committed.id, inProgressId: inProgress.id } +} + +describe('SessionList', () => { + it('renders an empty state when there are no sessions', () => { + expect(mountList().text()).toContain('No sessions yet') + }) + + it('renders committed and in-progress sessions, marking the editable one', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find(`[data-testid="session-${committedId}"]`).exists()).toBe(true) + expect(wrapper.find(`[data-testid="session-${inProgressId}"]`).exists()).toBe(true) + expect(wrapper.text()).toContain('Editing') + }) + + it('marks each session with its status, not just the editable one', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const marker = (id: string) => wrapper.find(`[data-testid="session-${id}"]`) + expect(marker(committedId).classes()).toContain('qc-timeline__item--done') + expect(marker(committedId).find('.mdi-check').exists()).toBe(true) + expect(marker(inProgressId).classes()).toContain( + 'qc-timeline__item--active' + ) + expect(marker(inProgressId).find('.mdi-pencil').exists()).toBe(true) + }) + + it('lists sessions oldest first, so the most recent is at the bottom', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const ids = wrapper + .findAll('.v-timeline-item[data-testid^="session-"]') + .map((item) => item.attributes('data-testid')) + expect(ids).toEqual([`session-${committedId}`, `session-${inProgressId}`]) + }) + + it('orders by creation even when sessions share a phenomenon-time window', async () => { + // Continuing work after a commit reuses the same window, so the windows + // tie and only the creation time distinguishes the sessions. + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const sameWindow = win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z') + const first = unwrap(await qc.sessions.create(h.id, sameWindow)) + await qc.sessions.commit(h.id, first.id) + const second = unwrap(await qc.sessions.create(h.id, sameWindow)) + useQcSessionStore().applySessions(h.id, await useQcSessionStore().fetchSessions(h.id)) + + const wrapper = mountList() + await flushPromises() + const ids = wrapper + .findAll('.v-timeline-item[data-testid^="session-"]') + .map((item) => item.attributes('data-testid')) + expect(ids).toEqual([`session-${first.id}`, `session-${second.id}`]) + }) + + // Selecting a session reloads observations and replays operations, so the + // list only reports the intent; the view owns the work. + it('emits view for a committed session', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-header-${committedId}"]`).trigger('click') + expect(wrapper.emitted('view')![0]).toEqual([committedId]) + }) + + it('makes each session row a button, so the keyboard can reach it', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const header = wrapper.find(`[data-testid="session-header-${committedId}"]`) + expect(header.element.tagName).toBe('BUTTON') + expect(header.attributes('type')).toBe('button') + }) + + it('emits view for the in-progress session too', async () => { + const { inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-header-${inProgressId}"]`).trigger('click') + expect(wrapper.emitted('view')![0]).toEqual([inProgressId]) + }) + + it('shows "Return to current" while read-only, and it emits the current session', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(false) + + // The view flips this after loading the committed session. + store.viewSession(committedId) + await flushPromises() + + const control = wrapper.find('[data-testid="session-return-current"]') + expect(control.exists()).toBe(true) + await control.trigger('click') + expect(wrapper.emitted('view')!.at(-1)).toEqual([inProgressId]) + }) + + // The operations panel nests under the session it belongs to, so the list + // reads hierarchically instead of as two sibling panels. + it('renders the operations slot under the viewed session only', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + + const inProgressItem = wrapper.find(`[data-testid="session-${inProgressId}"]`) + expect(inProgressItem.find('[data-testid="ops-panel"]').exists()).toBe(true) + const committedItem = wrapper.find(`[data-testid="session-${committedId}"]`) + expect(committedItem.find('[data-testid="ops-panel"]').exists()).toBe(false) + + store.viewSession(committedId) + await flushPromises() + + expect( + wrapper.find(`[data-testid="session-${committedId}"]`).find('[data-testid="ops-panel"]').exists() + ).toBe(true) + expect(wrapper.findAll('[data-testid="ops-panel"]')).toHaveLength(1) + }) + + it('previews the operation count of sessions that are not in view', async () => { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const range = win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z') + const committed = unwrap(await qc.sessions.create(h.id, range)) + await qc.operations.create(h.id, committed.id, [ + { operationType: 'SELECTION' as any, order: 0 }, + { operationType: 'DELETE_POINTS' as any, order: 1 }, + ]) + await qc.sessions.commit(h.id, committed.id) + await qc.sessions.create(h.id, range) + useQcSessionStore().applySessions(h.id, await useQcSessionStore().fetchSessions(h.id)) + + const wrapper = mountList() + await flushPromises() + expect( + wrapper.find(`[data-testid="session-preview-${committed.id}"]`).text() + ).toContain('2 operations') + }) + + it('says so when a session that is not in view has no operations', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find(`[data-testid="session-preview-${committedId}"]`).text()).toContain( + 'No operations' + ) + }) + + it('still renders the operations slot when there are no sessions', () => { + const wrapper = mountList() + expect(wrapper.find('[data-testid="ops-panel"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts new file mode 100644 index 000000000..919cf01d6 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts @@ -0,0 +1,221 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, afterEach } from 'vitest' +import { defineComponent, h } from 'vue' +import { createTestVuetify } from '@/utils/test/vuetify' +import SessionWindowDialog from '@/components/EditData/SessionWindowDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const DatePickerStub = defineComponent({ + props: { modelValue: { type: Date, required: true } }, + emits: ['update:modelValue'], + setup(props, { attrs }) { + return () => + h('input', { + ...attrs, + value: props.modelValue.toISOString(), + }) + }, +}) + +const source = { + phenomenonBeginTime: '2025-01-01T00:00:00Z', + phenomenonEndTime: '2025-12-31T00:00:00Z', +} +const sessions = [ + { + status: 'committed', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-05-01T00:00:00Z', + }, +] + +let wrapper: ReturnType | null = null +afterEach(() => { + wrapper?.unmount() + wrapper = null +}) + +const mountDialog = (props: Record = {}) => { + wrapper = mount(SessionWindowDialog, { + props: { managedName: 'Temp (QC)', source, sessions, ...props }, + global: { + plugins: [createTestVuetify()], + stubs: { DatePickerField: DatePickerStub }, + }, + attachTo: document.body, + }) + return wrapper +} + +const pickers = (w: ReturnType) => + w.findAllComponents(DatePickerStub) + +describe('SessionWindowDialog', () => { + it('prefills the window from where the committed history ends', () => { + const w = mountDialog() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('prefills the whole source extent when nothing is committed', () => { + const w = mountDialog({ sessions: [] }) + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-01-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + }) + + it('shows the committed history range', () => { + const w = mountDialog() + expect(w.find('[data-testid="session-window-committed"]').text()).not.toMatch(/Nothing committed/) + }) + + it('says when nothing is committed yet', () => { + const w = mountDialog({ sessions: [] }) + expect(w.find('[data-testid="session-window-committed"]').text()).toMatch(/Nothing committed yet/) + }) + + it('emits the window on Start', async () => { + const w = mountDialog() + await w.find('[data-testid="session-window-start"]').trigger('click') + const [window] = w.emitted('confirm')![0] as [{ begin: Date; end: Date }] + expect(window.begin.toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect(window.end.toISOString()).toBe('2025-12-31T00:00:00.000Z') + }) + + it('blocks Start when the committed history sits outside the source', () => { + const w = mountDialog({ + sessions: [ + { + status: 'committed', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }, + ], + }) + expect(w.find('[data-testid="session-window-error"]').text()).toMatch(/gap before/) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeDefined() + }) + + it('blocks Start and explains a gap after the history', async () => { + const w = mountDialog() + pickers(w)[0]!.vm.$emit('update:modelValue', new Date('2025-07-01T00:00:00Z')) + await flushPromises() + expect(w.find('[data-testid="session-window-error"]').text()).toMatch(/gap after/) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeDefined() + }) + + it('emits cancel', async () => { + const w = mountDialog() + await w.find('[data-testid="session-window-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) +}) + +describe('SessionWindowDialog presets', () => { + const preset = (w: ReturnType, id: string) => + w.find(`[data-testid="session-window-preset-${id}"]`) + + it('offers the record spans, plus one starting at the committed end', () => { + const w = mountDialog() + for (const id of ['all', '1y', '6m', '1m', 'since']) { + expect(preset(w, id).exists()).toBe(true) + } + }) + + it('leaves out the committed preset when nothing is committed', () => { + const w = mountDialog({ sessions: [] }) + expect(preset(w, 'since').exists()).toBe(false) + expect(preset(w, 'all').exists()).toBe(true) + }) + + it('sets both fields from a preset', async () => { + const w = mountDialog() + await preset(w, 'since').trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('disables a preset that would break a rule and says why', () => { + const w = mountDialog() + expect(preset(w, '1m').attributes('disabled')).toBeDefined() + expect(w.find('[data-testid="session-window-preset-slot-1m"]').attributes('title')).toBe( + 'Leaves a gap after the committed work' + ) + expect(preset(w, 'all').attributes('disabled')).toBeUndefined() + }) + + it('marks the preset matching the current window', async () => { + const w = mountDialog() + expect(preset(w, 'since').attributes('aria-pressed')).toBe('true') + await preset(w, 'all').trigger('click') + await flushPromises() + expect(preset(w, 'all').attributes('aria-pressed')).toBe('true') + expect(preset(w, 'since').attributes('aria-pressed')).toBe('false') + }) +}) + +describe('SessionWindowDialog one-click fix', () => { + const fix = (w: ReturnType) => + w.find('[data-testid="session-window-fix"]') + + it('offers the nearest valid start and leaves the end alone', async () => { + const w = mountDialog() + pickers(w)[0]!.vm.$emit('update:modelValue', new Date('2025-07-01T00:00:00Z')) + await flushPromises() + expect(fix(w).exists()).toBe(true) + + await fix(w).trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeUndefined() + }) + + it('offers the nearest valid end and leaves the start alone', async () => { + const w = mountDialog() + pickers(w)[1]!.vm.$emit('update:modelValue', new Date('2026-06-01T00:00:00Z')) + await flushPromises() + expect(fix(w).text()).toMatch(/^End at/) + + await fix(w).trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('offers no fix when no single correction would be valid', () => { + const w = mountDialog({ + sessions: [ + { + status: 'committed', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }, + ], + }) + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(true) + expect(fix(w).exists()).toBe(false) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts new file mode 100644 index 000000000..569ba0981 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts @@ -0,0 +1,334 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, afterEach } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import StartEditingDialog from '@/components/EditData/StartEditingDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const source = { id: 'src-1', name: 'Raw Temp' } as any + +const options = [ + { + historyId: 'h-1', + managed: { + id: 'mgd-1', + name: 'Temp (QC)', + valueCount: 1234, + processingLevel: { name: 'Quality Controlled' }, + }, + sessions: [ + { + id: 's-1', + status: 'in_progress', + createdAt: '2025-01-05T09:00:00Z', + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-01-15T00:00:00Z', + description: '', + }, + { + id: 's-0', + status: 'committed', + createdAt: '2024-12-01T09:00:00Z', + description: 'Removed spikes', + phenomenonTimeStart: '2024-12-01T00:00:00', + phenomenonTimeEnd: '2024-12-31T00:00:00', + }, + ], + }, + { historyId: 'h-2', managed: { id: 'mgd-2', name: 'Temp (QC v2)' }, sessions: [] }, +] as any + +// The delete confirmation is a v-dialog, whose content Vuetify teleports +// out of the wrapper, so it is queried through the document instead. +const mountDialog = (props: Record = {}) => + mount(StartEditingDialog, { + props: { source, options, ...props }, + global: { plugins: [createTestVuetify()] }, + attachTo: document.body, + }) + +const inDialog = (testId: string) => + document.querySelector(`[data-testid="${testId}"]`) + +const clickInDialog = async (testId: string) => { + const el = inDialog(testId) + if (!el) throw new Error(`${testId} is not in the dialog`) + ;(el as HTMLElement).click() + await flushPromises() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('StartEditingDialog', () => { + it('lists managed datastreams and labels in-progress vs committed sessions', () => { + const w = mountDialog() + expect(w.text()).toContain('Raw Temp') + expect(w.text()).toContain('Temp (QC)') + expect(w.text()).toContain('In progress') + expect(w.text()).toContain('Committed') + }) + + it('offers Continue on the in-progress session row, not in the header', () => { + const w = mountDialog() + // mgd-1 has an in-progress session: continuing hangs off that row. + expect(w.find('[data-testid="edit-managed-mgd-1"]').exists()).toBe(false) + expect(w.find('[data-testid="continue-session-s-1"]').text()).toContain( + 'Continue' + ) + // mgd-2 has none, so the header offers starting one. + expect(w.find('[data-testid="edit-managed-mgd-2"]').text()).toContain( + 'Start new' + ) + }) + + it('heads the timeline with the start-a-session node, above the sessions', () => { + const w = mountDialog() + const ids = w + .findAll('.v-timeline-item') + .map((el) => el.attributes('data-testid') ?? 'new-session-node') + // mgd-1 has an in-progress session, so only mgd-2 offers the node. + expect(w.find('[data-testid="edit-managed-mgd-1"]').exists()).toBe(false) + expect(w.find('[data-testid="edit-managed-mgd-2"]').text()).toContain( + 'Start new session' + ) + // mgd-1's sessions run oldest first; mgd-2's action node follows them. + expect(ids).toEqual([ + 'chooser-session-s-0', + 'chooser-session-s-1', + 'new-session-node', + ]) + }) + + it('renders sessions as a timeline, marked by status', () => { + const w = mountDialog() + const item = (id: string) => w.find(`[data-testid="chooser-session-${id}"]`) + + expect(w.findAll('.v-timeline-item[data-testid^="chooser-session-"]')) + .toHaveLength(2) + expect(item('s-1').classes()).toContain('qc-timeline__item--active') + expect(item('s-1').find('.mdi-pencil').exists()).toBe(true) + expect(item('s-0').classes()).toContain('qc-timeline__item--done') + expect(item('s-0').find('.mdi-check').exists()).toBe(true) + }) + + it('offers delete only on the in-progress session', () => { + const w = mountDialog() + expect(w.find('[data-testid="delete-session-s-1"]').exists()).toBe(true) + expect(w.find('[data-testid="delete-session-s-0"]').exists()).toBe(false) + expect(w.find('[data-testid="continue-session-s-0"]').exists()).toBe(false) + }) + + it('drops the In progress chip but keeps the state readable', () => { + const w = mountDialog() + const active = w.find('[data-testid="chooser-session-s-1"]') + // The Continue button already says the row is in progress. + expect(active.find('.v-chip').exists()).toBe(false) + expect(active.find('.d-sr-only').text()).toBe('In progress') + // A committed row has no button, so its chip is the only cue. + const done = w.find('[data-testid="chooser-session-s-0"]') + expect(done.find('.v-chip').text()).toBe('Committed') + expect(done.find('.d-sr-only').exists()).toBe(false) + }) + + it('asks for confirmation, then emits deleteSession for that session', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + expect(inDialog('confirm-delete-session-s-1')).not.toBeNull() + await clickInDialog('confirm-delete-session-s-1') + const [option, sessionId] = w.emitted('deleteSession')![0] + expect(option).toMatchObject({ historyId: 'h-1' }) + expect(sessionId).toBe('s-1') + }) + + it('cancelling the confirmation emits nothing', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + await clickInDialog('cancel-delete') + expect(w.emitted('deleteSession')).toBeUndefined() + }) + + it('warns that the delete cannot be undone', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + const dialog = inDialog('delete-session-dialog')! + expect(dialog.textContent).toContain('Delete this session?') + expect(dialog.textContent).toContain('cannot be undone') + expect(dialog.textContent).toContain( + "managed datastream's observations and earlier sessions are untouched" + ) + expect(inDialog('delete-session-chain')).toBeNull() + expect(inDialog('delete-session-dependents-note')).toBeNull() + expect(inDialog('delete-session-acknowledge')).toBeNull() + }) + + it('formats session date ranges readably', () => { + const w = mountDialog() + expect(w.text()).toContain('Dec 1 – Dec 31, 2024') + }) + + it('shows the period alongside the description on a described session', () => { + const w = mountDialog() + // s-0 is committed and described, so the description takes the title and + // the window has to surface underneath it. + expect(w.text()).toContain('Removed spikes') + expect(w.text()).toContain('Dec 1 – Dec 31, 2024') + }) + + it('shows a recap line with processing level, observations, and sessions', () => { + const w = mountDialog() + expect(w.text()).toContain('Quality Controlled') + expect(w.text()).toContain('1,234 obs') + expect(w.text()).toContain('2 sessions, 1 in progress') + }) + + it('asks for confirmation, then emits delete with the chosen option', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-managed-mgd-1"]').trigger('click') + await flushPromises() + expect(inDialog('delete-managed-dialog')?.textContent).toContain('Temp (QC)') + expect(w.emitted('delete')).toBeUndefined() + await clickInDialog('confirm-delete-mgd-1') + expect(w.emitted('delete')![0][0]).toMatchObject({ historyId: 'h-1' }) + }) + + it('emits edit from the in-progress row and from the header', async () => { + const w = mountDialog() + await w.find('[data-testid="continue-session-s-1"]').trigger('click') + expect(w.emitted('edit')![0][0]).toMatchObject({ historyId: 'h-1' }) + await w.find('[data-testid="edit-managed-mgd-2"]').trigger('click') + expect(w.emitted('edit')![1][0]).toMatchObject({ historyId: 'h-2' }) + }) + + it('emits create and cancel', async () => { + const w = mountDialog() + await w.find('[data-testid="chooser-create-managed"]').trigger('click') + expect(w.emitted('create')).toHaveLength(1) + await w.find('[data-testid="chooser-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) + + it('shows an empty hint with only the create option when there are no managed datastreams', () => { + const w = mountDialog({ options: [] }) + expect(w.text()).toContain('No QC datastreams') + expect(w.find('[data-testid="chooser-create-managed"]').exists()).toBe(true) + }) +}) + +describe('StartEditingDialog session deletion', () => { + // s-0 committed, s-1 built on it, s-2 built on s-1. + const chained = [ + { + historyId: 'h-1', + managed: { id: 'mgd-1', name: 'Temp (QC)' }, + sessions: [ + { + id: 's-0', + status: 'committed', + createdAt: '2024-12-01T09:00:00Z', + description: 'First pass', + phenomenonTimeStart: '2024-12-01T00:00:00Z', + phenomenonTimeEnd: '2024-12-31T00:00:00Z', + dependencyIds: [], + }, + { + id: 's-1', + status: 'committed', + createdAt: '2025-01-05T09:00:00Z', + description: 'Second pass', + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-01-15T00:00:00Z', + dependencyIds: ['s-0'], + }, + { + id: 's-2', + status: 'in_progress', + createdAt: '2025-02-01T09:00:00Z', + description: 'Third pass', + phenomenonTimeStart: '2025-02-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-10T00:00:00Z', + dependencyIds: ['s-1'], + }, + ], + }, + ] as any + + const mountChained = () => + mount(StartEditingDialog, { + props: { source, options: chained }, + global: { plugins: [createTestVuetify()] }, + attachTo: document.body, + }) + + const openDeleteFor = async (id: string) => { + const w = mountChained() + await w.find(`[data-testid="delete-session-${id}"]`).trigger('click') + return w + } + + it('offers delete on the in-progress session only, never a commit', () => { + const w = mountChained() + expect(w.find('[data-testid="delete-session-s-2"]').exists()).toBe(true) + expect(w.find('[data-testid="delete-session-s-1"]').exists()).toBe(false) + expect(w.find('[data-testid="delete-session-s-0"]').exists()).toBe(false) + }) + + it('names the session and deletes it without an acknowledgement', async () => { + const w = await openDeleteFor('s-2') + const dialog = inDialog('delete-session-dialog')! + expect(dialog.textContent).toContain('Delete this session?') + expect(dialog.textContent).toContain('Third pass') + expect(inDialog('delete-session-acknowledge')).toBeNull() + + const confirm = inDialog('confirm-delete-session-s-2') as HTMLButtonElement + expect(confirm.disabled).toBe(false) + await clickInDialog('confirm-delete-session-s-2') + expect(w.emitted('deleteSession')![0][1]).toBe('s-2') + }) + + it('labels the trash button for the one session it removes', () => { + const w = mountChained() + expect( + w.find('[data-testid="delete-session-s-2"]').attributes('title') + ).toBe('Delete this session') + }) + + it('does not offer delete on a committed session, even the newest', () => { + const lone = [ + { + historyId: 'h-9', + managed: { id: 'mgd-9', name: 'Temp (QC solo)' }, + sessions: [ + { + id: 's-solo', + status: 'committed', + createdAt: '2025-03-01T09:00:00Z', + description: 'Only pass', + phenomenonTimeStart: '2025-03-01T00:00:00Z', + phenomenonTimeEnd: '2025-03-05T00:00:00Z', + dependencyIds: [], + }, + ], + }, + ] as any + const w = mountDialog({ options: lone }) + expect(w.find('[data-testid="delete-session-s-solo"]').exists()).toBe(false) + // The managed datastream keeps its own delete, which removes its + // observations along with every session. + expect(w.find('[data-testid="delete-managed-mgd-9"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts new file mode 100644 index 000000000..3a1acadb5 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts @@ -0,0 +1,658 @@ +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { VDialog } from 'vuetify/components' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const { + loadForSource, + deleteManaged, + deleteSession, + enterEdit, + startSessionOver, + leaveEdit, + closeEditor, + createManaged, + addQcHistory, + removeManagedDatastream, + invalidate, + qcDatastream, + datastreams, + processingLevels, + sourceDatastream, + sessions, + success, + error, +} = vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + loadForSource: vi.fn(), + deleteManaged: vi.fn(), + deleteSession: vi.fn(), + enterEdit: vi.fn(), + startSessionOver: vi.fn(), + leaveEdit: vi.fn(), + closeEditor: vi.fn(), + createManaged: vi.fn(), + addQcHistory: vi.fn(), + removeManagedDatastream: vi.fn(), + invalidate: vi.fn(), + qcDatastream: r(null), + datastreams: r([]), + processingLevels: r([]), + sourceDatastream: r(null), + sessions: r([]), + success: vi.fn(), + error: vi.fn(), + } +}) + +vi.mock('@/store/dataVisualization', async () => { + const { defineStore } = await import('pinia') + return { + useDataVisStore: defineStore('dataVisualization', () => ({ + qcDatastream, + datastreams, + processingLevels, + addQcHistory, + removeManagedDatastream, + })), + } +}) + +vi.mock('@/store/qcSession', async () => { + const { defineStore } = await import('pinia') + const { computed } = await import('vue') + return { + useQcSessionStore: defineStore('qcSession', () => ({ + sourceDatastream, + sessions, + inProgressSession: computed( + () => sessions.value.find((s) => s.status === 'in_progress') ?? null + ), + })), + } +}) + +vi.mock('@/store/workingCopies', () => ({ + useWorkingCopiesStore: () => ({ invalidate }), +})) + +vi.mock('@/composables/useManagedDatastreams', () => ({ + useManagedDatastreams: () => ({ + loadForSource, + deleteManaged, + deleteSession, + }), +})) + +vi.mock('@/composables/useEditEntry', () => ({ + useEditEntry: () => ({ enterEdit, startSessionOver, leaveEdit, closeEditor }), +})) + +vi.mock('@/composables/useCreateManagedDatastream', () => ({ + useCreateManagedDatastream: () => ({ create: createManaged }), +})) + +vi.mock('@/composables/useProcessingLevels', () => ({ + useProcessingLevels: () => ({ createProcessingLevel: vi.fn() }), +})) + +vi.mock('@/composables/useWorkspacePermissions', () => ({ + useWorkspacePermissions: () => ({ + canCreateDatastream: () => true, + roleName: () => 'Owner', + }), +})) + +vi.mock('@uwrl/qc-utils', () => ({ Snackbar: { success, error } })) + +import StartEditingFlow from '@/components/EditData/StartEditingFlow.vue' + +const stub = (name: string, testId: string, emits: string[]) => + defineComponent({ + name, + emits, + setup: () => () => h('div', { 'data-testid': testId }), + }) + +const ChooserStub = stub('StartEditingDialog', 'chooser-stub', [ + 'edit', + 'delete', + 'deleteSession', + 'create', + 'cancel', +]) +const CreateStub = defineComponent({ + name: 'CreateDatastreamForm', + emits: ['cancel', 'confirm'], + props: { loading: Boolean }, + setup: () => () => h('div', { 'data-testid': 'create-stub' }), +}) +const WindowStub = stub('SessionWindowDialog', 'window-stub', [ + 'confirm', + 'cancel', +]) + +interface FlowApi { + openFor(source: unknown): Promise + openNewSession(): void + resume(managedId: string): Promise +} + +const source = { + id: 'src', + name: 'Raw Temp', + phenomenonBeginTime: '2025-01-01T00:00:00Z', + phenomenonEndTime: '2025-12-31T00:00:00Z', +} +const committed = { + id: 's-0', + status: 'committed', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', +} +const inProgress = { ...committed, id: 's-1', status: 'in_progress' } +const optionWith = (list: unknown[]) => ({ + historyId: 'h', + managed: { id: 'mgd', name: 'Temp (QC)' }, + sessions: list, +}) +const window = { + begin: new Date('2025-02-01T00:00:00Z'), + end: new Date('2025-03-01T00:00:00Z'), +} + +const mountFlow = () => { + const wrapper = mount(StartEditingFlow, { + global: { + plugins: [createTestPinia(), createTestVuetify()], + stubs: { + StartEditingDialog: ChooserStub, + CreateDatastreamForm: CreateStub, + SessionWindowDialog: WindowStub, + }, + }, + attachTo: document.body, + }) + return { wrapper, flow: wrapper.vm as unknown as FlowApi } +} + +// v-dialog teleports its content out of the wrapper. +const present = (testId: string) => + !!document.querySelector(`[data-testid="${testId}"]`) + +// Dialog order: chooser, create form, session window. +const dialogOpen = ( + wrapper: ReturnType['wrapper'], + i: number +) => wrapper.findAllComponents(VDialog)[i].props('modelValue') + +enableAutoUnmount(afterEach) +afterEach(() => { + document.body.innerHTML = '' +}) + +beforeEach(() => { + vi.clearAllMocks() + qcDatastream.value = null + datastreams.value = [] + processingLevels.value = [] + sourceDatastream.value = null + sessions.value = [] + enterEdit.mockResolvedValue('editing') + startSessionOver.mockResolvedValue(true) + leaveEdit.mockResolvedValue(undefined) + closeEditor.mockResolvedValue(true) +}) + +describe('StartEditingFlow', () => { + it('opens the chooser when the source has managed datastreams', async () => { + loadForSource.mockResolvedValue([optionWith([committed])]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(loadForSource).toHaveBeenCalledWith('src') + expect(present('chooser-stub')).toBe(true) + expect(present('create-stub')).toBe(false) + }) + + it('goes straight to the create form when the source has none', async () => { + loadForSource.mockResolvedValue([]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(present('create-stub')).toBe(true) + expect(present('chooser-stub')).toBe(false) + }) + + it('continues an in-progress session without asking for a window', async () => { + const option = optionWith([committed, inProgress]) + loadForSource.mockResolvedValue([option]) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(enterEdit).toHaveBeenCalledTimes(1) + // Picked from a row, so it opens in the Select view's preview. + expect(enterEdit.mock.calls[0]).toEqual(['mgd', undefined, 'Select']) + expect(present('window-stub')).toBe(false) + }) + + it('asks for a window before starting a new session', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(present('window-stub')).toBe(true) + expect(enterEdit).not.toHaveBeenCalled() + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(enterEdit).toHaveBeenCalledWith('mgd', window, 'Select') + expect(present('window-stub')).toBe(false) + }) + + it('opens the window step when a resume finds no session', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + expect(enterEdit.mock.calls[0]).toEqual(['mgd', undefined, undefined]) + expect(present('window-stub')).toBe(true) + }) + + it('leaves the editor when the window is cancelled with no sessions at all', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when the window is closed with Esc or the scrim after an empty resume', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findAllComponents(VDialog)[2].vm.$emit('update:modelValue', false) + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when the window is cancelled after a resume over committed-only sessions', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + sessions.value = [committed] + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + }) + + it('stays in the editor when the footer window is cancelled', async () => { + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).not.toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('starts the session in the open editor when a resume window is confirmed', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(startSessionOver).toHaveBeenCalledWith(window) + expect(enterEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the window open to pick again when starting the session fails', async () => { + startSessionOver.mockResolvedValue(false) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(present('window-stub')).toBe(true) + }) + + it('remounts the window step for a new target, so its defaults are fresh', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + // The entering start fails and reopens the step on the open editor. + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + const first = wrapper.findComponent(WindowStub).vm + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + + expect(present('window-stub')).toBe(true) + expect(wrapper.findComponent(WindowStub).vm).not.toBe(first) + }) + + it('closes the window when the start left the editor', async () => { + startSessionOver.mockImplementation(async () => { + qcDatastream.value = null + return false + }) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when a resume needs a window but the source is missing', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = null + const { flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(error).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('starts a new session in the open editor from the footer', async () => { + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + sessions.value = [committed] + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(startSessionOver).toHaveBeenCalledWith(window) + expect(enterEdit).not.toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the open session before the create form, not after the create', async () => { + loadForSource.mockResolvedValue([]) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('creates nothing when the open session is kept', async () => { + loadForSource.mockResolvedValue([]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(createManaged).not.toHaveBeenCalled() + }) + + it('asks before the create form opened from the chooser', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('create') + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('returns to the chooser when the open session is kept', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('create') + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(dialogOpen(wrapper, 0)).toBe(true) + }) + + it('asks about the open session when another datastream is picked, before the window step', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('window-stub')).toBe(true) + }) + + it('returns to the chooser when a pick keeps the open session', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(present('window-stub')).toBe(false) + expect(enterEdit).not.toHaveBeenCalled() + expect(dialogOpen(wrapper, 0)).toBe(true) + }) + + it('does not ask when the open datastream is picked again', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: option.managed.id, name: option.managed.name } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(closeEditor).not.toHaveBeenCalled() + expect(enterEdit).toHaveBeenCalled() + }) + + it('does not ask when nothing is being edited', async () => { + loadForSource.mockResolvedValue([]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(closeEditor).not.toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('asks for a window for a newly created managed datastream', async () => { + loadForSource.mockResolvedValue([]) + const managed = { id: 'mgd-new', name: 'Temp (QC new)' } + createManaged.mockResolvedValue({ + managedDatastream: managed, + history: { id: 'h-new' }, + }) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(CreateStub).vm.$emit('confirm', { + source, + processingLevelId: 'pl', + description: 'Cleaned series', + methodId: 'sn-1', + }) + await flushPromises() + + expect(addQcHistory).toHaveBeenCalledWith({ id: 'h-new' }) + expect(datastreams.value).toContainEqual(managed) + expect(success).toHaveBeenCalledWith('Managed datastream created.') + // Closed dialogs keep their content mounted, so check the dialog itself. + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(present('window-stub')).toBe(true) + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(enterEdit).toHaveBeenCalledWith('mgd-new', window, 'Select') + }) + + it('keeps the create form open and busy until the create finishes', async () => { + loadForSource.mockResolvedValue([]) + let finish!: (value: unknown) => void + createManaged.mockReturnValue(new Promise((resolve) => (finish = resolve))) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + const form = wrapper.findComponent(CreateStub) + form.vm.$emit('confirm', { source, processingLevelId: 'pl' }) + await flushPromises() + + expect(dialogOpen(wrapper, 1)).toBe(true) + expect(form.props('loading')).toBe(true) + + finish({ managedDatastream: { id: 'mgd-new', name: 'New' }, history: {} }) + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(form.props('loading')).toBe(false) + }) + + it('keeps the create form open when the create fails', async () => { + loadForSource.mockResolvedValue([]) + createManaged.mockRejectedValue(new Error('Name already taken')) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + const form = wrapper.findComponent(CreateStub) + form.vm.$emit('confirm', { source, processingLevelId: 'pl' }) + await flushPromises() + + expect(error).toHaveBeenCalledWith('Name already taken') + expect(dialogOpen(wrapper, 1)).toBe(true) + expect(form.props('loading')).toBe(false) + expect(present('window-stub')).toBe(false) + }) +}) + +describe('StartEditingFlow deletes', () => { + const openChooser = async (option: unknown) => { + loadForSource.mockResolvedValue([option]) + const mounted = mountFlow() + await mounted.flow.openFor(source) + await flushPromises() + return mounted.wrapper.findComponent(ChooserStub) + } + + it('leaves the editor after deleting the managed datastream it has open', async () => { + qcDatastream.value = { id: 'mgd' } + deleteManaged.mockResolvedValue(undefined) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(removeManagedDatastream).toHaveBeenCalledWith('h', 'mgd') + expect(leaveEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the editor when another managed datastream is deleted', async () => { + qcDatastream.value = { id: 'other' } + deleteManaged.mockResolvedValue(undefined) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(leaveEdit).not.toHaveBeenCalled() + }) + + it('leaves the editor after deleting a session of the datastream it has open', async () => { + qcDatastream.value = { id: 'mgd' } + deleteSession.mockResolvedValue(undefined) + const option = optionWith([committed, inProgress]) + const chooser = await openChooser(option) + + chooser.vm.$emit('deleteSession', option, 's-1') + await flushPromises() + + expect(deleteSession).toHaveBeenCalledWith('h', 's-1') + expect(leaveEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the editor when the delete fails', async () => { + qcDatastream.value = { id: 'mgd' } + deleteManaged.mockRejectedValue(new Error('Forbidden')) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(error).toHaveBeenCalledWith('Forbidden') + expect(leaveEdit).not.toHaveBeenCalled() + }) + + it('shows a failed session delete even when reloading the chooser fails too', async () => { + deleteSession.mockRejectedValue(new Error('Only in-progress sessions can be deleted.')) + const option = optionWith([committed, inProgress]) + const chooser = await openChooser(option) + loadForSource.mockRejectedValue(new Error('Offline')) + + chooser.vm.$emit('deleteSession', option, 's-1') + await flushPromises() + + expect(error).toHaveBeenCalledWith('Only in-progress sessions can be deleted.') + }) +}) diff --git a/apps/qc-app/src/components/EditData/operations.ts b/apps/qc-app/src/components/EditData/operations.ts index 5de0b9f9e..8f224a1fa 100644 --- a/apps/qc-app/src/components/EditData/operations.ts +++ b/apps/qc-app/src/components/EditData/operations.ts @@ -19,6 +19,7 @@ import AddPoints from '@/components/EditData/AddPoints.vue' import ShiftDatetimes from '@/components/EditData/ShiftDatetimes.vue' import FillGaps from '@/components/EditData/FillGaps.vue' import QualifyingComments from '@/components/EditData/QualifyingComments.vue' +import { QUALIFIER_TOOL_ENABLED } from '@/store/qualifiers' export type OperationGroup = 'filter' | 'edit' | 'add' @@ -137,15 +138,19 @@ export const operations: OperationMeta[] = [ component: markRaw(DeletePoints), }, // Add Data - { - id: 'qualifyingComments', - title: 'Qualifying comments', - description: 'Attach qualifier flags to selected points', - icon: 'mdi-flag', - group: 'add', - requiresSelection: true, - component: markRaw(QualifyingComments), - }, + ...(QUALIFIER_TOOL_ENABLED + ? [ + { + id: 'qualifyingComments', + title: 'Qualifying comments', + description: 'Attach qualifier flags to selected points', + icon: 'mdi-flag', + group: 'add' as const, + requiresSelection: true, + component: markRaw(QualifyingComments), + }, + ] + : []), { id: 'addPoints', title: 'Add points', diff --git a/apps/qc-app/src/components/FilterPoints/GapFinder.vue b/apps/qc-app/src/components/FilterPoints/GapFinder.vue index 6ff539bee..ba35bea76 100644 --- a/apps/qc-app/src/components/FilterPoints/GapFinder.vue +++ b/apps/qc-app/src/components/FilterPoints/GapFinder.vue @@ -256,7 +256,7 @@ const snapChips = computed(() => { return multipliers.map((m) => { const amount = n * m return { - label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, + label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, amount, unit: unitKey, active: diff --git a/apps/qc-app/src/components/FilterPoints/RangeStager.vue b/apps/qc-app/src/components/FilterPoints/RangeStager.vue index 14dac2d48..e7b849733 100644 --- a/apps/qc-app/src/components/FilterPoints/RangeStager.vue +++ b/apps/qc-app/src/components/FilterPoints/RangeStager.vue @@ -79,6 +79,7 @@ import { stagePanMode, } from '@/utils/plotting/staging' import DatePickerField from '@/components/VisualizeData/DatePickerField.vue' +import { fromWallParts, wallParts } from '@/utils/timeZone' const { selectedSeries } = storeToRefs(usePlotlyStore()) @@ -205,7 +206,7 @@ const rangePresets = computed(() => { { label: 'YTD', compute: (b) => { - const jan1 = new Date(new Date().getFullYear(), 0, 1).getTime() + const jan1 = fromWallParts(wallParts(Date.now()).year, 0, 1) const start = Math.max(jan1, b.min) if (start >= b.max) return null return [start, b.max] diff --git a/apps/qc-app/src/components/Navigation/EditDrawer.vue b/apps/qc-app/src/components/Navigation/EditDrawer.vue index b85c841c4..7b9564504 100644 --- a/apps/qc-app/src/components/Navigation/EditDrawer.vue +++ b/apps/qc-app/src/components/Navigation/EditDrawer.vue @@ -1,6 +1,17 @@  diff --git a/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue b/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue index 17d6e90e5..7ed7cca2e 100644 --- a/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue +++ b/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue @@ -158,7 +158,7 @@ the algorithm, not the machine, so it's shipped with qc-utils, not measured at runtime. Recalibration only re-measures the three device primitives above; weights stay fixed. The - dispatch formula is weight × N / throughput, so + dispatch formula is weight × N / throughput, so one universal weight per op plus your per-device throughputs covers the full operation catalog.

diff --git a/apps/qc-app/src/components/Navigation/SelectDrawer.vue b/apps/qc-app/src/components/Navigation/SelectDrawer.vue index 3c1ebc179..1d718cbfb 100644 --- a/apps/qc-app/src/components/Navigation/SelectDrawer.vue +++ b/apps/qc-app/src/components/Navigation/SelectDrawer.vue @@ -35,27 +35,6 @@ -
- - - Time range -
-
- -
- - -
diff --git a/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts b/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts new file mode 100644 index 000000000..2e284ff5b --- /dev/null +++ b/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts @@ -0,0 +1,75 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const editLock = ref<'readOnly' | 'preview' | null>(null) +vi.mock('@/composables/useEditLock', () => ({ + useEditLock: () => ({ editLock }), +})) + +const selectedOperation = ref(null) +vi.mock('@/store/userInterface', () => ({ + useUIStore: () => ({ selectedOperation }), +})) + +const selectedData = ref([1]) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ selectedData }), +})) + +vi.mock('@/components/EditData/operations', () => { + const op = (id: string, group: string) => ({ + id, + title: id, + description: '', + icon: 'mdi-x', + group, + requiresSelection: group === 'edit', + }) + return { + operationsByGroup: { + filter: [op('valueThreshold', 'filter')], + edit: [op('changeValues', 'edit')], + add: [op('addPoints', 'add')], + }, + colorForOperation: () => 'primary', + } +}) + +import EditDrawer from '@/components/Navigation/EditDrawer.vue' + +const mountDrawer = () => + mount(EditDrawer, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +const isDisabled = (w: ReturnType, id: string) => + w.find(`[data-testid="op-${id}"]`).classes().includes('v-list-item--disabled') + +beforeEach(() => { + editLock.value = null + selectedOperation.value = null +}) + +describe('EditDrawer edit lock', () => { + it('enables every operation when unlocked', async () => { + const w = mountDrawer() + await flushPromises() + expect(w.find('[data-testid="edit-drawer-read-only"]').exists()).toBe(false) + for (const id of ['valueThreshold', 'changeValues', 'addPoints']) { + expect(isDisabled(w, id)).toBe(false) + } + }) + + it('disables every operation and explains why on a committed session', async () => { + editLock.value = 'readOnly' + const w = mountDrawer() + await flushPromises() + expect(w.find('[data-testid="edit-drawer-read-only"]').exists()).toBe(true) + for (const id of ['valueThreshold', 'changeValues', 'addPoints']) { + expect(isDisabled(w, id)).toBe(true) + } + }) +}) diff --git a/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts b/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts new file mode 100644 index 000000000..365702938 --- /dev/null +++ b/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts @@ -0,0 +1,307 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +import { VLayout } from 'vuetify/components' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const { + qcDatastream, + qcDatastreamId, + resetState, + clearEditTarget, + currentView, + selectedDrawer, + isDrawerOpen, + resumeDatastreamId, + hasUnsavedChanges, + inProgressSession, + hasSessionOperations, + push, + assign, + logout, + showView, +} = vi.hoisted(() => { + const { ref: r, computed: c } = require('vue') as typeof import('vue') + const qcDatastreamId = r(null) + const currentView = r('Edit') + const selectedDrawer = r('Edit') + const isDrawerOpen = r(true) + return { + qcDatastreamId, + qcDatastream: c(() => + qcDatastreamId.value ? { id: qcDatastreamId.value } : null + ), + resetState: vi.fn(() => { + qcDatastreamId.value = null + }), + clearEditTarget: vi.fn(async () => { + qcDatastreamId.value = null + }), + currentView, + selectedDrawer, + isDrawerOpen, + resumeDatastreamId: r(null), + hasUnsavedChanges: r(false), + inProgressSession: r<{ id: string } | null>(null), + hasSessionOperations: r(false), + push: vi.fn(async () => {}), + assign: vi.fn(), + logout: vi.fn(), + showView: vi.fn((view: string) => { + currentView.value = view + selectedDrawer.value = view + isDrawerOpen.value = true + }), + } +}) + +vi.mock('@/store/dataVisualization', async () => { + const { defineStore } = await import('pinia') + return { + useDataVisStore: defineStore('dataVisualization', () => ({ + qcDatastream, + qcDatastreamId, + resetState, + setEditTarget: vi.fn(), + clearEditTarget, + })), + } +}) + +vi.mock('@/store/userInterface', async () => { + const { defineStore } = await import('pinia') + return { + DrawerType: { Edit: 'Edit', Select: 'Select', None: '' }, + useUIStore: defineStore('userInterface', () => ({ + currentView, + selectedDrawer, + isDrawerOpen, + showView, + onRailItemClicked: vi.fn((title: string) => { + if (selectedDrawer.value === title) { + isDrawerOpen.value = !isDrawerOpen.value + } else showView(title) + }), + })), + } +}) + +vi.mock('@/store/qcSession', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useQcSessionStore: defineStore('qcSession', () => ({ + resumeDatastreamId, + historyId: ref('h-1'), + sessions: ref([]), + inProgressSession, + isReadOnly: ref(false), + hasSessionOperations, + })), + } +}) + +vi.mock('@/store/hydroserver', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useHydroServer: defineStore('hydroserver', () => ({ + hs: ref({ session: { logout } }), + })), + } +}) + +vi.mock('@/store/workspaces', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useWorkspaceStore: defineStore('workspaces', () => ({ + selectedWorkspace: ref(null), + clearSelection: vi.fn(), + })), + } +}) + +vi.mock('@/store/plotly', () => ({ + usePlotlyStore: () => ({ redraw: vi.fn() }), +})) + +vi.mock('@/composables/useEditSession', async () => { + const { ref } = await import('vue') + return { + ResumeSupersededError: class extends Error {}, + useEditSession: () => ({ + hasUnsavedChanges, + unsavedEditCount: ref(0), + saveDraft: vi.fn(), + discardUnsavedEdits: vi.fn(), + beginEditing: vi.fn(), + startSession: vi.fn(), + }), + } +}) + +vi.mock('@/router/router', () => ({ default: { push } })) + +vi.mock('@uwrl/qc-utils', () => ({ + Snackbar: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + +vi.mock('@/components/Navigation/SelectDrawer.vue', () => ({ + default: { name: 'SelectDrawer', render: () => null }, +})) +vi.mock('@/components/Navigation/PerformanceCalibration.vue', () => ({ + default: { name: 'PerformanceCalibration', render: () => null }, +})) + +import NavigationRail from '@/components/Navigation/NavigationRail.vue' +import { useLeaveSession } from '@/composables/useLeaveSession' + +function mountRail() { + return mount( + { render: () => h(VLayout, () => [h(NavigationRail)]) }, + { + global: { + plugins: [createTestPinia(), createTestVuetify()], + stubs: { + VTooltip: defineComponent({ + setup: (_, { slots }) => () => slots.activator?.({ props: {} }), + }), + VDialog: true, + }, + }, + } + ) +} + +describe('NavigationRail view switching', () => { + beforeEach(() => { + vi.clearAllMocks() + qcDatastreamId.value = 'm-1' + resumeDatastreamId.value = 'm-1' + currentView.value = 'Edit' + selectedDrawer.value = 'Edit' + hasUnsavedChanges.value = true + }) + + it('Select keeps the session, even with unsaved edits', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-item-select"]').trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Select') + expect(qcDatastreamId.value).toBe('m-1') + expect(resumeDatastreamId.value).toBe('m-1') + expect(clearEditTarget).not.toHaveBeenCalled() + expect(wrapper.find('.v-dialog').exists()).toBe(false) + wrapper.unmount() + }) + + it('Edit returns to the editor from the Select view', async () => { + currentView.value = 'Select' + selectedDrawer.value = 'Select' + const wrapper = mountRail() + const editRail = wrapper.find('[data-testid="nav-rail-item-edit"]') + expect(editRail.attributes('aria-disabled')).toBe('false') + await editRail.trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Edit') + expect(qcDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) + + it('Edit stays disabled with no edit target', async () => { + qcDatastreamId.value = null + currentView.value = 'Select' + selectedDrawer.value = 'Select' + const wrapper = mountRail() + const editRail = wrapper.find('[data-testid="nav-rail-item-edit"]') + expect(editRail.attributes('aria-disabled')).toBe('true') + await editRail.trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Select') + wrapper.unmount() + }) +}) + +describe('NavigationRail leaving the editor', () => { + beforeEach(() => { + vi.clearAllMocks() + qcDatastreamId.value = 'm-1' + resumeDatastreamId.value = 'm-1' + currentView.value = 'Edit' + selectedDrawer.value = 'Edit' + hasUnsavedChanges.value = false + inProgressSession.value = null + hasSessionOperations.value = false + vi.stubGlobal('location', { ...window.location, assign }) + }) + + it('Home clears the resume pointer before reloading', async () => { + const wrapper = mountRail() + await wrapper.find('button[aria-label="Home"]').trigger('click') + await flushPromises() + + expect(resumeDatastreamId.value).toBeNull() + expect(qcDatastreamId.value).toBeNull() + expect(currentView.value).toBe('Select') + expect(assign).toHaveBeenCalledWith('/') + wrapper.unmount() + }) + + it('logging out ends the session first', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-logout"]').trigger('click') + await flushPromises() + + expect(resumeDatastreamId.value).toBeNull() + expect(qcDatastreamId.value).toBeNull() + // Logging out redirects; the session ends before it does. + expect(logout).toHaveBeenCalledWith('/browse') + wrapper.unmount() + }) + + // The router guard runs the leave flow for in-app navigation, so the rail + // only navigates. + it('switching workspace navigates and leaves the session to the guard', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-workspaces"]').trigger('click') + await flushPromises() + + expect(push).toHaveBeenCalledWith({ + name: 'Workspaces', + query: { switch: '1' }, + }) + expect(clearEditTarget).not.toHaveBeenCalled() + expect(resumeDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) + + it('asks about unsaved edits and stays put when the user cancels', async () => { + hasUnsavedChanges.value = true + inProgressSession.value = { id: 'qcs-1' } + const wrapper = mountRail() + await wrapper.find('button[aria-label="Home"]').trigger('click') + await flushPromises() + + const { leavePrompt, cancelLeave } = useLeaveSession() + expect(leavePrompt.value?.kind).toBe('unsaved') + expect(assign).not.toHaveBeenCalled() + + cancelLeave() + await flushPromises() + + expect(assign).not.toHaveBeenCalled() + expect(qcDatastreamId.value).toBe('m-1') + expect(resumeDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) +}) diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index c6d472de6..6242f6681 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -1,6 +1,6 @@