From 54002f5bde9b096f4dae8d99c97d58ecbd589264 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Tue, 24 Mar 2026 13:44:37 -0600 Subject: [PATCH 01/93] Resolves hydroserver2/hydroserver#371 --- .../src/components/Site/SiteForm.vue | 31 ++++++++++++---- .../data-management/src/pages/SiteDetails.vue | 3 +- apps/data-management/src/store/photos.ts | 9 +++-- tests/e2e/specs/sites.spec.ts | 35 +++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/data-management/src/components/Site/SiteForm.vue b/apps/data-management/src/components/Site/SiteForm.vue index 8a193cc5..61191cd2 100644 --- a/apps/data-management/src/components/Site/SiteForm.vue +++ b/apps/data-management/src/components/Site/SiteForm.vue @@ -213,9 +213,9 @@ const countryTitle = (item: { name: string; code: string } | undefined) => { } const { thing: storedThing } = storeToRefs(useThingStore()) -const { updatePhotos } = usePhotosStore() +const { updatePhotos, resetPendingPhotos } = usePhotosStore() const { updateRatingCurves, resetRatingCurves } = useRatingCurveStore() -const { tags } = storeToRefs(useTagStore()) +const { tags, previewTags } = storeToRefs(useTagStore()) const { updateTags } = useTagStore() const vocabularyStore = useVocabularyStore() @@ -230,6 +230,21 @@ const myForm = ref() const thing = reactive(new Thing()) const includeDataDisclaimer = ref(thing.dataDisclaimer !== '') +function cloneThing(source?: Thing) { + const clonedThing = source + ? JSON.parse(JSON.stringify(source)) + : new Thing() + + Object.assign(thing, new Thing(), clonedThing) + thing.location = Object.assign(new Thing().location, clonedThing.location ?? {}) +} + +function resetPendingSiteChanges() { + resetPendingPhotos() + resetRatingCurves() + previewTags.value = props.thingId ? [...tags.value] : [] +} + watch( () => includeDataDisclaimer.value, (newVal) => { @@ -241,12 +256,15 @@ watch( ) async function populateThing() { - Object.assign(thing, storedThing.value) + cloneThing(storedThing.value) if (thing.location.latitude && thing.location.longitude) loaded.value = true } function closeDialog() { - resetRatingCurves() + if (props.thingId) cloneThing(storedThing.value) + else cloneThing() + includeDataDisclaimer.value = !!thing.dataDisclaimer + resetPendingSiteChanges() emit('close') } @@ -296,17 +314,18 @@ function onMapLocationClicked(locationData: Thing) { } onMounted(async () => { - resetRatingCurves() + resetPendingSiteChanges() countries.value = countryList.getData() if (props.thingId) { await populateThing() includeDataDisclaimer.value = !!thing.dataDisclaimer } else { + cloneThing() loaded.value = true } }) onUnmounted(() => { - resetRatingCurves() + resetPendingSiteChanges() }) diff --git a/apps/data-management/src/pages/SiteDetails.vue b/apps/data-management/src/pages/SiteDetails.vue index 52c85dc2..15853b15 100644 --- a/apps/data-management/src/pages/SiteDetails.vue +++ b/apps/data-management/src/pages/SiteDetails.vue @@ -131,8 +131,9 @@ > Edit site information - + { const photosToDelete = ref([]) const loading = ref(false) + const resetPendingPhotos = () => { + newPhotos.value = [] + photosToDelete.value = [] + } + const uploadNewPhotos = async (thingId: string) => { if (!newPhotos.value.length) return @@ -44,8 +49,7 @@ export const usePhotosStore = defineStore('photos', () => { console.error('Error updating photos', error) } finally { loading.value = false - newPhotos.value = [] - photosToDelete.value = [] + resetPendingPhotos() } } @@ -54,6 +58,7 @@ export const usePhotosStore = defineStore('photos', () => { newPhotos, photosToDelete, loading, + resetPendingPhotos, updatePhotos, } }) diff --git a/tests/e2e/specs/sites.spec.ts b/tests/e2e/specs/sites.spec.ts index 9321e7fb..46c01e81 100644 --- a/tests/e2e/specs/sites.spec.ts +++ b/tests/e2e/specs/sites.spec.ts @@ -151,6 +151,41 @@ test.describe('sites and workspaces', () => { await expect(page).toHaveURL(/\/sites$/) }) + 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.things.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, From 73679ae8c64fffa03c02c8957b99af4984286e0e Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Wed, 25 Mar 2026 10:44:22 -0600 Subject: [PATCH 02/93] Mount app immediately then async load API data --- apps/data-management/src/App.vue | 44 +++-- .../__tests__/appInitialization.spec.ts | 181 ++++++++++++++++++ .../src/bootstrap/appInitialization.ts | 110 +++++++++++ .../DatastreamInformationPanels.vue | 24 +-- .../components/Workspace/WorkspaceToolbar.vue | 22 ++- apps/data-management/src/main.ts | 62 ++---- apps/data-management/src/pages/Sites.vue | 114 ++++++++--- .../src/pages/__tests__/Sites.spec.ts | 163 ++++++++++++++++ apps/data-management/src/router/router.ts | 7 + tests/e2e/specs/visualization.spec.ts | 18 +- tests/e2e/support/ui.ts | 15 +- 11 files changed, 657 insertions(+), 103 deletions(-) create mode 100644 apps/data-management/src/bootstrap/__tests__/appInitialization.spec.ts create mode 100644 apps/data-management/src/bootstrap/appInitialization.ts create mode 100644 apps/data-management/src/pages/__tests__/Sites.spec.ts diff --git a/apps/data-management/src/App.vue b/apps/data-management/src/App.vue index c1da9fb8..6785a972 100644 --- a/apps/data-management/src/App.vue +++ b/apps/data-management/src/App.vue @@ -1,24 +1,44 @@ diff --git a/apps/data-management/src/pages/__tests__/Sites.spec.ts b/apps/data-management/src/pages/__tests__/Sites.spec.ts new file mode 100644 index 00000000..22cbc83d --- /dev/null +++ b/apps/data-management/src/pages/__tests__/Sites.spec.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, shallowMount } from '@vue/test-utils' +import { useWorkspaceStore } from '@/store/workspaces' + +const { + hasBootstrappedWorkspacesState, + isAppInitializingState, + listAllItemsMock, + listThingSiteSummariesMock, + routerPushMock, +} = + vi.hoisted(() => ({ + hasBootstrappedWorkspacesState: { value: false }, + isAppInitializingState: { value: false }, + listAllItemsMock: vi.fn(), + listThingSiteSummariesMock: vi.fn(), + routerPushMock: vi.fn(), + })) + +vi.mock('@hydroserver/client', () => ({ + User: class User { + email = '' + }, + PermissionAction: { + Create: 'Create', + }, + PermissionResource: { + Thing: 'Thing', + }, + default: { + workspaces: { + listAllItems: listAllItemsMock, + }, + }, +})) + +vi.mock('@/api/thingSiteSummaries', () => ({ + listThingSiteSummaries: listThingSiteSummariesMock, +})) + +vi.mock('@/bootstrap/appInitialization', () => ({ + hasBootstrappedWorkspaces: hasBootstrappedWorkspacesState, + isAppInitializing: isAppInitializingState, + startAppInitialization: vi.fn(), +})) + +vi.mock('@/components/Maps/OpenLayersMap.vue', () => ({ + default: { + name: 'OpenLayersMap', + template: '
', + }, +})) + +vi.mock('@/components/Site/SiteForm.vue', () => ({ + default: { + name: 'SiteForm', + template: '
', + }, +})) + +vi.mock('@/components/Site/SiteFilterToolbar.vue', () => ({ + default: { + name: 'SiteFilterToolbar', + template: '
', + }, +})) + +vi.mock('@/components/Workspace/WorkspaceToolbar.vue', () => ({ + default: { + name: 'WorkspaceToolbar', + template: '
', + }, +})) + +vi.mock('@/components/base/FullScreenLoader.vue', () => ({ + default: { + name: 'FullScreenLoader', + template: '
', + }, +})) + +vi.mock('@/utils/maps/markers', () => ({ + addColorToMarkers: (things: unknown[]) => things, +})) + +vi.mock('@/composables/useWorkspacePermissions', () => ({ + useWorkspacePermissions: () => ({ + hasPermission: () => true, + }), +})) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useRouter: () => ({ + push: routerPushMock, + }), + } +}) + +import Sites from '../Sites.vue' + +const mountSites = () => + shallowMount(Sites, { + global: { + stubs: { + 'v-btn-add': true, + }, + }, + }) + +describe('Sites page', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + vi.restoreAllMocks() + hasBootstrappedWorkspacesState.value = false + isAppInitializingState.value = false + listAllItemsMock.mockReset() + listThingSiteSummariesMock.mockReset() + routerPushMock.mockReset() + }) + + it('clears a stale workspace selection without requesting site summaries', async () => { + const workspaceStore = useWorkspaceStore() + workspaceStore.selectedWorkspace = { + id: 'stale-workspace', + name: 'Stale workspace', + isPrivate: false, + } as any + + listAllItemsMock.mockResolvedValue([]) + + const wrapper = mountSites() + + await flushPromises() + + expect(listThingSiteSummariesMock).not.toHaveBeenCalled() + expect(workspaceStore.selectedWorkspace).toBeNull() + }) + + it('loads site summaries once after bootstrapped workspaces are available', async () => { + const workspaceStore = useWorkspaceStore() + workspaceStore.selectedWorkspace = { + id: 'workspace-1', + name: 'Workspace 1', + isPrivate: false, + } as any + + hasBootstrappedWorkspacesState.value = true + listThingSiteSummariesMock.mockResolvedValue([]) + + mountSites() + + await flushPromises() + + expect(listThingSiteSummariesMock).toHaveBeenCalledTimes(1) + expect(listThingSiteSummariesMock).toHaveBeenCalledWith('workspace-1') + }) +}) diff --git a/apps/data-management/src/router/router.ts b/apps/data-management/src/router/router.ts index 76aa68e6..24a84657 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' @@ -30,6 +34,9 @@ function updateDocumentTitle(matched: RouteRecordNormalized[]): void { router.beforeEach( async (to: RouteLocationNormalized, from: RouteLocationNormalized) => { + await waitForHydroServerInitialization() + if (!isHydroServerReady.value) return false + const { inEmailVerificationFlow, inProviderSignupFlow } = hs.session if (inEmailVerificationFlow && to.name !== 'VerifyEmail') { diff --git a/tests/e2e/specs/visualization.spec.ts b/tests/e2e/specs/visualization.spec.ts index 9ea9aa73..0454c63e 100644 --- a/tests/e2e/specs/visualization.spec.ts +++ b/tests/e2e/specs/visualization.spec.ts @@ -11,7 +11,23 @@ test.describe('visualization', () => { async function ensureFiltersDrawerOpen(page: Page) { const workspaceFilter = page.getByRole('combobox', { name: 'Workspaces' }).first() - if ((await workspaceFilter.count()) > 0 && (await workspaceFilter.isVisible())) { + const viewport = page.viewportSize() + const box = await workspaceFilter.boundingBox() + const isWithinViewport = + !!box && + !!viewport && + box.width > 0 && + box.height > 0 && + box.x + box.width > 0 && + box.y + box.height > 0 && + box.x < viewport.width && + box.y < viewport.height + + if ( + (await workspaceFilter.count()) > 0 && + (await workspaceFilter.isVisible()) && + isWithinViewport + ) { return } diff --git a/tests/e2e/support/ui.ts b/tests/e2e/support/ui.ts index f8940b9b..4638f9a5 100644 --- a/tests/e2e/support/ui.ts +++ b/tests/e2e/support/ui.ts @@ -29,8 +29,19 @@ export async function chooseAutocompleteOption( optionText: string ) { const field = page.getByRole('combobox', { name: label }).first() - await field.click() - await field.fill(optionText) + const container = field.locator('xpath=ancestor::*[@role="combobox"][1]') + await expect(field).toBeVisible() + await container.scrollIntoViewIfNeeded() + const toggle = container.getByRole('button').last() + if ((await toggle.count()) > 0) { + await toggle.click({ force: true }) + } + await field.evaluate((element) => { + ;(element as HTMLInputElement).focus() + }) + await page.keyboard.press('Control+A') + await page.keyboard.press('Backspace') + await page.keyboard.type(optionText) await chooseOverlayOption(page, optionText) } From a848a1a1543a27c4e1af7aa1c5e35b1a5ce41441 Mon Sep 17 00:00:00 2001 From: Maurier Date: Mon, 22 Jun 2026 12:53:36 -0600 Subject: [PATCH 03/93] feat: add QC session editing and history integration --- apps/qc-app/docs/API_REFERENCE.md | 78 +++++ apps/qc-app/docs/ARCHITECTURE.md | 49 +++ .../EditData/CreateDatastreamForm.vue | 128 +++++++ .../src/components/EditData/EditHistory.vue | 3 + .../src/components/EditData/SessionList.vue | 112 ++++++ .../components/EditData/StartSessionForm.vue | 88 +++++ .../__tests__/CreateDatastreamForm.spec.ts | 90 +++++ .../EditData/__tests__/SessionList.spec.ts | 101 ++++++ .../__tests__/StartSessionForm.spec.ts | 54 +++ apps/qc-app/src/components/VisualizeData.vue | 329 +++++++++++++----- .../useCreateManagedDatastream.spec.ts | 72 ++++ .../__tests__/useEditSession.spec.ts | 175 ++++++++++ .../__tests__/useWorkspacePermissions.spec.ts | 114 ++++++ .../composables/useCreateManagedDatastream.ts | 35 ++ apps/qc-app/src/composables/useEditSession.ts | 160 +++++++++ .../qc-app/src/composables/useQcSubmission.ts | 22 +- .../composables/useWorkspacePermissions.ts | 109 ++++++ apps/qc-app/src/pages/Workspaces.vue | 36 +- .../__tests__/commitSession.spec.ts | 85 +++++ .../__tests__/createManagedDatastream.spec.ts | 167 +++++++++ .../__tests__/findHistory.spec.ts | 25 ++ .../__tests__/observationsBody.spec.ts | 20 ++ .../__tests__/persistOperations.spec.ts | 100 ++++++ .../qualityControl/__tests__/qcServiceFake.ts | 245 +++++++++++++ .../__tests__/reconstructSession.spec.ts | 127 +++++++ .../qualityControl/__tests__/session.spec.ts | 85 +++++ .../services/qualityControl/commitSession.ts | 56 +++ .../qualityControl/createManagedDatastream.ts | 124 +++++++ .../services/qualityControl/findHistory.ts | 25 ++ .../src/services/qualityControl/index.ts | 19 + .../qualityControl/observationsBody.ts | 24 ++ .../qualityControl/persistOperations.ts | 84 +++++ .../qualityControl/reconstructSession.ts | 120 +++++++ .../src/services/qualityControl/session.ts | 73 ++++ .../src/services/qualityControl/unwrap.ts | 12 + .../src/store/__tests__/qcSession.spec.ts | 104 ++++++ apps/qc-app/src/store/qcPreferences.ts | 24 ++ apps/qc-app/src/store/qcSession.ts | 97 ++++++ 38 files changed, 3260 insertions(+), 111 deletions(-) create mode 100644 apps/qc-app/src/components/EditData/CreateDatastreamForm.vue create mode 100644 apps/qc-app/src/components/EditData/SessionList.vue create mode 100644 apps/qc-app/src/components/EditData/StartSessionForm.vue create mode 100644 apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts create mode 100644 apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts create mode 100644 apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useEditSession.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts create mode 100644 apps/qc-app/src/composables/useCreateManagedDatastream.ts create mode 100644 apps/qc-app/src/composables/useEditSession.ts create mode 100644 apps/qc-app/src/composables/useWorkspacePermissions.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/commitSession.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/createManagedDatastream.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/findHistory.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/observationsBody.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/persistOperations.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/qcServiceFake.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/reconstructSession.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/session.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/commitSession.ts create mode 100644 apps/qc-app/src/services/qualityControl/createManagedDatastream.ts create mode 100644 apps/qc-app/src/services/qualityControl/findHistory.ts create mode 100644 apps/qc-app/src/services/qualityControl/index.ts create mode 100644 apps/qc-app/src/services/qualityControl/observationsBody.ts create mode 100644 apps/qc-app/src/services/qualityControl/persistOperations.ts create mode 100644 apps/qc-app/src/services/qualityControl/reconstructSession.ts create mode 100644 apps/qc-app/src/services/qualityControl/session.ts create mode 100644 apps/qc-app/src/services/qualityControl/unwrap.ts create mode 100644 apps/qc-app/src/store/__tests__/qcSession.spec.ts create mode 100644 apps/qc-app/src/store/qcPreferences.ts create mode 100644 apps/qc-app/src/store/qcSession.ts diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index 3fdf5069..f00930d0 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -140,6 +140,53 @@ history), serializes `[phenomenonTime, result]` rows, POSTs with `mode: 'replace'`, surfaces a Snackbar, and clears the history in place on success. +### `useEditSession()` + +Orchestrates the server-backed QC session workflow against the +`services/qualityControl/` glue: + +```ts +const { beginEditing, startSession, saveDraft, commit, needsSession, needsHistory } = + useEditSession() +``` + +- `beginEditing()` — resolves the QC history for the QC datastream, loads + its sessions, resumes the in-progress one (or sets `needsSession`); sets + `needsHistory` when the datastream isn't QC-managed yet. +- `startSession(spec)` — creates a session and copies the source window in. +- `saveDraft()` — persists the record's edit operations to the session + (append-only reconcile). +- `commit()` — saves, verifies checksum C, pushes observations + (`mode: 'replace'`), then locks the session. + +### `useCreateManagedDatastream()` + +```ts +const { create } = useCreateManagedDatastream() +const { managedDatastream, history } = await create({ source, processingLevelId, name }) +``` + +Delegates to the tested `createManagedDatastream` orchestration with the +live client (`hs.datastreams` + `hs.qualityControlHistories`). + +### `useWorkspacePermissions()` + +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 { canEdit, canCreateDatastream, roleName, isOwner, can } = + useWorkspacePermissions() +canEdit() // selected workspace: can run the QC edit flow? +canCreateDatastream(ws) // can create the managed datastream here? +roleName(ws) // 'Owner' | | 'Admin' | 'Read-only' +``` + +Used to disable the editor's Start editing / Save / Commit / Create +controls and to mark each workspace's role on the picker. + ### `useResizable()` Generic pointer-drag-resize hook. Used by `SelectDrawer`, `EditDrawer`, @@ -411,6 +458,37 @@ 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. | +| `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. | +| `isLoading` | state | `boolean` | True while `loadSessions` is in flight. | +| `isReadOnly` | computed | `boolean` | True unless viewing the in-progress session. | +| `inProgressSession` | computed | `QualityControlSession \| null` | The editable session, if any. | +| `committedSessions` | computed | `QualityControlSession[]` | Sessions with status `committed`. | +| `viewedSession` | computed | `QualityControlSession \| null` | The session for `viewedSessionId`. | +| `loadSessions` | action | `(historyId: string) => Promise` | Load a history's sessions; default the view to the in-progress one. | +| `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 editing preferences. Persistence: key `qc:preferences:v1`, +`pick: ['processingLevelId']`. + +| 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). | + ## Internal: utilities ### `src/utils/plotting/plotly.ts` (barrel) diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index 661c0dd1..0656d1be 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -220,6 +220,55 @@ itself has zero Vue / Pinia / Plotly dependencies. The contract: Side-stepping `dispatch` breaks undo / redo, breaks QC History export, and silently breaks the worker fast-path. Don't. +## QC history / session service + +Editing is persisted as a session DAG through the HydroServer QC API +(`/api/data/quality-control/histories/{id}/sessions/{id}/operations`). + +The API client itself lives in **`@hydroserver/client`**, split across three +SDK services on the `hs` instance: `qualityControlHistories`, +`qualityControlSessions` (with `commit`), and `qualityControlOperations`. They +are normal SDK services built on the shared `apiMethods` layer, so they inherit +the session auth (CSRF cookie -> `X-CSRFToken`, `credentials: 'include'`) and +the `ApiResponse` return shape — methods never throw on HTTP errors. Bodies are +camelCase (`by_alias`); query parameters are snake_case (`expand_related`, +`range_start`, `managed_datastream_id`, `ancestor_of`, ...). + +`src/services/qualityControl/` holds only the **app-side orchestration** that +composes those services with `@uwrl/qc-utils` and the datastream/observation +APIs: `createManagedDatastream`, the session lifecycle (`session.ts`), +`persistOperations`, `commitSession`, `reconstructSession`, `findHistory`, and +the `observationsBulkBody` serializer. `unwrap` bridges `ApiResponse` to the +thrown errors this glue surfaces. None of it is a transport — swapping the QC +client out is a `@hydroserver/client` change, not an app one. + +Two contract notes worth keeping in mind: + +- **The backend stores the operation DAG as metadata only — it never replays + operations.** The app applies ops locally (qc-utils), pushes the edited + series to the managed datastream via `bulk-create` (replace mode), then calls + `/commit`, which only records checksums and extends the history window. + Checksum verification (source/managed) is the client's responsibility; + `/commit` performs none. +- **Vocabulary differs across the boundary.** qc-utils serializes operations as + `{ method, args }`; the QC API speaks `{ operationType, arguments, order }`. + The enum values are identical, so `persistOperations`/`reconstructSession` + rename the fields when crossing between qc-utils and the API. + +Tests stub the three services with `makeQcFake()` (a stateful in-memory double +under `services/qualityControl/__tests__/` that returns +`{ histories, sessions, operations }`) — the production client lives in the +package, not the app. + +**Permission gating.** QC editing writes to the source datastream's workspace +(creates the managed datastream, pushes observations), so the editor's entry +points are gated on the signed-in user's workspace role via +`useWorkspacePermissions()` — a read-only collaborator sees disabled Start +editing / Save / Commit controls and an explanation instead of a mid-flow 403, +and each workspace's role is marked on the picker. The role rides along on the +`Workspace` object (`collaboratorRole.permissions`; owners have a null role; +admins override), so no extra request is needed. + ## Routing and auth vue-router 5, two routes (Home, Workspaces). Two guards run on diff --git a/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue new file mode 100644 index 00000000..bade4760 --- /dev/null +++ b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue @@ -0,0 +1,128 @@ + + + diff --git a/apps/qc-app/src/components/EditData/EditHistory.vue b/apps/qc-app/src/components/EditData/EditHistory.vue index 84802f5a..ffee4082 100644 --- a/apps/qc-app/src/components/EditData/EditHistory.vue +++ b/apps/qc-app/src/components/EditData/EditHistory.vue @@ -122,6 +122,8 @@ class="flex-grow-1 overflow-y-auto pa-2" style="min-height: 0" > + +
+
+
+ + Sessions + + + Return to current + +
+ + + +
+ No sessions yet. +
+ + + + + + + {{ sessionLabel(session) }} + + + + + +
+ + + + + diff --git a/apps/qc-app/src/components/EditData/StartSessionForm.vue b/apps/qc-app/src/components/EditData/StartSessionForm.vue new file mode 100644 index 00000000..5f4c2e1b --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartSessionForm.vue @@ -0,0 +1,88 @@ + + + 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 00000000..0a3fa445 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } 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', processingLevelId: 'pl-raw' } as any + +const processingLevels = [ + { id: 'pl-raw', definition: 'Raw' }, + { id: 'pl-qc', definition: 'Quality Controlled' }, +] + +const mountForm = () => + mount(CreateDatastreamForm, { + props: { source, processingLevels }, + global: { plugins: [createTestVuetify()] }, + }) + +const levelSelect = (w: ReturnType) => + w.findComponent({ name: 'VSelect' }) + +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 name 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 + } + expect(spec.source.id).toBe('s-1') + expect(spec.processingLevelId).toBe('pl-qc') + expect(spec.name).toBe('Raw Temp (QC)') + }) + + it('uses the provided default processing level', async () => { + const w = mount(CreateDatastreamForm, { + props: { source, processingLevels, defaultProcessingLevelId: 'pl-qc' }, + global: { plugins: [createTestVuetify()] }, + }) + // 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('blocks create and shows a warning when permissionError is set', async () => { + const w = mount(CreateDatastreamForm, { + props: { + source, + processingLevels, + defaultProcessingLevelId: 'pl-qc', // otherwise valid + permissionError: 'You cannot create datastreams here.', + }, + global: { plugins: [createTestVuetify()] }, + }) + 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__/SessionList.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts new file mode 100644 index 00000000..20a43c79 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts @@ -0,0 +1,101 @@ +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()] } }) + +/** 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() + await store.loadSessions(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('clicking a committed session views it read-only', async () => { + const { store, committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + expect(store.viewedSessionId).toBe(committedId) + expect(store.isReadOnly).toBe(true) + }) + + it('clicking the current session returns to editing', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + expect(store.isReadOnly).toBe(true) + await wrapper.find(`[data-testid="session-${inProgressId}"]`).trigger('click') + expect(store.viewedSessionId).toBe(inProgressId) + expect(store.isReadOnly).toBe(false) + }) + + it('shows a "Return to current" control while viewing read-only', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(false) + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts new file mode 100644 index 00000000..0ad30459 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts @@ -0,0 +1,54 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import StartSessionForm from '@/components/EditData/StartSessionForm.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const mountForm = () => + mount(StartSessionForm, { global: { plugins: [createTestVuetify()] } }) + +describe('StartSessionForm', () => { + it('disables confirm until a valid range is entered', async () => { + const wrapper = mountForm() + const confirm = () => wrapper.find('[data-testid="session-confirm"]') + expect(confirm().attributes('disabled')).toBeDefined() + + await wrapper.find('[data-testid="session-start"] input').setValue('2025-02-01T00:00') + await wrapper.find('[data-testid="session-end"] input').setValue('2025-01-01T00:00') // before start + expect(confirm().attributes('disabled')).toBeDefined() + + await wrapper.find('[data-testid="session-end"] input').setValue('2025-03-01T00:00') // after start + expect(confirm().attributes('disabled')).toBeUndefined() + }) + + it('emits the session spec on confirm', async () => { + const wrapper = mountForm() + await wrapper.find('[data-testid="session-start"] input').setValue('2025-01-01T00:00') + await wrapper.find('[data-testid="session-end"] input').setValue('2025-02-01T00:00') + await wrapper.find('[data-testid="session-description"] textarea').setValue('Jan QC') + await wrapper.find('[data-testid="session-confirm"]').trigger('click') + + const emitted = wrapper.emitted('confirm') + expect(emitted).toHaveLength(1) + const spec = emitted![0][0] as { + phenomenonTimeStart: string + phenomenonTimeEnd: string + description?: string + } + expect(typeof spec.phenomenonTimeStart).toBe('string') + expect(new Date(spec.phenomenonTimeEnd).getTime()).toBeGreaterThan( + new Date(spec.phenomenonTimeStart).getTime() + ) + expect(spec.description).toBe('Jan QC') + }) + + it('emits cancel', async () => { + const wrapper = mountForm() + await wrapper.find('[data-testid="session-cancel"]').trigger('click') + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) +}) diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index 51bb7eba..f893b36d 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -25,6 +25,18 @@
+ + {{ workspaceRole }} + + - - Start editing - + +
@@ -80,6 +103,23 @@ + + + + + +
Save @@ -194,19 +234,31 @@ variant="tonal" color="primary" prepend-icon="mdi-content-save-move-outline" - :disabled="!editCount || isUpdating || isSubmitting" - :loading="isSubmitting && exitIntent === 'save-close'" - @click="requestSaveAndClose" + :disabled="saveDisabled" + @click="onSaveAndClose" > Save & Close + + Commit + Close @@ -307,36 +359,36 @@ - +
- - + +
-
Submit QC observations?
+
Commit session to datastream?
- {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} pending + Materializes this session into the managed datastream
- This will - overwrite existing server observations in the - submitted time range (replace mode). This action cannot be undone. + The source data's integrity is verified, then the edited observations + replace the managed datastream over this session's + range and the session is locked into the history. - Cancel + Cancel - Submit + Commit
@@ -345,35 +397,43 @@
- - + +
-
Discard unsaved edits?
+
Save before closing?
- {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} will be lost + {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} not yet saved to the session
- Closing will leave the editor without submitting your changes. - Discarded edits cannot be recovered. + Save your edits to the in-progress session before closing, or close + without saving (unsaved changes are dropped; previously-saved draft + operations stay in the session). Cancel + Close without saving - Discard & close + Save & close
+ + + + + +
@@ -391,13 +451,23 @@ import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import PlottedDatastreams from './VisualizeData/PlottedDatastreams.vue' import { usePlotlyStore } from '@/store/plotly' -import { useQcSubmission } from '@/composables/useQcSubmission' +import { useEditSession } from '@/composables/useEditSession' +import { useQcSessionStore } from '@/store/qcSession' +import { useQcPreferencesStore } from '@/store/qcPreferences' +import StartSessionForm from '@/components/EditData/StartSessionForm.vue' +import CreateDatastreamForm from '@/components/EditData/CreateDatastreamForm.vue' +import { + useCreateManagedDatastream, + type CreateManagedDatastreamSpec, +} from '@/composables/useCreateManagedDatastream' +import { useWorkspacePermissions } from '@/composables/useWorkspacePermissions' +import type { QualityControlSessionContract } from '@hydroserver/client' +import { Snackbar } from '@uwrl/qc-utils' import { decodeShareState, encodeShareState, type ShareState, } from '@/utils/share' -import { useDataSelection } from '@/composables/useDataSelection' import { useWorkspaceStore } from '@/store/workspaces' import { useResizable, usePersistedFlag } from '@/composables/useResizable' @@ -406,6 +476,7 @@ const { plottedDatastreams, qcDatastream, datastreams, + processingLevels, things, beginDate, endDate, @@ -421,8 +492,6 @@ const { selectedWorkspaceId } = storeToRefs(useWorkspaceStore()) const { editHistory, isUpdating, - isSubmitting, - selectedSeries, activeTab, hiddenTraceIds, hiddenAxisIds, @@ -433,14 +502,60 @@ const { tooltipsMaxDataPoints, } = storeToRefs(usePlotlyStore()) const { redraw } = usePlotlyStore() -const { refreshGraphSeriesArray, setPlottedDatastreams } = useDataVisStore() -const { clearSelected } = useDataSelection() -const { submitQcEdits } = useQcSubmission() +const { setPlottedDatastreams } = useDataVisStore() + +const { + beginEditing, + startSession, + saveDraft, + commit, + needsSession, + needsHistory, +} = useEditSession() +const qcSessionStore = useQcSessionStore() +const { isReadOnly, inProgressSession } = storeToRefs(qcSessionStore) +const { create: createManaged } = useCreateManagedDatastream() +const qcPreferences = useQcPreferencesStore() +const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + +type QcSessionPostBody = QualityControlSessionContract.PostBody + +// Permission gating: QC editing writes to the selected workspace (creates +// the managed datastream, pushes observations). Gate the editor entry +// points so a read-only collaborator gets a clear disabled state and an +// explanation instead of a 403 mid-flow. +const canEditWorkspace = computed(() => canEdit()) +const canCreateDatastreamHere = computed(() => canCreateDatastream()) +const workspaceRole = computed(() => roleName()) const editCount = computed(() => editHistory.value?.length ?? 0) -const showSaveConfirm = ref(false) +const showCommitConfirm = ref(false) const showCloseConfirm = ref(false) -const exitIntent = ref<'save' | 'save-close' | null>(null) +const showStartSession = ref(false) +const showCreateDatastream = ref(false) +const isSavingDraft = ref(false) +const isCommitting = ref(false) +const isCreating = ref(false) + +const saveDisabled = computed( + () => + !canEditWorkspace.value || + isReadOnly.value || + !inProgressSession.value || + !editCount.value || + isUpdating.value || + isSavingDraft.value || + isCommitting.value +) +const commitDisabled = computed( + () => + !canEditWorkspace.value || + isReadOnly.value || + !inProgressSession.value || + isUpdating.value || + isSavingDraft.value || + isCommitting.value +) // --- Editor layout: sidebar sizes + collapse flags ------------------ // Persisted to localStorage so the user's preferred layout survives @@ -525,22 +640,73 @@ function exitToSelect() { isDrawerOpen.value = true } -function requestSave() { - exitIntent.value = 'save' - showSaveConfirm.value = true +async function onStartSession(spec: QcSessionPostBody) { + showStartSession.value = false + try { + await startSession(spec) + await redraw() + Snackbar.success('Edit session started.') + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not start the session.') + } +} + +function cancelStartSession() { + showStartSession.value = false + exitToSelect() +} + +async function onCreateDatastream(spec: CreateManagedDatastreamSpec) { + showCreateDatastream.value = false + qcPreferences.processingLevelId = spec.processingLevelId + isCreating.value = true + try { + const { managedDatastream } = await createManaged(spec) + await setPlottedDatastreams( + [...plottedDatastreams.value, managedDatastream], + managedDatastream.id + ) + Snackbar.success('Managed datastream created.') + await goToEdit() + } catch (e) { + Snackbar.error( + e instanceof Error ? e.message : 'Could not create the datastream.' + ) + } finally { + isCreating.value = false + } +} + +async function onSaveDraft(): Promise { + isSavingDraft.value = true + try { + await saveDraft() + Snackbar.success('Draft saved.') + return true + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not save the draft.') + return false + } finally { + isSavingDraft.value = false + } } -function requestSaveAndClose() { - exitIntent.value = 'save-close' - showSaveConfirm.value = true +async function onSaveAndClose() { + if (await onSaveDraft()) exitToSelect() } -async function confirmSave() { - const intent = exitIntent.value - showSaveConfirm.value = false - await submitQcEdits() - if (intent === 'save-close') exitToSelect() - exitIntent.value = null +async function onCommit() { + showCommitConfirm.value = false + isCommitting.value = true + try { + await commit() + await redraw() + Snackbar.success('Session committed.') + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not commit the session.') + } finally { + isCommitting.value = false + } } function requestClose() { @@ -551,25 +717,13 @@ function requestClose() { } } -async function discardEdits() { - if (!editCount.value) return - isUpdating.value = true - try { - // In-place clear so the `editHistory` ref keeps tracking the - // same array (reassigning `history = []` detaches it). - if (selectedSeries.value) selectedSeries.value.data.history.length = 0 - await refreshGraphSeriesArray() - await selectedSeries.value?.data.reload() - await clearSelected({ recordHistory: false }) - await redraw() - } finally { - isUpdating.value = false - } +async function saveDraftAndClose() { + showCloseConfirm.value = false + await onSaveAndClose() } -async function confirmClose() { +function closeWithoutSaving() { showCloseConfirm.value = false - await discardEdits() exitToSelect() } @@ -765,10 +919,23 @@ onUnmounted(() => { resetState() }) -function goToEdit() { - currentView.value = DrawerType.Edit - selectedDrawer.value = DrawerType.Edit - isDrawerOpen.value = true +async function goToEdit() { + try { + await beginEditing() + if (needsHistory.value) { + // Not a managed datastream yet: create one from it (as source). + showCreateDatastream.value = true + return + } + currentView.value = DrawerType.Edit + selectedDrawer.value = DrawerType.Edit + isDrawerOpen.value = true + if (needsSession.value) showStartSession.value = true + } catch (e) { + Snackbar.error( + e instanceof Error ? e.message : 'Could not start editing this datastream.' + ) + } } diff --git a/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts b/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts new file mode 100644 index 00000000..33a8a0cd --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { unwrap } from '@/services/qualityControl/unwrap' + +const hsCreate = vi.fn() +const createTag = vi.fn() +const hs = ref({ datastreams: { create: hsCreate, createTag } }) +vi.mock('@/store/hydroserver', () => ({ + useHydroServer: () => ({ hs }), +})) + +const makeSource = (overrides: Record = {}) => + ({ + id: 'source-1', + name: 'Raw', + processingLevelId: 'pl-raw', + thingId: 't-1', + unitId: 'u-1', + observedPropertyId: 'op-1', + sensorId: 'sn-1', + ...overrides, + }) as any + +let qc: ReturnType + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + qc = makeQcFake() + hs.value = { + datastreams: { create: hsCreate, createTag }, + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } + hsCreate.mockResolvedValue({ + data: { id: 'managed-1', name: 'Raw (QC)', processingLevelId: 'pl-qc' }, + ok: true, + status: 201, + message: '', + }) + createTag.mockResolvedValue({}) +}) + +describe('useCreateManagedDatastream', () => { + it('creates the datastream, the history, and tags the source', async () => { + const { useCreateManagedDatastream } = await import( + '@/composables/useCreateManagedDatastream' + ) + const { create } = useCreateManagedDatastream() + const result = await create({ + source: makeSource(), + processingLevelId: 'pl-qc', + name: 'My QC', + }) + + const body = hsCreate.mock.calls[0][0] + expect(body.processingLevelId).toBe('pl-qc') + expect(body.name).toBe('My QC') + expect(body.valueCount).toBe(0) + + expect(result.history.managedDatastream.id).toBe('managed-1') + expect(unwrap(await qc.histories.list())).toHaveLength(1) + + expect(createTag).toHaveBeenCalledWith( + 'source-1', + expect.objectContaining({ value: 'managed-1' }) + ) + }) +}) diff --git a/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts b/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts new file mode 100644 index 00000000..d5cfdd08 --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { unwrap } from '@/services/qualityControl/unwrap' + +const qcDatastream = ref(null) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ qcDatastream }), +})) + +const selectedSeries = ref(null) +vi.mock('@/store/plotly', () => ({ + usePlotlyStore: () => ({ selectedSeries }), +})) + +const getItem = vi.fn() +const createObservations = vi.fn() +const hs = ref({ datastreams: { getItem, createObservations } }) +vi.mock('@/store/hydroserver', () => ({ + useHydroServer: () => ({ hs }), +})) + +const fetchObservationsInRange = vi.fn() +vi.mock('@/store/observations', () => ({ + useObservationStore: () => ({ fetchObservationsInRange }), +})) + +// qc-utils is only used at runtime by the composable (the service layer +// imports types only), so stub serializeHistory/applyHistory here. +vi.mock('@uwrl/qc-utils', () => ({ + serializeHistory: vi.fn((record: any, window: any) => ({ + version: '1', + createdAt: '2025-01-01T00:00:00Z', + window, + operations: (record.history ?? []).map((h: any) => ({ + method: h.method, + args: h.args ?? [], + })), + })), + applyHistory: vi.fn(async () => ({ applied: 0, failed: [] })), +})) + +import { useQcSessionStore } from '@/store/qcSession' + +const WIN = { + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', +} + +const makeRecord = (history: any[] = []) => ({ + history, + dataX: [Date.UTC(2025, 0, 1)], + dataY: [10], +}) + +let qc: ReturnType + +const wireHs = () => { + hs.value = { + datastreams: { getItem, createObservations }, + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + qc = makeQcFake() + wireHs() + qcDatastream.value = { id: 'm-1' } + selectedSeries.value = { data: makeRecord() } + getItem.mockResolvedValue({ id: 's-1', name: 'Source' }) + createObservations.mockResolvedValue(undefined) + fetchObservationsInRange.mockResolvedValue(makeRecord()) +}) + +const seedHistory = async () => { + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) +} + +describe('useEditSession', () => { + it('beginEditing resolves the history and needs a session when none is in progress', async () => { + await seedHistory() + const { useEditSession } = await import('@/composables/useEditSession') + const { beginEditing, needsSession, sourceDatastream } = useEditSession() + await beginEditing() + expect(sourceDatastream.value?.id).toBe('s-1') + expect(needsSession.value).toBe(true) + expect(useQcSessionStore().historyId).toBeTruthy() + }) + + it('beginEditing flags needsHistory when the datastream is not a managed one', async () => { + const { useEditSession } = await import('@/composables/useEditSession') + const { beginEditing, needsHistory, needsSession } = useEditSession() + await beginEditing() + expect(needsHistory.value).toBe(true) + expect(needsSession.value).toBe(false) + }) + + it('beginEditing resumes an in-progress session (replays via applyHistory)', async () => { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + await qc.sessions.create(h.id, WIN) + const { useEditSession } = await import('@/composables/useEditSession') + const qcUtils = await import('@uwrl/qc-utils') + const { beginEditing, needsSession } = useEditSession() + await beginEditing() + expect(needsSession.value).toBe(false) + expect(qcUtils.applyHistory).toHaveBeenCalled() + }) + + it('startSession creates a session and copies the source window', async () => { + await seedHistory() + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession({ ...WIN, description: 'Jan' }) + expect(session.needsSession.value).toBe(false) + expect(useQcSessionStore().inProgressSession?.description).toBe('Jan') + expect(fetchObservationsInRange.mock.calls.at(-1)?.[0].id).toBe('s-1') + }) + + it('saveDraft persists the record operations to the session', async () => { + await seedHistory() + selectedSeries.value = { + data: makeRecord([ + { method: 'VALUE_THRESHOLD', args: [] }, + { method: 'DELETE_POINTS', args: [] }, + ]), + } + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession(WIN) + await session.saveDraft() + + const store = useQcSessionStore() + const ops = unwrap( + await qc.operations.list(store.historyId!, store.inProgressSession!.id) + ) + expect(ops.map((o) => o.operationType)).toEqual([ + 'VALUE_THRESHOLD', + 'DELETE_POINTS', + ]) + }) + + it('commit pushes observations in replace mode and locks the session', async () => { + await seedHistory() + selectedSeries.value = { data: makeRecord([{ method: 'VALUE_THRESHOLD', args: [] }]) } + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession(WIN) + await session.commit() + + expect(createObservations).toHaveBeenCalledWith( + 'm-1', + expect.objectContaining({ fields: ['phenomenonTime', 'result'] }), + { mode: 'replace' } + ) + const store = useQcSessionStore() + expect(store.committedSessions.length).toBe(1) + expect(store.inProgressSession).toBeNull() + }) +}) diff --git a/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts b/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts new file mode 100644 index 00000000..310eed3d --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { + PermissionAction, + PermissionResource, + type Workspace, + type User, +} from '@hydroserver/client' +import { useWorkspacePermissions } from '../useWorkspacePermissions' +import { useWorkspaceStore } from '@/store/workspaces' +import { useUserStore } from '@/store/user' + +function ws(partial: Partial): Workspace { + return { + id: 'w', + name: 'W', + isPrivate: false, + owner: null, + collaboratorRole: null, + ...partial, + } as Workspace +} + +const role = (name: string, permissions: { action: string; resource: string }[]) => + ({ name, permissions }) as any + +const ownerWs = ws({ owner: { email: 'me@x.org', name: 'Me' } as any }) +const editorWs = ws({ + id: 'ed', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Editor', [ + { action: PermissionAction.Create, resource: PermissionResource.Datastream }, + { action: PermissionAction.Edit, resource: PermissionResource.Observation }, + ]), +}) +const viewerWs = ws({ + id: 'vw', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Viewer', [ + { action: PermissionAction.View, resource: PermissionResource.Datastream }, + ]), +}) +const globalWs = ws({ + id: 'gl', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Super', [ + { action: PermissionAction.Global, resource: PermissionResource.Global }, + ]), +}) + +function setUser(email: string, accountType = 'standard') { + useUserStore().user = { email, accountType } as unknown as User +} + +describe('useWorkspacePermissions', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('owner: can edit and create, role is Owner', () => { + setUser('me@x.org') + const { isOwner, canEdit, canCreateDatastream, roleName } = + useWorkspacePermissions() + expect(isOwner(ownerWs)).toBe(true) + expect(canEdit(ownerWs)).toBe(true) + expect(canCreateDatastream(ownerWs)).toBe(true) + expect(roleName(ownerWs)).toBe('Owner') + }) + + it('editor role: can edit and create datastreams', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + expect(canEdit(editorWs)).toBe(true) + expect(canCreateDatastream(editorWs)).toBe(true) + expect(roleName(editorWs)).toBe('Editor') + }) + + it('viewer role: cannot edit or create', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + expect(canEdit(viewerWs)).toBe(false) + expect(canCreateDatastream(viewerWs)).toBe(false) + expect(roleName(viewerWs)).toBe('Viewer') + }) + + it('global permission grants edit and create', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream } = useWorkspacePermissions() + expect(canEdit(globalWs)).toBe(true) + expect(canCreateDatastream(globalWs)).toBe(true) + }) + + it('admin overrides a read-only role', () => { + setUser('me@x.org', 'admin') + const { canEdit, canCreateDatastream } = useWorkspacePermissions() + expect(canEdit(viewerWs)).toBe(true) + expect(canCreateDatastream(viewerWs)).toBe(true) + }) + + it('defaults to the selected workspace when no arg is passed', () => { + setUser('me@x.org') + useWorkspaceStore().selectedWorkspace = editorWs + const { canEdit, roleName } = useWorkspacePermissions() + expect(canEdit()).toBe(true) + expect(roleName()).toBe('Editor') + }) + + it('workspaceById resolves from availableWorkspaces', () => { + setUser('me@x.org') + useWorkspaceStore().availableWorkspaces = [editorWs, viewerWs] + const { workspaceById } = useWorkspacePermissions() + expect(workspaceById('ed')?.id).toBe('ed') + expect(workspaceById('missing')).toBeNull() + expect(workspaceById(null)).toBeNull() + }) +}) diff --git a/apps/qc-app/src/composables/useCreateManagedDatastream.ts b/apps/qc-app/src/composables/useCreateManagedDatastream.ts new file mode 100644 index 00000000..2d170429 --- /dev/null +++ b/apps/qc-app/src/composables/useCreateManagedDatastream.ts @@ -0,0 +1,35 @@ +/** + * Wires the "Create Datastream for Editing" flow to the live stores: + * delegates to the tested `createManagedDatastream` orchestration with the + * HydroServer client and the QC history service. + */ + +import { storeToRefs } from 'pinia' +import type { Datastream } from '@hydroserver/client' +import { useHydroServer } from '@/store/hydroserver' +import { + createManagedDatastream, + type CreateManagedDatastreamResult, +} from '@/services/qualityControl' + +export interface CreateManagedDatastreamSpec { + source: Datastream + processingLevelId: string + name?: string +} + +export function useCreateManagedDatastream() { + const { hs } = storeToRefs(useHydroServer()) + + async function create( + spec: CreateManagedDatastreamSpec + ): Promise { + return createManagedDatastream(hs.value, hs.value.qualityControlHistories, { + source: spec.source, + processingLevelId: spec.processingLevelId, + overrides: spec.name ? { name: spec.name } : undefined, + }) + } + + return { create } +} diff --git a/apps/qc-app/src/composables/useEditSession.ts b/apps/qc-app/src/composables/useEditSession.ts new file mode 100644 index 00000000..90bcedb5 --- /dev/null +++ b/apps/qc-app/src/composables/useEditSession.ts @@ -0,0 +1,160 @@ +/** + * Orchestrates the session-based editing workflow (spec section 5-9), + * wiring the QC service layer to the app's stores: + * - beginEditing: resolve the managed datastream's history, load its + * sessions, and resume the in-progress one (or signal that a session + * must be started), + * - startSession: create a session and copy the source window in, + * - saveDraft: persist the record's edit operations to the session + * (append-only, so each user's operations keep their creator), + * - commit: push the final observations (replace) and lock the session. + * + * The QC API client comes from `hs.qualityControl*`; the server stamps every + * operation/session with the authenticated user. + */ + +import { ref } from 'vue' +import { storeToRefs } from 'pinia' +import { serializeHistory, applyHistory } from '@uwrl/qc-utils' +import type { Datastream, QualityControlSessionContract } from '@hydroserver/client' +import type { ObservationRecord } from '@uwrl/qc-utils' +import { useDataVisStore } from '@/store/dataVisualization' +import { usePlotlyStore } from '@/store/plotly' +import { useHydroServer } from '@/store/hydroserver' +import { useObservationStore } from '@/store/observations' +import { useQcSessionStore } from '@/store/qcSession' +import { + findHistoryForDatastream, + startOrResumeSession, + loadSourceWindow, + persistSessionOperations, + commitQcSession, + reconstructSession, + observationsBulkBody, +} from '@/services/qualityControl' + +type QcSessionPostBody = QualityControlSessionContract.PostBody + +export function useEditSession() { + const { qcDatastream } = storeToRefs(useDataVisStore()) + const { selectedSeries } = storeToRefs(usePlotlyStore()) + const { hs } = storeToRefs(useHydroServer()) + const { fetchObservationsInRange } = useObservationStore() + const sessionStore = useQcSessionStore() + + const sourceDatastream = ref(null) + /** True when the managed datastream has no in-progress session to resume. */ + const needsSession = ref(false) + /** True when the selected datastream has no QC history (not a managed datastream). */ + const needsHistory = ref(false) + + async function beginEditing(): Promise { + const managed = qcDatastream.value + if (!managed) return + needsHistory.value = false + + const history = await findHistoryForDatastream( + hs.value.qualityControlHistories, + managed.id + ) + if (!history) { + // Not a managed datastream: the caller should offer to create one + // from it (with this datastream as the source). + needsHistory.value = true + return + } + sourceDatastream.value = + (await hs.value.datastreams.getItem(history.sourceDatastream.id)) ?? null + await sessionStore.loadSessions(history.id) + + const inProgress = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (inProgress && sourceDatastream.value && record) { + // Resume: reconstruct the in-progress session's working state. + await reconstructSession( + { + qcSessions: hs.value.qualityControlSessions, + fetchInRange: fetchObservationsInRange, + applyHistory, + }, + sourceDatastream.value, + history.id, + inProgress.id + ) + needsSession.value = false + } else { + needsSession.value = true + } + } + + async function startSession(spec: QcSessionPostBody): Promise { + const historyId = sessionStore.historyId + if (!historyId || !sourceDatastream.value) { + throw new Error('Load a managed datastream for editing first.') + } + const session = await startOrResumeSession( + hs.value.qualityControlSessions, + historyId, + spec + ) + await sessionStore.loadSessions(historyId) + await loadSourceWindow(fetchObservationsInRange, sourceDatastream.value, session) + needsSession.value = false + } + + async function saveDraft(): Promise { + const historyId = sessionStore.historyId + const session = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (!historyId || !session || !record) { + throw new Error('No active edit session to save.') + } + const operations = serializeHistory(record as ObservationRecord, { + startDate: session.phenomenonTimeStart, + endDate: session.phenomenonTimeEnd, + }).operations + await persistSessionOperations( + hs.value.qualityControlOperations, + historyId, + session.id, + operations + ) + } + + async function commit(): Promise { + const managed = qcDatastream.value + const historyId = sessionStore.historyId + const session = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (!managed || !historyId || !session || !record) { + throw new Error('No active edit session to commit.') + } + await saveDraft() + + const body = observationsBulkBody(record as ObservationRecord) + await commitQcSession({ + qcSessions: hs.value.qualityControlSessions, + historyId, + sessionId: session.id, + // The real client fetches the source window's X-Checksum header for a + // genuine integrity check; here we reuse the checksum captured at create. + currentSourceChecksum: session.sourceChecksum, + pushObservations: async () => { + await hs.value.datastreams.createObservations(managed.id, body, { + mode: 'replace', + }) + }, + }) + await sessionStore.loadSessions(historyId) + } + + return { + sourceDatastream, + needsSession, + needsHistory, + beginEditing, + startSession, + saveDraft, + commit, + } +} diff --git a/apps/qc-app/src/composables/useQcSubmission.ts b/apps/qc-app/src/composables/useQcSubmission.ts index bc53ebfe..ddec4021 100644 --- a/apps/qc-app/src/composables/useQcSubmission.ts +++ b/apps/qc-app/src/composables/useQcSubmission.ts @@ -1,20 +1,10 @@ import { useDataVisStore } from '@/store/dataVisualization' import { usePlotlyStore } from '@/store/plotly' import { useHydroServer } from '@/store/hydroserver' +import { observationsBulkBody } from '@/services/qualityControl/observationsBody' import { Snackbar } from '@uwrl/qc-utils' import { storeToRefs } from 'pinia' -/** - * ObservationBulkPostBody is a local type inside @hydroserver/client's - * datastream.service and is not re-exported from the package entry. Define - * its structural shape here to keep the composable type-safe without - * reaching into internal module paths. - */ -type ObservationBulkPostBody = { - fields: ('phenomenonTime' | 'result')[] - data: unknown[][] -} - /** * Encapsulates the QC submission flow: guard checks, serialization of the * edited ObservationRecord into ObservationBulkPostBody, calling @@ -37,18 +27,10 @@ export function useQcSubmission() { return } - const { dataX, dataY } = selectedSeries.value.data - // TODO: resultQualifierCodes serialization is deferred — qualifier // tracking in useDataVisStore is still stubbed. Submit only // phenomenonTime + result for now. - const body: ObservationBulkPostBody = { - fields: ['phenomenonTime', 'result'], - data: Array.from(dataX as ArrayLike).map((ts, i) => [ - new Date(ts).toISOString(), - (dataY as ArrayLike)[i], - ]), - } + const body = observationsBulkBody(selectedSeries.value.data) isSubmitting.value = true try { diff --git a/apps/qc-app/src/composables/useWorkspacePermissions.ts b/apps/qc-app/src/composables/useWorkspacePermissions.ts new file mode 100644 index 00000000..da743454 --- /dev/null +++ b/apps/qc-app/src/composables/useWorkspacePermissions.ts @@ -0,0 +1,109 @@ +/** + * Reactive workspace permission checks for the QC app. + * + * The signed-in user's role on a workspace travels with the `Workspace` + * object: owners have a null `collaboratorRole`, collaborators carry their + * role's `permissions[]`, and admins (`accountType === 'admin'`) override + * everything. No separate "am I an editor" endpoint is needed — the role is + * embedded in `hs.workspaces.list()`. (`hs.user.can()` is the async + * single-shot equivalent; this composable is the synchronous, reactive one + * for gating UI.) + */ + +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { + PermissionAction, + PermissionResource, + type Permission, + type Workspace, +} from '@hydroserver/client' +import { useWorkspaceStore } from '@/store/workspaces' +import { useUserStore } from '@/store/user' + +export function useWorkspacePermissions() { + const { availableWorkspaces, selectedWorkspace } = storeToRefs( + useWorkspaceStore() + ) + const { user } = storeToRefs(useUserStore()) + + const isAdmin = computed( + () => (user.value?.accountType as string) === 'admin' + ) + + function isOwner(ws?: Workspace | null): boolean { + if (!ws) return false + if (ws.owner?.email && user.value?.email) { + return ws.owner.email === user.value.email + } + // Owned workspaces carry a null collaboratorRole. + return ws.collaboratorRole == null + } + + function hasGlobal(perms: Permission[]): boolean { + return perms.some( + (p) => + p.resource === PermissionResource.Global && + p.action === PermissionAction.Global + ) + } + + function can( + action: PermissionAction, + resource: PermissionResource, + ws?: Workspace | null + ): boolean { + const w = ws ?? selectedWorkspace.value + if (!w) return false + if (isOwner(w) || isAdmin.value) return true + const perms = w.collaboratorRole?.permissions ?? [] + return ( + hasGlobal(perms) || + perms.some((p) => p.action === action && p.resource === resource) + ) + } + + /** Can create a managed datastream in this workspace (the QC setup step). */ + function canCreateDatastream(ws?: Workspace | null): boolean { + return can(PermissionAction.Create, PermissionResource.Datastream, ws) + } + + /** + * Can run the QC edit workflow in this workspace — needs to create the + * managed datastream and/or write observations. Used to gate the editor's + * Start editing / Save / Commit controls. + */ + function canEdit(ws?: Workspace | null): boolean { + return ( + can(PermissionAction.Create, PermissionResource.Datastream, ws) || + can(PermissionAction.Edit, PermissionResource.Datastream, ws) || + can(PermissionAction.Create, PermissionResource.Observation, ws) || + can(PermissionAction.Edit, PermissionResource.Observation, ws) + ) + } + + /** Human-readable role label for display. */ + function roleName(ws?: Workspace | null): string { + const w = ws ?? selectedWorkspace.value + if (!w) return '' + if (isOwner(w)) return 'Owner' + if (w.collaboratorRole?.name) return w.collaboratorRole.name + if (isAdmin.value) return 'Admin' + return 'Read-only' + } + + function workspaceById(id?: string | null): Workspace | null { + if (!id) return null + return availableWorkspaces.value.find((w) => w.id === id) ?? null + } + + return { + isAdmin, + isOwner, + can, + canEdit, + canCreateDatastream, + roleName, + workspaceById, + } +} diff --git a/apps/qc-app/src/pages/Workspaces.vue b/apps/qc-app/src/pages/Workspaces.vue index c8683ce4..9bade159 100644 --- a/apps/qc-app/src/pages/Workspaces.vue +++ b/apps/qc-app/src/pages/Workspaces.vue @@ -47,14 +47,34 @@ @@ -552,6 +552,11 @@ 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 @@ -680,6 +685,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 }) @@ -862,10 +869,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(var(--v-theme-primary), 0.04); } -.edit-history__author { - max-width: 8rem; -} - .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)); @@ -896,6 +899,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(0, 0, 0, 0.05); } +.edit-history__title { + min-width: 0; +} + .edit-history__method { font-size: 0.8125rem; min-width: 0; 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 8fd7a639..80f98391 100644 --- a/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts +++ b/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts @@ -144,9 +144,13 @@ function makeEntry( } } -function createWrapper(props: Record = {}) { +function createWrapper( + props: Record = {}, + slots: Record = {} +) { return mount(EditHistory, { props, + slots, global: { plugins: [createTestPinia(), createTestVuetify()] }, }) } @@ -236,6 +240,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"]') @@ -492,16 +516,26 @@ describe('EditHistory.vue actions', () => { ).toBeDefined() }) - it('disables reload-from-server, which would wipe the record', async () => { + 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"]').attributes('disabled') - ).toBeDefined() + 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() }) it('keeps the entries below when reloading from a step', async () => { @@ -763,7 +797,7 @@ describe('EditHistory.vue actions', () => { }) describe('attribution', () => { - it('shows who applied an operation, on the row and in the detail', async () => { + 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: {} }, @@ -772,15 +806,21 @@ describe('EditHistory.vue actions', () => { const w = createWrapper() await flushPromises() - expect(w.find('[data-testid="history-author-0"]').text()).toBe('Ada Lovelace') - // Unsaved operations have no server attribution yet. - expect(w.find('[data-testid="history-author-1"]').exists()).toBe(false) + // 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) }) }) diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index dc3bdbd9..76808b3a 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -219,7 +219,7 @@ @@ -632,6 +627,16 @@ 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: replay to 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 } @@ -869,6 +874,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(var(--v-theme-primary), 0.04); } +.edit-history__row--clickable { + cursor: pointer; +} + +.edit-history__row--clickable: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)); @@ -886,6 +900,12 @@ 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__row--unapplied { + opacity: 0.45; +} + .edit-history__expand { width: 20px; height: 20px; diff --git a/apps/qc-app/src/components/EditData/SessionList.vue b/apps/qc-app/src/components/EditData/SessionList.vue index 1b6238fd..c3caf847 100644 --- a/apps/qc-app/src/components/EditData/SessionList.vue +++ b/apps/qc-app/src/components/EditData/SessionList.vue @@ -26,6 +26,7 @@
diff --git a/apps/qc-app/src/components/EditData/StartEditingDialog.vue b/apps/qc-app/src/components/EditData/StartEditingDialog.vue index ccbb00ba..95954382 100644 --- a/apps/qc-app/src/components/EditData/StartEditingDialog.vue +++ b/apps/qc-app/src/components/EditData/StartEditingDialog.vue @@ -42,16 +42,6 @@
- - Start new session - -
- No sessions yet. -
- - + +
+
+ {{ + opt.sessions.length + ? 'Continue from the latest commit' + : 'No sessions yet' + }} +
+ + Start new session + +
+
+ + - - - {{ sessionLabel(s) }} - - - {{ sessionPeriod(s) }} - - -
-
- -
- - Discard this in-progress session and its unsaved edits? Committed - sessions are unaffected. This can't be undone. - - - - Cancel - - - Discard - -
-
+ + {{ s.status === 'in_progress' ? 'In progress' : 'Committed' }} + + + + Continue + + +
+ + + + + + +
+ + + +
+
+ {{ + chainCount > 1 + ? `Delete ${chainCount} sessions?` + : 'Delete this session?' + }} +
+
+ This permanently removes quality control work +
+
+
+ + +

+ + +

+ +
+ {{ chainCount > 1 ? 'Will be deleted, newest first' : 'Will be deleted' }} +
+ + + + + {{ sessionLabel(s) }} + + (the one you picked) + + + + + + +
+ Any session built on this one is deleted with it. +
+ + + This cannot be undone. + + + + + + +
+ + + + + Cancel + + + + {{ chainCount > 1 ? `Delete ${chainCount} sessions` : 'Delete session' }} + + +
+
+ + diff --git a/apps/qc-app/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts b/apps/qc-app/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts new file mode 100644 index 00000000..79d83e8e --- /dev/null +++ b/apps/qc-app/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for DataVisDatasetsTable.vue — focused on the plot checkbox. + * A source with managed (QC) datastreams opens a chooser instead of + * toggling, so the row's checked state has to speak for the whole group. + */ + +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import PlotSourceDialog from '../PlotSourceDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, +} + +const raw = { id: 'src', name: 'Raw', valueCount: 10, thing: { id: 't-1' } } +const lonely = { id: 'solo', name: 'Solo', valueCount: 5, thing: { id: 't-1' } } + +const filteredDatastreams = ref([raw, lonely]) +const plottedDatastreams = ref([]) +const qcDatastream = ref(null) +const historiesBySource = ref( + new Map([ + ['src', [{ id: 'h-1', managedDatastreamId: 'mgd', sourceDatastreamId: 'src' }]], + ]) +) + +const toggleDatastream = vi.fn().mockResolvedValue(undefined) +const clearPlottedDatastreams = vi.fn().mockResolvedValue(undefined) +const plotSourceSelection = vi.fn().mockResolvedValue(undefined) +const sourceGroupIds = (id: string) => (id === 'src' ? ['src', 'mgd'] : [id]) + +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ + filteredDatastreams, + plottedDatastreams, + qcDatastream, + historiesBySource, + toggleDatastream, + clearPlottedDatastreams, + sourceGroupIds, + plotSourceSelection, + }), +})) + +const loadForSource = vi.fn().mockResolvedValue([ + { + historyId: 'h-1', + managed: { id: 'mgd', name: 'Raw (QC)', valueCount: 8 }, + sessions: [], + }, +]) + +vi.mock('@/composables/useManagedDatastreams', () => ({ + useManagedDatastreams: () => ({ loadForSource }), +})) + +vi.mock('@/utils/csvExport', () => ({ + downloadDatastreamsCsvZip: vi.fn().mockResolvedValue(undefined), +})) + +const snackbarError = vi.fn() +vi.mock('@uwrl/qc-utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, Snackbar: { error: snackbarError, success: vi.fn() } } +}) + +beforeEach(() => { + vi.clearAllMocks() + plottedDatastreams.value = [] + qcDatastream.value = null +}) + +const mountTable = async () => { + const DataVisDatasetsTable = (await import('../DataVisDatasetsTable.vue')) + .default + const wrapper = mount(DataVisDatasetsTable, { + global: { plugins: [createTestVuetify()] }, + attachTo: document.body, + }) + await flushPromises() + return wrapper +} + +const checkbox = (wrapper: any, id: string) => + wrapper.find(`[data-testid="plot-checkbox-${id}"]`) + +describe('DataVisDatasetsTable plot checkbox', () => { + it('toggles directly when the source has no managed datastreams', async () => { + const wrapper = await mountTable() + await checkbox(wrapper, 'solo').trigger('click') + await flushPromises() + + expect(toggleDatastream).toHaveBeenCalledWith( + expect.objectContaining({ id: 'solo' }) + ) + expect(wrapper.findComponent(PlotSourceDialog).exists()).toBe(false) + }) + + it('opens the chooser when the source has managed datastreams', async () => { + const wrapper = await mountTable() + await checkbox(wrapper, 'src').trigger('click') + await flushPromises() + + expect(toggleDatastream).not.toHaveBeenCalled() + expect(loadForSource).toHaveBeenCalledWith('src') + expect(wrapper.findComponent(PlotSourceDialog).exists()).toBe(true) + }) + + it('reports a chooser failure and still offers the raw option', async () => { + loadForSource.mockRejectedValueOnce(new Error('boom')) + const wrapper = await mountTable() + await checkbox(wrapper, 'src').trigger('click') + await flushPromises() + + expect(snackbarError).toHaveBeenCalledWith('boom') + const dialog = wrapper.findComponent(PlotSourceDialog) + expect(dialog.exists()).toBe(true) + expect(dialog.props('options')).toEqual([]) + }) + + it('applies the chooser selection through the batched store action', async () => { + const wrapper = await mountTable() + await checkbox(wrapper, 'src').trigger('click') + await flushPromises() + + wrapper.findComponent(PlotSourceDialog).vm.$emit('apply', ['mgd']) + await flushPromises() + + expect(plotSourceSelection).toHaveBeenCalledWith('src', ['mgd']) + }) + + it('shows a checked box when the raw datastream is plotted', async () => { + plottedDatastreams.value = [raw] + const wrapper = await mountTable() + expect(checkbox(wrapper, 'src').html()).toContain('mdi-checkbox-marked') + }) + + // The row must not claim the raw line is plotted when only a QC version is. + it('shows an indeterminate box when only a managed datastream is plotted', async () => { + plottedDatastreams.value = [{ id: 'mgd', name: 'Raw (QC)' }] + const wrapper = await mountTable() + const html = checkbox(wrapper, 'src').html() + expect(html).toContain('mdi-checkbox-intermediate') + expect(checkbox(wrapper, 'src').attributes('aria-pressed')).toBe('true') + }) + + it('shows an empty box when nothing from the group is plotted', async () => { + const wrapper = await mountTable() + expect(checkbox(wrapper, 'src').html()).toContain( + 'mdi-checkbox-blank-outline' + ) + }) +}) diff --git a/apps/qc-app/src/components/VisualizeData/__tests__/PlotSourceDialog.spec.ts b/apps/qc-app/src/components/VisualizeData/__tests__/PlotSourceDialog.spec.ts new file mode 100644 index 00000000..26fca03d --- /dev/null +++ b/apps/qc-app/src/components/VisualizeData/__tests__/PlotSourceDialog.spec.ts @@ -0,0 +1,127 @@ +/** + * Unit tests for PlotSourceDialog.vue — the chooser shown when plotting a + * source datastream that has managed (QC) datastreams derived from it. + * Purely presentational, so no stores are mocked. + */ + +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import PlotSourceDialog from '../PlotSourceDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const source = { + id: 'src', + name: 'Logan River Temp', + valueCount: 1000, + processingLevel: { definition: 'Raw' }, +} as any + +const options = [ + { + historyId: 'h-1', + managed: { + id: 'mgd-1', + name: 'Logan River Temp (QC)', + valueCount: 900, + processingLevel: { definition: 'Quality controlled' }, + }, + sessions: [{ id: 's-1', status: 'in_progress' }], + }, + { + historyId: 'h-2', + managed: { id: 'mgd-2', name: 'Logan River Temp (QC2)', valueCount: 800 }, + sessions: [], + }, +] as any + +const mountDialog = (props: Record = {}) => + mount(PlotSourceDialog, { + props: { + source, + options, + plottedIds: [], + slotsLeft: 5, + ...props, + }, + global: { plugins: [createTestVuetify()] }, + }) + +const optionInput = (wrapper: any, id: string) => + wrapper.find(`[data-testid="plot-option-${id}"] input`) + +describe('PlotSourceDialog', () => { + it('lists the raw datastream first, then one row per managed datastream', () => { + const wrapper = mountDialog() + const labels = wrapper + .findAll('[data-testid^="plot-option-"]') + .map((el) => el.attributes('data-testid')) + expect(labels).toEqual([ + 'plot-option-src', + 'plot-option-mgd-1', + 'plot-option-mgd-2', + ]) + expect(wrapper.text()).toContain('Raw data') + expect(wrapper.text()).toContain('Logan River Temp (QC)') + }) + + it('shows a summary line for each managed datastream', () => { + const wrapper = mountDialog() + expect(wrapper.text()).toContain('Level: Quality controlled') + expect(wrapper.text()).toContain('1 session, 1 in progress') + expect(wrapper.text()).toContain('0 sessions') + }) + + it('starts with the already-plotted series checked', () => { + const wrapper = mountDialog({ plottedIds: ['mgd-1'] }) + expect( + (optionInput(wrapper, 'mgd-1').element as HTMLInputElement).checked + ).toBe(true) + expect( + (optionInput(wrapper, 'src').element as HTMLInputElement).checked + ).toBe(false) + }) + + it('shows a spinner and no rows while loading', () => { + const wrapper = mountDialog({ loading: true, options: [] }) + expect(wrapper.findAll('[data-testid^="plot-option-"]')).toHaveLength(0) + expect(wrapper.find('.v-progress-circular').exists()).toBe(true) + }) + + it('emits the checked ids in display order, not click order', async () => { + const wrapper = mountDialog() + await optionInput(wrapper, 'mgd-2').setValue(true) + await optionInput(wrapper, 'src').setValue(true) + await wrapper.find('[data-testid="plot-source-apply"]').trigger('click') + expect(wrapper.emitted('apply')?.[0]).toEqual([['src', 'mgd-2']]) + }) + + it('emits an empty selection when everything is unchecked', async () => { + const wrapper = mountDialog({ plottedIds: ['src'] }) + await optionInput(wrapper, 'src').setValue(false) + await wrapper.find('[data-testid="plot-source-apply"]').trigger('click') + expect(wrapper.emitted('apply')?.[0]).toEqual([[]]) + }) + + it('disables unchecked rows once the plot cap is reached', async () => { + const wrapper = mountDialog({ plottedIds: ['src'], slotsLeft: 1 }) + expect(optionInput(wrapper, 'mgd-1').attributes('disabled')).toBeDefined() + // The checked one stays enabled so the selection can still be changed. + expect(optionInput(wrapper, 'src').attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-testid="plot-source-cap"]').exists()).toBe(true) + + await optionInput(wrapper, 'src').setValue(false) + expect(optionInput(wrapper, 'mgd-1').attributes('disabled')).toBeUndefined() + }) + + it('emits cancel without a selection', async () => { + const wrapper = mountDialog() + await wrapper.find('[data-testid="plot-source-cancel"]').trigger('click') + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('apply')).toBeUndefined() + }) +}) diff --git a/apps/qc-app/src/store/__tests__/dataVisualization.spec.ts b/apps/qc-app/src/store/__tests__/dataVisualization.spec.ts index 2cd1df97..b502dd57 100644 --- a/apps/qc-app/src/store/__tests__/dataVisualization.spec.ts +++ b/apps/qc-app/src/store/__tests__/dataVisualization.spec.ts @@ -792,3 +792,184 @@ describe('useDataVisStore snapshot series', () => { ).toBe(false) }) }) + +describe('useDataVisStore.sourceGroupIds', () => { + it('returns the source plus every managed datastream derived from it', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + store.qcHistories = [ + { id: 'h-1', managedDatastreamId: 'mgd-1', sourceDatastreamId: 'src' }, + { id: 'h-2', managedDatastreamId: 'mgd-2', sourceDatastreamId: 'src' }, + { id: 'h-3', managedDatastreamId: 'mgd-3', sourceDatastreamId: 'other' }, + ] as any + + expect(store.sourceGroupIds('src')).toEqual(['src', 'mgd-1', 'mgd-2']) + expect(store.sourceGroupIds('lonely')).toEqual(['lonely']) + }) +}) + +describe('useDataVisStore.plotSourceSelection', () => { + const withGroup = (store: any) => { + store.qcHistories = [ + { id: 'h-1', managedDatastreamId: 'mgd-1', sourceDatastreamId: 'src' }, + { id: 'h-2', managedDatastreamId: 'mgd-2', sourceDatastreamId: 'src' }, + ] as any + store.datastreams = [ + makeDs({ id: 'src', name: 'Raw' }), + makeDs({ id: 'mgd-1', name: 'Raw (QC)' }), + makeDs({ id: 'mgd-2', name: 'Raw (QC2)' }), + makeDs({ id: 'other', name: 'Other' }), + ] as any + } + + it('plots the chosen series and promotes the first as QC target', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + + await store.plotSourceSelection('src', ['src', 'mgd-1']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual([ + 'src', + 'mgd-1', + ]) + expect(store.qcDatastreamId).toBe('src') + }) + + it('makes a managed datastream the QC target when the raw one is not picked', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + + await store.plotSourceSelection('src', ['mgd-2']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual(['mgd-2']) + expect(store.qcDatastreamId).toBe('mgd-2') + }) + + it('adds and removes within the group in a single call', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + store.plottedDatastreams = [ + makeDs({ id: 'src', name: 'Raw' }), + makeDs({ id: 'mgd-1', name: 'Raw (QC)' }), + ] as any + store.qcDatastreamId = 'src' + + await store.plotSourceSelection('src', ['mgd-1', 'mgd-2']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual([ + 'mgd-1', + 'mgd-2', + ]) + }) + + it('leaves datastreams from other sources untouched', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + store.plottedDatastreams = [makeDs({ id: 'other', name: 'Other' })] as any + store.qcDatastreamId = 'other' + + await store.plotSourceSelection('src', ['mgd-1']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual([ + 'other', + 'mgd-1', + ]) + expect(store.qcDatastreamId).toBe('other') + }) + + it('promotes a new QC target when the current one is deselected', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + store.plottedDatastreams = [ + makeDs({ id: 'other', name: 'Other' }), + makeDs({ id: 'src', name: 'Raw' }), + ] as any + store.qcDatastreamId = 'src' + + await store.plotSourceSelection('src', ['mgd-1']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual([ + 'other', + 'mgd-1', + ]) + expect(store.qcDatastreamId).toBe('other') + }) + + it('clears the QC target when nothing is left plotted', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + store.plottedDatastreams = [makeDs({ id: 'mgd-1', name: 'Raw (QC)' })] as any + store.qcDatastreamId = 'mgd-1' + + await store.plotSourceSelection('src', []) + + expect(store.plottedDatastreams).toEqual([]) + expect(store.qcDatastreamId).toBeNull() + }) + + it('ignores ids that do not belong to the source group', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + + await store.plotSourceSelection('src', ['other', 'mgd-1']) + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual(['mgd-1']) + }) + + // Looping plot/unplot would rebuild once per change; the batched action + // settles the whole selection against one rebuild. + it('rebuilds the plot once for the whole selection', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + + await store.plotSourceSelection('src', ['src', 'mgd-1', 'mgd-2']) + + expect(mockUpdateOptions).toHaveBeenCalledTimes(1) + }) + + it('does nothing when the selection already matches', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + withGroup(store) + store.plottedDatastreams = [makeDs({ id: 'src', name: 'Raw' })] as any + store.qcDatastreamId = 'src' + + await store.plotSourceSelection('src', ['src']) + + expect(mockUpdateOptions).not.toHaveBeenCalled() + }) +}) + +// Both the source and a managed datastream can be plotted at once now, so +// leaving the editor must not re-add a source that is already there. +describe('useDataVisStore.releaseManagedDatastream with the source plotted', () => { + it('drops the managed datastream instead of duplicating the source', async () => { + const { useDataVisStore } = await import('@/store/dataVisualization') + const store = useDataVisStore() + store.qcHistories = [ + { id: 'h-1', managedDatastreamId: 'mgd', sourceDatastreamId: 'src' }, + ] as any + store.datastreams = [ + makeDs({ id: 'src', name: 'Raw' }), + makeDs({ id: 'mgd', name: 'Raw (QC)' }), + ] as any + store.plottedDatastreams = [ + makeDs({ id: 'src', name: 'Raw' }), + makeDs({ id: 'mgd', name: 'Raw (QC)' }), + ] as any + store.qcDatastreamId = 'mgd' + + await store.releaseManagedDatastream() + + expect(store.plottedDatastreams.map((d: any) => d.id)).toEqual(['src']) + expect(store.qcDatastreamId).toBe('src') + }) +}) diff --git a/apps/qc-app/src/store/dataVisualization.ts b/apps/qc-app/src/store/dataVisualization.ts index 09536f2e..08e3ad23 100644 --- a/apps/qc-app/src/store/dataVisualization.ts +++ b/apps/qc-app/src/store/dataVisualization.ts @@ -245,6 +245,59 @@ export const useDataVisStore = defineStore('dataVisualization', () => { if (plotlyRef.value) await handleNewPlot(undefined, { preserveZoom: true }) } + /** The source datastream plus every managed datastream derived from it. */ + function sourceGroupIds(sourceId: string): string[] { + const ids = [sourceId] + for (const h of historiesBySource.value.get(sourceId) ?? []) { + const id = historyManagedId(h) + if (id) ids.push(id) + } + return ids + } + + /** + * 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, so the + * first-plotted-wins QC rule sees the caller's ordering. + * + * Batched rather than looping plot/unplot so the QC target is promoted + * once against the final set instead of drifting through each + * intermediate state. + */ + async function plotSourceSelection(sourceId: string, ids: string[]) { + const group = new Set(sourceGroupIds(sourceId)) + const wanted = ids.filter((id) => group.has(id)) + const wantedSet = new Set(wanted) + + const kept = plottedDatastreams.value.filter( + (d) => !group.has(d.id) || wantedSet.has(d.id) + ) + const present = new Set(kept.map((d) => d.id)) + const added = wanted + .filter((id) => !present.has(id)) + .map((id) => datastreams.value.find((d) => d.id === id)) + .filter((d): d is Datastream & DatastreamExtended => !!d) + + const next = [...kept, ...added] + const unchanged = + next.length === plottedDatastreams.value.length && + next.every((d, i) => d.id === plottedDatastreams.value[i]?.id) + if (unchanged) return + + const previousIndex = plottedDatastreams.value.findIndex( + (d) => d.id === qcDatastreamId.value + ) + plottedDatastreams.value = next + if (!next.some((d) => d.id === qcDatastreamId.value)) { + // Same promotion rule as `unplotDatastream`: the entry before the one + // that left, clamped into the surviving set. + const idx = Math.max(Math.min(previousIndex - 1, next.length - 1), 0) + qcDatastreamId.value = next[idx]?.id ?? null + } + await rebuildPlot() + } + /** Clear every plotted datastream at once. */ async function clearPlottedDatastreams() { if (!plottedDatastreams.value.length) return @@ -335,7 +388,13 @@ export const useDataVisStore = defineStore('dataVisualization', () => { const idx = plottedDatastreams.value.findIndex((d) => d.id === managedId) if (idx < 0) return - plottedDatastreams.value.splice(idx, 1, source) + // The source can already be plotted alongside its managed datastream + // (both picked in the plot chooser); replacing would duplicate it. + if (plottedDatastreams.value.some((d) => d.id === source.id)) { + plottedDatastreams.value.splice(idx, 1) + } else { + plottedDatastreams.value.splice(idx, 1, source) + } qcDatastreamId.value = source.id // The working copy holds uncommitted edits; `rebuildPlot` refetches. @@ -761,6 +820,8 @@ export const useDataVisStore = defineStore('dataVisualization', () => { toggleDatastream, plotDatastream, unplotDatastream, + sourceGroupIds, + plotSourceSelection, clearPlottedDatastreams, addSnapshotSeries, removeSnapshotSeries, diff --git a/apps/qc-app/src/utils/__tests__/datastreamSummary.spec.ts b/apps/qc-app/src/utils/__tests__/datastreamSummary.spec.ts new file mode 100644 index 00000000..6efb9279 --- /dev/null +++ b/apps/qc-app/src/utils/__tests__/datastreamSummary.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { datastreamSummary } from '@/utils/datastreamSummary' + +const ds = (overrides: Record = {}) => + ({ + id: 'ds-1', + name: 'Stream', + valueCount: 1234, + processingLevel: { definition: 'Quality controlled' }, + ...overrides, + }) as any + +const session = (status: string) => ({ id: 's', status }) as any + +describe('datastreamSummary', () => { + it('leads with the processing level definition', () => { + expect(datastreamSummary(ds())).toContain('Level: Quality controlled') + }) + + it('falls back to the processing level code when there is no definition', () => { + expect(datastreamSummary(ds({ processingLevel: { code: 'L1' } }))).toContain( + 'Level: L1' + ) + }) + + it('omits the level entirely when the datastream has none', () => { + expect(datastreamSummary(ds({ processingLevel: undefined }))).not.toContain( + 'Level:' + ) + }) + + it('formats the observation count and treats a missing count as zero', () => { + expect(datastreamSummary(ds())).toContain( + `${new Intl.NumberFormat().format(1234)} obs` + ) + expect(datastreamSummary(ds({ valueCount: undefined }))).toContain('0 obs') + }) + + it('omits sessions when none are supplied', () => { + expect(datastreamSummary(ds())).not.toContain('session') + }) + + it('pluralizes the session count', () => { + expect(datastreamSummary(ds(), [])).toContain('0 sessions') + expect(datastreamSummary(ds(), [session('committed')])).toContain( + '1 session' + ) + expect( + datastreamSummary(ds(), [session('committed'), session('committed')]) + ).toContain('2 sessions') + }) + + it('calls out in-progress sessions', () => { + const summary = datastreamSummary(ds(), [ + session('committed'), + session('in_progress'), + ]) + expect(summary).toContain('2 sessions, 1 in progress') + }) +}) diff --git a/apps/qc-app/src/utils/datastreamSummary.ts b/apps/qc-app/src/utils/datastreamSummary.ts new file mode 100644 index 00000000..cbdedc0c --- /dev/null +++ b/apps/qc-app/src/utils/datastreamSummary.ts @@ -0,0 +1,32 @@ +import type { + Datastream, + DatastreamExtended, + QualityControlSession, +} from '@hydroserver/client' + +const NUMBER = new Intl.NumberFormat() + +/** + * One-line recap of a datastream, so several derived from the same source + * are distinguishable in a chooser: processing level, observation count, + * and — when sessions are supplied — how many QC sessions it carries. + */ +export function datastreamSummary( + datastream: Datastream & Partial, + sessions?: QualityControlSession[] +): string { + const parts: string[] = [] + const level = + datastream.processingLevel?.definition || datastream.processingLevel?.code + if (level) parts.push(`Level: ${level}`) + parts.push(`${NUMBER.format(datastream.valueCount ?? 0)} obs`) + if (sessions) { + const inProgress = sessions.filter((s) => s.status === 'in_progress').length + parts.push( + `${sessions.length} session${sessions.length === 1 ? '' : 's'}${ + inProgress ? `, ${inProgress} in progress` : '' + }` + ) + } + return parts.join(' · ') +} From 8502b55a41220825ba5f9a8eca1dae7911d100ec Mon Sep 17 00:00:00 2001 From: Maurier Date: Mon, 14 Sep 2026 11:34:00 -0600 Subject: [PATCH 27/93] refactor: add time range preset resolver --- apps/qc-app/docs/ARCHITECTURE.md | 1 + .../utils/__tests__/timeRangePresets.spec.ts | 104 ++++++++++++++++++ apps/qc-app/src/utils/timeRangePresets.ts | 75 +++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 apps/qc-app/src/utils/__tests__/timeRangePresets.spec.ts create mode 100644 apps/qc-app/src/utils/timeRangePresets.ts diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index 0960e853..ba3b5503 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -107,6 +107,7 @@ src/ ├─ utils/ │ ├─ plotting/ Plotly integration (trace builders, event handlers, selection, staging). │ ├─ dateMath.ts Time-range arithmetic for presets ("1w", "1m", "All", …). +│ ├─ timeRangePresets.ts Preset definitions, resolved against the plotted data's extent. │ ├─ observations.ts Observation fetch helpers (paged columnar fetch). │ ├─ rules.ts Vuetify form validation rules. │ └─ time.ts Time unit conversions. diff --git a/apps/qc-app/src/utils/__tests__/timeRangePresets.spec.ts b/apps/qc-app/src/utils/__tests__/timeRangePresets.spec.ts new file mode 100644 index 00000000..22feb112 --- /dev/null +++ b/apps/qc-app/src/utils/__tests__/timeRangePresets.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import { + ALL_PRESET_ID, + DEFAULT_PRESET_ID, + TIME_RANGE_PRESETS, + dataExtent, + presetWindow, +} from '@/utils/timeRangePresets' + +const DAY = 24 * 60 * 60 * 1000 + +describe('TIME_RANGE_PRESETS', () => { + it('keeps the stable preset ids and defaults to 1m', () => { + expect(TIME_RANGE_PRESETS.map((p) => [p.id, p.label])).toEqual([ + [0, '1w'], + [1, '1m'], + [2, '6m'], + [4, '1y'], + [3, 'YTD'], + [5, 'All'], + ]) + expect(DEFAULT_PRESET_ID).toBe(1) + expect(ALL_PRESET_ID).toBe(5) + }) +}) + +describe('dataExtent', () => { + it('spans the earliest begin to the latest end', () => { + const extent = dataExtent([ + { + phenomenonBeginTime: '2020-01-01T00:00:00Z', + phenomenonEndTime: '2021-06-30T12:00:00Z', + }, + { + phenomenonBeginTime: '2019-05-01T00:00:00Z', + phenomenonEndTime: '2020-02-01T00:00:00Z', + }, + ]) + expect(extent?.begin.toISOString()).toBe('2019-05-01T00:00:00.000Z') + expect(extent?.end.toISOString()).toBe('2021-06-30T12:00:00.000Z') + }) + + it('skips datastreams without observations', () => { + const extent = dataExtent([ + { phenomenonBeginTime: null, phenomenonEndTime: null }, + {}, + { + phenomenonBeginTime: '2020-01-01T00:00:00Z', + phenomenonEndTime: '2020-02-01T00:00:00Z', + }, + ]) + expect(extent?.end.toISOString()).toBe('2020-02-01T00:00:00.000Z') + }) + + it('is null when nothing has observations', () => { + expect(dataExtent([])).toBeNull() + expect(dataExtent([{ phenomenonEndTime: null }])).toBeNull() + }) +}) + +describe('presetWindow', () => { + const extent = { + begin: new Date(2019, 4, 1), + end: new Date(2021, 2, 31, 12), + } + + it('1w counts back seven days from the data end', () => { + const w = presetWindow(0, { begin: extent.begin, end: new Date(2021, 5, 30, 12) }) + expect(w?.end.getTime()).toBe(new Date(2021, 5, 30, 12).getTime()) + expect(w!.end.getTime() - w!.begin.getTime()).toBe(7 * DAY) + }) + + it('1m clamps to the end of a shorter month', () => { + const w = presetWindow(1, extent) + expect(w?.begin.getTime()).toBe(new Date(2021, 1, 28, 12).getTime()) + expect(w?.end.getTime()).toBe(extent.end.getTime()) + }) + + it('6m and 1y count back from the data end', () => { + expect(presetWindow(2, extent)?.begin.getTime()).toBe( + new Date(2020, 8, 30, 12).getTime() + ) + expect(presetWindow(4, extent)?.begin.getTime()).toBe( + new Date(2020, 2, 31, 12).getTime() + ) + }) + + it('YTD starts on Jan 1 of the data end year', () => { + expect(presetWindow(3, extent)?.begin.getTime()).toBe( + new Date(2021, 0, 1).getTime() + ) + }) + + it('All spans the whole extent', () => { + const w = presetWindow(5, extent) + expect(w?.begin.getTime()).toBe(extent.begin.getTime()) + expect(w?.end.getTime()).toBe(extent.end.getTime()) + }) + + it('is null for an unknown id', () => { + expect(presetWindow(999, extent)).toBeNull() + expect(presetWindow(-1, extent)).toBeNull() + }) +}) diff --git a/apps/qc-app/src/utils/timeRangePresets.ts b/apps/qc-app/src/utils/timeRangePresets.ts new file mode 100644 index 00000000..835649c2 --- /dev/null +++ b/apps/qc-app/src/utils/timeRangePresets.ts @@ -0,0 +1,75 @@ +/** + * Time range presets. A preset resolves against a data extent rather than + * the wall clock, so it always lands on observations. + */ + +import { subtractDays, subtractMonths, subtractYears } from '@/utils/dateMath' + +export interface TimeRangePreset { + id: number + icon: string + label: '1w' | '1m' | '6m' | '1y' | 'YTD' | 'All' + title: string +} + +export interface TimeWindow { + begin: Date + end: Date +} + +interface PhenomenonTimes { + phenomenonBeginTime?: string | null + phenomenonEndTime?: string | null +} + +export const TIME_RANGE_PRESETS: readonly TimeRangePreset[] = [ + { id: 0, icon: 'mdi-calendar-week', label: '1w', title: 'Last week of data' }, + { id: 1, icon: 'mdi-calendar-month', label: '1m', title: 'Last month of data' }, + { id: 2, icon: 'mdi-calendar-range', label: '6m', title: 'Last 6 months of data' }, + { id: 4, icon: 'mdi-calendar', label: '1y', title: 'Last year of data' }, + { id: 3, icon: 'mdi-calendar-today', label: 'YTD', title: 'Year to date' }, + { id: 5, icon: 'mdi-infinity', label: 'All', title: 'All data' }, +] + +export const DEFAULT_PRESET_ID = 1 +export const ALL_PRESET_ID = 5 +export const CUSTOM_PRESET_ID = -1 + +export const findPreset = (id: number) => + TIME_RANGE_PRESETS.find((p) => p.id === id) + +/** Earliest first to latest last observation; null when none has data. */ +export function dataExtent( + datastreams: readonly PhenomenonTimes[] +): TimeWindow | null { + let begin = Infinity + let end = -Infinity + for (const ds of datastreams) { + if (!ds.phenomenonBeginTime || !ds.phenomenonEndTime) continue + begin = Math.min(begin, Date.parse(ds.phenomenonBeginTime)) + end = Math.max(end, Date.parse(ds.phenomenonEndTime)) + } + if (!Number.isFinite(begin) || !Number.isFinite(end)) return null + return { begin: new Date(begin), end: new Date(end) } +} + +/** The window a preset covers, counting back from the extent's end. */ +export function presetWindow(id: number, extent: TimeWindow): TimeWindow | null { + const preset = findPreset(id) + if (!preset) return null + const end = new Date(extent.end) + switch (preset.label) { + case '1w': + return { begin: subtractDays(end, 7), end } + case '1m': + return { begin: subtractMonths(end, 1), end } + case '6m': + return { begin: subtractMonths(end, 6), end } + case '1y': + return { begin: subtractYears(end, 1), end } + case 'YTD': + return { begin: new Date(end.getFullYear(), 0, 1), end } + case 'All': + return { begin: new Date(extent.begin), end } + } +} From a1364577f5f5d0cd60125fd11ba819bbc2e685c3 Mon Sep 17 00:00:00 2001 From: Maurier Date: Mon, 14 Sep 2026 11:46:12 -0600 Subject: [PATCH 28/93] fix: anchor time range presets to the plotted data --- apps/qc-app/docs/API_REFERENCE.md | 8 +- apps/qc-app/docs/USER_GUIDE.md | 14 +- apps/qc-app/e2e/all-operations.spec.ts | 2 +- apps/qc-app/e2e/edit-fill-gaps.spec.ts | 2 +- apps/qc-app/e2e/filter-gaps.spec.ts | 2 +- apps/qc-app/e2e/filter-persistence.spec.ts | 2 +- apps/qc-app/e2e/support/fixtures.ts | 15 +- apps/qc-app/src/components/VisualizeData.vue | 13 +- .../VisualizeData/DataVisTimeFilters.vue | 21 +- .../src/components/VisualizeData/Plot.vue | 87 ++------ .../store/__tests__/dataVisualization.spec.ts | 207 +++++++++--------- apps/qc-app/src/store/dataVisualization.ts | 141 +++--------- apps/qc-app/src/utils/share.ts | 10 +- 13 files changed, 190 insertions(+), 334 deletions(-) diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index f7e53481..c9630df5 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -293,15 +293,13 @@ on boot. | `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. | +| `beginDate` / `endDate` | state | `Date` | Active loaded window. A preset re-resolves it from the plotted datastreams' phenomenon times on every plot rebuild; a custom range stays fixed. | +| `selectedDateBtnId` | state | `number` | Active preset id (default `1`, 1m); `-1` (`CUSTOM_PRESET_ID`) for a manual range. Presets are defined in `utils/timeRangePresets.ts`. | | `matchesSelectedThing` | 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. | +| `onDateBtnClick` | action | `(id: number) => Promise` | Selects the preset and applies its window over the plotted data's extent. With nothing plotted, only the selection changes. | | `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. | | `toggleDatastream` | action | `(ds: Datastream) => Promise` | Plot if absent, unplot if present. | diff --git a/apps/qc-app/docs/USER_GUIDE.md b/apps/qc-app/docs/USER_GUIDE.md index f34d730c..b1fb97ad 100644 --- a/apps/qc-app/docs/USER_GUIDE.md +++ b/apps/qc-app/docs/USER_GUIDE.md @@ -79,13 +79,7 @@ The select view is the default landing surface after picking a workspace. The le The filter drawer has two collapsible sections: -- **Time range**: the loaded time window for the selected datastreams. The two date pickers (`From` / `To`) are the source of truth. They will respond when you make a selection in the row of preset chips below them: - **1w**, **1m**, **6m**, **1y**, **YTD**, **All**. Picking a preset re-fetches observations from the server. A `Custom` chip appears when the dates were edited manually in the `From` and `To` data pickers. - - > **Tip:** when previewing a brand-new datastream whose observations - > might be years old, click **All** first. The default `1w` preset - > can show an empty window for old data and make the plot look - > broken. +- **Time range**: the loaded time window for the plotted datastreams. The two date pickers (`From` / `To`) show the window. The preset chips below them, **1w**, **1m**, **6m**, **1y**, **YTD**, **All**, count back from the last observation of the plotted datastreams (the newest one when several are plotted), so a preset always lands on data, even for datastreams whose observations are years old. **1m** is the default, and the last preset you pick is remembered. Adding or removing a datastream re-anchors the preset. A `Custom` chip appears when you change a date in `From` or `To`; a custom window stays fixed while you add or remove datastreams. - **Datastream filters**: These filters allow you control the list of datastreams shown in the datastreams table by picking the site, observed property, and/or processing level. The list of matching datastreams updates live in the datastreams table. @@ -200,7 +194,7 @@ The URL encodes everything needed to reproduce what the sender is looking at - q - **View** (Select vs Edit; `m=e` for Edit) - **Active center-column tab** (`tab=t` for Table) - **Plotted datastreams** (`ds`), in order. The first id is the QC target. -- **Time window**: either a preset id (`r=0..5`) which the recipient re-anchors to *their* "now", or an explicit `from` / `to` pair as base36 second-epochs when the sender used a custom range. +- **Time window**: either a preset id (`r=0..5`) which the recipient resolves against the plotted data, or an explicit `from` / `to` pair as base36 second-epochs when the sender used a custom range. - **Per-trace eye-toggle visibility** (`h`) and **per-axis visibility** (`ya`) as hex bitmasks over the `ds` order. - **Plot zoom**: X zoom (`z`) plus optional per-Y-axis zoom (`yz`) for axes that aren't at their default fit. - **Data points mode** (`dp`) and **threshold** (`th`) when they differ from the app defaults. @@ -587,7 +581,7 @@ See [PERFORMANCE.md](./PERFORMANCE.md) for the envelope details. 1. Pick a workspace, then pick the datastream you want to QC. 2. Plot it (it becomes the QC target). -3. Click **All** in the Time range so you load the full series. +3. Click **All** in the Time range to load the full series (plotting loads the last month of data). 4. Click the pencil icon → expand **Value thresholds**, set `Greater than: 1000`, press Enter. 5. Expand **Delete points**, click Delete. 6. Click **Save** at the bottom of the Edit history panel, then **Commit** and confirm in the dialog. @@ -616,7 +610,7 @@ Click the grid icon in the nav rail → pick another. If you have unsaved edits, | Symptom | Likely cause | Fix | |---------|--------------|-----| | Blank page on load | Wrong API URL or `localhost` vs `127.0.0.1` mismatch. | See [DEPLOYMENT.md](./DEPLOYMENT.md). | -| Plot stays empty after picking a datastream | Time range falls outside the datastream's observations. The plotted row shows a database-off icon and the subtitle reads `0 pts loaded`. | Click **All** in Time range. | +| Plot stays empty after picking a datastream | A `Custom` time range that doesn't overlap the datastream's observations, or the datastream has no observations. The plotted row shows a database-off icon and the subtitle reads `0 pts loaded`. | Click a preset such as **1m** or **All** in Time range. | | Pencil ("Edit") icon is greyed out | No QC datastream selected. | Plot at least one datastream. The first becomes the QC target. | | Big edits freeze the page | `SharedArrayBuffer` not available; running inline. | Have your admin re-enable COOP/COEP headers, or accept the slower fallback. | | Save fails with a backend error | Permissions / workspace issue / network. | The Snackbar shows the backend message verbatim. Share that with your admin. | diff --git a/apps/qc-app/e2e/all-operations.spec.ts b/apps/qc-app/e2e/all-operations.spec.ts index 763fa86d..0c75c467 100644 --- a/apps/qc-app/e2e/all-operations.spec.ts +++ b/apps/qc-app/e2e/all-operations.spec.ts @@ -30,7 +30,7 @@ import { FIXTURE_OBS_START_MS } from './support/fixtures' */ function observationsWithGap() { // Anchor to FIXTURE_OBS_START_MS (relative to "now") so the series - // falls inside the QC app's default 1w window — a hard-coded literal + // falls inside the QC app's default time range — a hard-coded literal // would slide out of range as the calendar moves and leave the main // plot empty. const startMs = FIXTURE_OBS_START_MS diff --git a/apps/qc-app/e2e/edit-fill-gaps.spec.ts b/apps/qc-app/e2e/edit-fill-gaps.spec.ts index cea6e030..1a707779 100644 --- a/apps/qc-app/e2e/edit-fill-gaps.spec.ts +++ b/apps/qc-app/e2e/edit-fill-gaps.spec.ts @@ -12,7 +12,7 @@ import { FIXTURE_OBS_START_MS } from './support/fixtures' function observationsWithGap() { // See FIXTURE_OBS_START_MS — anchored to "now" so the series sits - // inside the QC app's default 1w window. + // inside the QC app's default time range. const startMs = FIXTURE_OBS_START_MS const spacingMs = 15 * 60 * 1000 const phenomenonTime: string[] = [] diff --git a/apps/qc-app/e2e/filter-gaps.spec.ts b/apps/qc-app/e2e/filter-gaps.spec.ts index 56c1aa01..7d3fc919 100644 --- a/apps/qc-app/e2e/filter-gaps.spec.ts +++ b/apps/qc-app/e2e/filter-gaps.spec.ts @@ -15,7 +15,7 @@ import { FIXTURE_OBS_START_MS } from './support/fixtures' function observationsWithGap() { // See FIXTURE_OBS_START_MS — anchored to "now" so the series sits - // inside the QC app's default 1w window. + // inside the QC app's default time range. const startMs = FIXTURE_OBS_START_MS const spacingMs = 15 * 60 * 1000 const phenomenonTime: string[] = [] diff --git a/apps/qc-app/e2e/filter-persistence.spec.ts b/apps/qc-app/e2e/filter-persistence.spec.ts index 060aba23..a03da722 100644 --- a/apps/qc-app/e2e/filter-persistence.spec.ts +++ b/apps/qc-app/e2e/filter-persistence.spec.ts @@ -12,7 +12,7 @@ import { FIXTURE_OBS_START_MS } from './support/fixtures' function observationsWithPlateau() { // See FIXTURE_OBS_START_MS — anchored to "now" so the series sits - // inside the QC app's default 1w window. + // inside the QC app's default time range. const startMs = FIXTURE_OBS_START_MS const spacingMs = 15 * 60 * 1000 const result: number[] = [] diff --git a/apps/qc-app/e2e/support/fixtures.ts b/apps/qc-app/e2e/support/fixtures.ts index 51a44a83..38d03d87 100644 --- a/apps/qc-app/e2e/support/fixtures.ts +++ b/apps/qc-app/e2e/support/fixtures.ts @@ -32,15 +32,9 @@ export const OBSERVED_PROP_ID_B = 'op-water-temp' export const SENSOR_ID_B = 'sensor-temp' /** - * Synthetic-observation timing constants, anchored to "now" at module - * load so the generated series always falls inside the QC app's default - * `1w` time-range window. Hard-coding a literal anchor (e.g. - * `2024-01-01`) was a footgun: as the calendar moved past the fixture, - * the main plot — which slices by `[beginDate, endDate]` — silently - * collapsed to an empty window even though the ContextPlot (which uses - * all observations un-windowed) kept looking correct. Specs that build - * custom observation series should import `FIXTURE_OBS_START_MS` and - * derive their timestamps from it for the same reason. + * Synthetic-observation timing constants. The catalog's phenomenon times are + * derived from these, and the app's presets anchor to them, so specs that + * build custom series should derive timestamps from `FIXTURE_OBS_START_MS`. */ export const FIXTURE_OBS_SPACING_MS = 15 * 60 * 1000 export const FIXTURE_OBS_COUNT = 120 @@ -336,8 +330,7 @@ export const qcSessions = [ * `count` samples at 15-minute spacing starting at `startMs`, with * values following `y = 10 + 5 * sin(i / 5)` so every filter op has * interesting but predictable points to select. The default anchor is - * `FIXTURE_OBS_START_MS` (relative to "now") so the series always lies - * inside the QC app's default `1w` time-range window. + * `FIXTURE_OBS_START_MS`, matching the catalog's phenomenon times. */ export function buildObservations( count = FIXTURE_OBS_COUNT, diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index bdae2b6b..c97bf14f 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -566,6 +566,7 @@ import { encodeShareState, type ShareState, } from '@/utils/share' +import { CUSTOM_PRESET_ID } from '@/utils/timeRangePresets' import { isSnapshotId, parseSnapshotId } from '@/utils/snapshotId' import { useHistorySnapshots } from '@/composables/useHistorySnapshots' import { useWorkspaceStore } from '@/store/workspaces' @@ -583,7 +584,6 @@ const { beginDate, endDate, selectedDateBtnId, - dateOptions, selectedThings, selectedObservedPropertyNames, selectedProcessingLevelNames, @@ -965,17 +965,12 @@ const hydrateFromUrl = () => { // QC target is the first id by convention. const qcId = resolved[0]?.id ?? null - // Apply the date window BEFORE loading datastreams so the first - // fetch uses the correct range. + // Set the window BEFORE loading datastreams so the first fetch uses it. + // A preset resolves against the plotted data during that load. if (state.datePresetId != null && state.datePresetId >= 0) { selectedDateBtnId.value = state.datePresetId - const option = dateOptions.value.find((o) => o.id === state.datePresetId) - if (option) { - endDate.value = new Date() - beginDate.value = option.calculateBeginDate() - } } else if (state.beginMs != null || state.endMs != null) { - selectedDateBtnId.value = -1 + selectedDateBtnId.value = CUSTOM_PRESET_ID if (state.beginMs != null) beginDate.value = new Date(state.beginMs) if (state.endMs != null) endDate.value = new Date(state.endMs) } diff --git a/apps/qc-app/src/components/VisualizeData/DataVisTimeFilters.vue b/apps/qc-app/src/components/VisualizeData/DataVisTimeFilters.vue index 689b6862..53cac21c 100644 --- a/apps/qc-app/src/components/VisualizeData/DataVisTimeFilters.vue +++ b/apps/qc-app/src/components/VisualizeData/DataVisTimeFilters.vue @@ -1,9 +1,9 @@ -