From 55960a8ac05c454eeeeb6126d577ca67d407a737 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 28 Jul 2026 15:20:23 +0200 Subject: [PATCH 1/9] feat(website): show resistance mutation annotations on COVID and RSV dashboards Extract resistance mutation collection configs and helpers from the wasap subtree into a shared util file (resistanceMutations.ts). Add `byEnv` as a pure selector to dbIdSpace.ts. Wire COVID, RSV-A, and RSV-B constants to factory functions that build collection configs; have the generic Astro page components fetch annotations server-side and pass them down through the React layout tree. Co-Authored-By: Claude Sonnet 4.6 --- .../GenericAnalyseSingleVariantReactPage.tsx | 4 + .../GenericAnalyzeSingleVariantPage.astro | 28 ++++- .../GenericCompareSideBySidePage.astro | 28 ++++- .../GenericCompareSideBySideReactPage.tsx | 4 + .../GenericCompareToBaselinePage.astro | 28 ++++- .../GenericCompareToBaselineReactPage.tsx | 4 + .../GenericCompareVariantsPage.astro | 28 ++++- .../GenericCompareVariantsReactPage.tsx | 4 + .../GenericSequencingEffortsPage.astro | 28 ++++- .../GenericSequencingEffortsReactPage.tsx | 4 + .../components/views/wasap/resistanceData.ts | 23 +--- .../components/views/wasap/wasapPageConfig.ts | 11 +- .../OrganismPage/OrganismViewPageLayout.tsx | 8 +- .../SingleVariantOrganismPageLayout.tsx | 4 + website/src/types/dbIdSpace.ts | 11 ++ website/src/types/wastewaterConfig.ts | 77 ++----------- website/src/util/resistanceMutations.ts | 106 ++++++++++++++++++ website/src/views/OrganismConstants.ts | 3 + website/src/views/covid.ts | 15 +-- website/src/views/rsvA.ts | 5 +- website/src/views/rsvB.ts | 5 +- 21 files changed, 316 insertions(+), 112 deletions(-) create mode 100644 website/src/util/resistanceMutations.ts diff --git a/website/src/components/views/analyzeSingleVariant/GenericAnalyseSingleVariantReactPage.tsx b/website/src/components/views/analyzeSingleVariant/GenericAnalyseSingleVariantReactPage.tsx index a3d6c8d41..c4a62a5fa 100644 --- a/website/src/components/views/analyzeSingleVariant/GenericAnalyseSingleVariantReactPage.tsx +++ b/website/src/components/views/analyzeSingleVariant/GenericAnalyseSingleVariantReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericAnalyseSingleVariantDataDisplay } from './GenericAnalyseSingleVariantDataDisplay'; @@ -16,12 +17,14 @@ export type GenericAnalyseSingleVariantReactPageProps = { organism: Exclude, typeof Organisms.covid>; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericAnalyseSingleVariantReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${singleVariantViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -57,6 +60,7 @@ export const GenericAnalyseSingleVariantReactPage: FC @@ -21,6 +46,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${singleVariantView organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro index e6216e72e..5d138a4c6 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro @@ -1,7 +1,15 @@ --- +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + import { GenericCompareSideBySideReactPage } from './GenericCompareSideBySideReactPage'; -import { isStaging, getDashboardsConfig } from '../../../config'; +import { BackendService } from '../../../backendApi/backendService.ts'; +import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { getInstanceLogger } from '../../../logger.ts'; +import { getDbIdSpace } from '../../../types/dbIdSpace'; +import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; +import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; +import type { OrganismConstants } from '../../../views/OrganismConstants'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareSideBySideViewKey } from '../../../views/viewKeys'; @@ -15,6 +23,23 @@ const { organism, hideMutationComponents } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareSideBySideViewKey}`); const organismsConfig = getDashboardsConfig().dashboards.organisms; + +let mutationAnnotations: MutationAnnotations | undefined; +const constants: OrganismConstants = view.organismConstants; +const buildCollections = constants.buildResistanceMutationCollections; +if (buildCollections) { + try { + const backendService = new BackendService(getBackendHost()); + mutationAnnotations = await fetchMutationAnnotationsFromCollections( + buildCollections(getDbIdSpace()), + backendService, + ); + } catch (error) { + getInstanceLogger('GenericCompareSideBySidePage').error( + `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, + ); + } +} --- @@ -23,6 +48,7 @@ const organismsConfig = getDashboardsConfig().dashboards.organisms; hideMutationComponents={hideMutationComponents} organismsConfig={organismsConfig} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx index 9d58f2243..13d11342f 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo, useState, useEffect } from 'react'; import { GenericCompareSideBySideDataDisplay } from './GenericCompareSideBySideDataDisplay.tsx'; @@ -15,6 +16,7 @@ export type GenericCompareSideBySideReactPageProps = { hideMutationComponents?: boolean; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareSideBySideReactPage: FC = ({ @@ -22,6 +24,7 @@ export const GenericCompareSideBySideReactPage: FC { const organismViewKey = `${organism}.${compareSideBySideViewKey}` satisfies OrganismViewKey; @@ -45,6 +48,7 @@ export const GenericCompareSideBySideReactPage: FC
diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro index 650f35d58..b350f7662 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro @@ -1,7 +1,15 @@ --- +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + import { GenericCompareToBaselineReactPage } from './GenericCompareToBaselineReactPage'; -import { isStaging, getDashboardsConfig } from '../../../config'; +import { BackendService } from '../../../backendApi/backendService.ts'; +import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { getInstanceLogger } from '../../../logger.ts'; +import { getDbIdSpace } from '../../../types/dbIdSpace'; +import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; +import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; +import type { OrganismConstants } from '../../../views/OrganismConstants'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareToBaselineViewKey } from '../../../views/viewKeys'; @@ -13,6 +21,23 @@ interface Props { const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaselineViewKey}`); + +let mutationAnnotations: MutationAnnotations | undefined; +const constants: OrganismConstants = view.organismConstants; +const buildCollections = constants.buildResistanceMutationCollections; +if (buildCollections) { + try { + const backendService = new BackendService(getBackendHost()); + mutationAnnotations = await fetchMutationAnnotationsFromCollections( + buildCollections(getDbIdSpace()), + backendService, + ); + } catch (error) { + getInstanceLogger('GenericCompareToBaselinePage').error( + `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, + ); + } +} --- @@ -20,6 +45,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaseline organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx index c750149bd..9beb450b7 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareToBaselineDataDisplay } from './GenericCompareToBaselineDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareToBaselineReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareToBaselineReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareToBaselineViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareToBaselineReactPage: FC @@ -20,6 +45,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareVariantsVi organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx index 737f16139..3c16ff38d 100644 --- a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx +++ b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareVariantsDataDisplay } from './GenericCompareVariantsDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareVariantsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareVariantsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareVariantsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareVariantsReactPage: FC @@ -20,6 +45,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${sequencingEfforts organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx index 2f7eb91d3..f46bf3b14 100644 --- a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx +++ b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericSequencingEffortsDataDisplay } from './GenericSequencingEffortsDataDisplay'; @@ -12,12 +13,14 @@ export type GenericSequencingEffortsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericSequencingEffortsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${sequencingEffortsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -42,6 +45,7 @@ export const GenericSequencingEffortsReactPage: FC = {}; setConfigs.forEach((setConfig, i) => { const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); - const allMutations = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); - - displayMutationsBySet[setConfig.name] = allMutations; - - mutationAnnotations.push({ - name: setConfig.name, - symbol: setConfig.annotationSymbol, - description: setConfig.description, - aminoAcidMutations: filterVariants.flatMap((variant) => - (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ - mutation, - name: variant.name, - })), - ), - }); + displayMutationsBySet[setConfig.name] = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); }); return { mutationAnnotations, displayMutationsBySet }; diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 74575de67..f1110d18f 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -1,6 +1,7 @@ import type { DateRangeOption, SequenceType, TemporalGranularity } from '@genspectrum/dashboard-components/util'; import type { Organism } from '../../../types/Organism.ts'; +import type { ResistanceMutationCollectionConfig } from '../../../util/resistanceMutations'; export const SEQUENCE_TYPE = { nucleotide: 'nucleotide', @@ -306,12 +307,4 @@ export type WasapFilter = { analysis: WasapAnalysisFilter; }; -/** - * Resistance mutations defined in a collection, which is specified via the collection ID. - */ -export type ResistanceMutationCollectionConfig = { - collectionId: number; - name: string; - description: string; - annotationSymbol: string; -}; +export type { ResistanceMutationCollectionConfig } from '../../../util/resistanceMutations'; diff --git a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx index ba77e9674..1e7cba6ee 100644 --- a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx +++ b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, PropsWithChildren } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -10,12 +11,14 @@ export type OrganismViewPageLayoutProps = PropsWithChildren<{ view: View>; downloadLinks: DownloadLink[]; lapisUrl: string; + mutationAnnotations?: MutationAnnotations; }>; export const OrganismViewPageLayout: FC = ({ view, downloadLinks, lapisUrl, + mutationAnnotations, children, }) => { return ( @@ -26,7 +29,10 @@ export const OrganismViewPageLayout: FC = ({ lapisUrl={lapisUrl} accessionDownloadFields={view.organismConstants.accessionDownloadFields} > - + {children} diff --git a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx index ceebf3a44..ca2b33c96 100644 --- a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx +++ b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, ReactNode } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -13,6 +14,7 @@ export type SingleVariantOrganismPageLayoutProps = { organismsConfig: OrganismsConfig; filters: ReactNode; dataDisplay: ReactNode; + mutationAnnotations?: MutationAnnotations; }; export const SingleVariantOrganismPageLayout: FC = ({ @@ -21,12 +23,14 @@ export const SingleVariantOrganismPageLayout: FC { return (
{filters}
diff --git a/website/src/types/dbIdSpace.ts b/website/src/types/dbIdSpace.ts index be1828a16..019943a03 100644 --- a/website/src/types/dbIdSpace.ts +++ b/website/src/types/dbIdSpace.ts @@ -6,6 +6,17 @@ export const dbIdSpaces = { export type DbIdSpace = (typeof dbIdSpaces)[keyof typeof dbIdSpaces]; +export function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { + switch (env) { + case dbIdSpaces.prod: + return vars.prod; + case dbIdSpaces.staging: + return vars.staging; + case dbIdSpaces.local: + return vars.local; + } +} + export function getDbIdSpace(): DbIdSpace { const envValue = process.env.DB_ID_SPACE ?? import.meta.env.DB_ID_SPACE; if (envValue) { diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index d11cde4c6..ec1928521 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -1,19 +1,12 @@ import type { MutationAnnotation } from '@genspectrum/dashboard-components/util'; -import { getDbIdSpace, dbIdSpaces, type DbIdSpace } from './dbIdSpace'; -import type { ResistanceMutationCollectionConfig } from '../components/views/wasap/wasapPageConfig'; +import { byEnv, dbIdSpaces, getDbIdSpace, type DbIdSpace } from './dbIdSpace'; import { VARIANT_TIME_FRAME, type WasapPageConfig } from '../components/views/wasap/wasapPageConfig'; - -function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { - switch (env) { - case dbIdSpaces.prod: - return vars.prod; - case dbIdSpaces.staging: - return vars.staging; - case dbIdSpaces.local: - return vars.local; - } -} +import { + buildCovidResistanceMutationCollections, + buildRsvAResistanceMutationCollections, + buildRsvBResistanceMutationCollections, +} from '../util/resistanceMutations'; export const wastewaterOrganisms = { covid: 'covid', @@ -45,29 +38,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), - name: 'RdRp', - annotationSymbol: 'r', - description: - 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), - name: 'Spike', - annotationSymbol: 's', - description: - 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildCovidResistanceMutationCollections(env), lapisBaseUrl: 'https://lapis.wasap.genspectrum.org/covid', samplingDateField: 'samplingDate', locationNameField: 'locationName', @@ -157,22 +128,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvAResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', @@ -233,22 +189,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvBResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', diff --git a/website/src/util/resistanceMutations.ts b/website/src/util/resistanceMutations.ts new file mode 100644 index 000000000..e937ba611 --- /dev/null +++ b/website/src/util/resistanceMutations.ts @@ -0,0 +1,106 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import type { BackendService } from '../backendApi/backendService'; +import type { Collection } from '../types/Collection'; +import { byEnv, type DbIdSpace } from '../types/dbIdSpace'; + +export type ResistanceMutationCollectionConfig = { + collectionId: number; + name: string; + description: string; + annotationSymbol: string; +}; + +export function buildMutationAnnotations( + setConfigs: ResistanceMutationCollectionConfig[], + collections: Collection[], +): MutationAnnotations { + return setConfigs.map((setConfig, i) => { + const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); + return { + name: setConfig.name, + symbol: setConfig.annotationSymbol, + description: setConfig.description, + aminoAcidMutations: filterVariants.flatMap((variant) => + (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ + mutation, + name: variant.name, + })), + ), + }; + }); +} + +export async function fetchMutationAnnotationsFromCollections( + configs: ResistanceMutationCollectionConfig[], + backendService: BackendService, +): Promise { + const collections = await Promise.all( + configs.map((c) => backendService.getCollection({ id: String(c.collectionId) })), + ); + return buildMutationAnnotations(configs, collections); +} + +export function buildCovidResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4, staging: 1, local: 1 }), + name: '3CLpro', + annotationSymbol: 'c', + description: + 'SARS-CoV-2 3C-like protease (3CLpro, or Mpro for Main protease) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), + name: 'RdRp', + annotationSymbol: 'r', + description: + 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), + name: 'Spike', + annotationSymbol: 's', + description: + 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + ]; +} + +export function buildRsvAResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4983, staging: 4, local: 4 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-A F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} + +export function buildRsvBResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4985, staging: 6, local: 6 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-B F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} diff --git a/website/src/views/OrganismConstants.ts b/website/src/views/OrganismConstants.ts index b6c349517..6458932e1 100644 --- a/website/src/views/OrganismConstants.ts +++ b/website/src/views/OrganismConstants.ts @@ -15,6 +15,8 @@ import type { BaselineFilterConfig } from '../components/pageStateSelectors/Base import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import type { Organism } from '../types/Organism.ts'; import type { DataOrigin } from '../types/dataOrigins.ts'; +import type { DbIdSpace } from '../types/dbIdSpace.ts'; +import type { ResistanceMutationCollectionConfig } from '../util/resistanceMutations.ts'; type AggregatedVisualizations = { sequencingEfforts: GsAggregatedConfig[]; @@ -36,6 +38,7 @@ export interface OrganismConstants { readonly lineageFilters: LineageFilterConfig[]; readonly predefinedVariants?: VariantFilter[]; readonly mutationAnnotations?: MutationAnnotation[]; + readonly buildResistanceMutationCollections?: (env: DbIdSpace) => ResistanceMutationCollectionConfig[]; } export const ComponentHeight = { diff --git a/website/src/views/covid.ts b/website/src/views/covid.ts index be5df708e..af030249d 100644 --- a/website/src/views/covid.ts +++ b/website/src/views/covid.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation, views } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets, views } from '@genspectrum/dashboard-components/util'; import { getIntegerFromSearch, @@ -29,19 +29,20 @@ import { makeDatasetAndVariantData, } from './View.ts'; import { compareSideBySideViewConstants, singleVariantViewConstants } from './ViewConstants.ts'; +import { buildCovidResistanceMutationCollections } from '../util/resistanceMutations'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import { type PageStateHandler } from './pageStateHandlers/PageStateHandler.ts'; +import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; import { setSearchFromDateFilters } from './pageStateHandlers/dateFilterFromToUrl.ts'; +import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; +import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; +import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; +import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import { Organisms } from '../types/Organism.ts'; import { type DataOrigin, dataOrigins } from '../types/dataOrigins.ts'; -import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; -import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; -import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { ALL_TIMES_LABEL, defaultDateRangeOption } from '../util/defaultDateRangeOption.ts'; import { formatUrl } from '../util/formatUrl.ts'; -import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; -import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; const earliestDate = '2020-01-06'; const hostField = 'host'; @@ -127,7 +128,7 @@ class CovidConstants implements OrganismConstants { public readonly additionalFilters: Record | undefined; public readonly dataOrigins: DataOrigin[] = [dataOrigins.nextstrain]; public readonly accessionDownloadFields = ['strain']; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildCovidResistanceMutationCollections; public get aggregatedVisualizations() { const hosts = getHostsAggregatedVisualization(this); diff --git a/website/src/views/rsvA.ts b/website/src/views/rsvA.ts index 74bf2b7d9..8fd9600fd 100644 --- a/website/src/views/rsvA.ts +++ b/website/src/views/rsvA.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvAResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvAConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'A.D.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvAResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { diff --git a/website/src/views/rsvB.ts b/website/src/views/rsvB.ts index e2819b913..fc35a6d86 100644 --- a/website/src/views/rsvB.ts +++ b/website/src/views/rsvB.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvBResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvBConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'B.D.4.1.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvBResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { From a25896819b768153d45eb26b452919c6f6854b67 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 10:07:06 +0200 Subject: [PATCH 2/9] refactor(website): extract fetchOrganismMutationAnnotations to remove duplication The five generic Astro page components all had identical 12-line blocks for fetching resistance mutation annotations. Extract these into a single helper with its own dedicated logger instance. Co-Authored-By: Claude Sonnet 4.6 --- .../GenericAnalyzeSingleVariantPage.astro | 28 ++--------------- .../GenericCompareSideBySidePage.astro | 28 ++--------------- .../GenericCompareToBaselinePage.astro | 28 ++--------------- .../GenericCompareVariantsPage.astro | 28 ++--------------- .../GenericSequencingEffortsPage.astro | 28 ++--------------- .../util/fetchOrganismMutationAnnotations.ts | 30 +++++++++++++++++++ 6 files changed, 45 insertions(+), 125 deletions(-) create mode 100644 website/src/util/fetchOrganismMutationAnnotations.ts diff --git a/website/src/components/views/analyzeSingleVariant/GenericAnalyzeSingleVariantPage.astro b/website/src/components/views/analyzeSingleVariant/GenericAnalyzeSingleVariantPage.astro index 8cbe83aa1..4d0b5b371 100644 --- a/website/src/components/views/analyzeSingleVariant/GenericAnalyzeSingleVariantPage.astro +++ b/website/src/components/views/analyzeSingleVariant/GenericAnalyzeSingleVariantPage.astro @@ -1,16 +1,9 @@ --- -import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; - import { GenericAnalyseSingleVariantReactPage } from './GenericAnalyseSingleVariantReactPage'; -import { BackendService } from '../../../backendApi/backendService.ts'; -import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; +import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { getInstanceLogger } from '../../../logger.ts'; import { Organisms } from '../../../types/Organism'; -import { getDbIdSpace } from '../../../types/dbIdSpace'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; -import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; -import type { OrganismConstants } from '../../../views/OrganismConstants'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { singleVariantViewKey } from '../../../views/viewKeys'; @@ -23,22 +16,7 @@ const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${singleVariantViewKey}`); -let mutationAnnotations: MutationAnnotations | undefined; -const constants: OrganismConstants = view.organismConstants; -const buildCollections = constants.buildResistanceMutationCollections; -if (buildCollections) { - try { - const backendService = new BackendService(getBackendHost()); - mutationAnnotations = await fetchMutationAnnotationsFromCollections( - buildCollections(getDbIdSpace()), - backendService, - ); - } catch (error) { - getInstanceLogger('GenericAnalyzeSingleVariantPage').error( - `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, - ); - } -} +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro index 5d138a4c6..dd94158bf 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro @@ -1,15 +1,8 @@ --- -import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; - import { GenericCompareSideBySideReactPage } from './GenericCompareSideBySideReactPage'; -import { BackendService } from '../../../backendApi/backendService.ts'; -import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; +import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { getInstanceLogger } from '../../../logger.ts'; -import { getDbIdSpace } from '../../../types/dbIdSpace'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; -import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; -import type { OrganismConstants } from '../../../views/OrganismConstants'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareSideBySideViewKey } from '../../../views/viewKeys'; @@ -24,22 +17,7 @@ const { organism, hideMutationComponents } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareSideBySideViewKey}`); const organismsConfig = getDashboardsConfig().dashboards.organisms; -let mutationAnnotations: MutationAnnotations | undefined; -const constants: OrganismConstants = view.organismConstants; -const buildCollections = constants.buildResistanceMutationCollections; -if (buildCollections) { - try { - const backendService = new BackendService(getBackendHost()); - mutationAnnotations = await fetchMutationAnnotationsFromCollections( - buildCollections(getDbIdSpace()), - backendService, - ); - } catch (error) { - getInstanceLogger('GenericCompareSideBySidePage').error( - `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, - ); - } -} +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro index b350f7662..96868e346 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro @@ -1,15 +1,8 @@ --- -import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; - import { GenericCompareToBaselineReactPage } from './GenericCompareToBaselineReactPage'; -import { BackendService } from '../../../backendApi/backendService.ts'; -import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; +import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { getInstanceLogger } from '../../../logger.ts'; -import { getDbIdSpace } from '../../../types/dbIdSpace'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; -import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; -import type { OrganismConstants } from '../../../views/OrganismConstants'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareToBaselineViewKey } from '../../../views/viewKeys'; @@ -22,22 +15,7 @@ const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaselineViewKey}`); -let mutationAnnotations: MutationAnnotations | undefined; -const constants: OrganismConstants = view.organismConstants; -const buildCollections = constants.buildResistanceMutationCollections; -if (buildCollections) { - try { - const backendService = new BackendService(getBackendHost()); - mutationAnnotations = await fetchMutationAnnotationsFromCollections( - buildCollections(getDbIdSpace()), - backendService, - ); - } catch (error) { - getInstanceLogger('GenericCompareToBaselinePage').error( - `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, - ); - } -} +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- diff --git a/website/src/components/views/compareVariants/GenericCompareVariantsPage.astro b/website/src/components/views/compareVariants/GenericCompareVariantsPage.astro index ea9501a99..02332c32d 100644 --- a/website/src/components/views/compareVariants/GenericCompareVariantsPage.astro +++ b/website/src/components/views/compareVariants/GenericCompareVariantsPage.astro @@ -1,15 +1,8 @@ --- -import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; - import { GenericCompareVariantsReactPage } from './GenericCompareVariantsReactPage'; -import { BackendService } from '../../../backendApi/backendService.ts'; -import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; +import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { getInstanceLogger } from '../../../logger.ts'; -import { getDbIdSpace } from '../../../types/dbIdSpace'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; -import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; -import type { OrganismConstants } from '../../../views/OrganismConstants'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareVariantsViewKey } from '../../../views/viewKeys'; @@ -22,22 +15,7 @@ const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareVariantsViewKey}`); -let mutationAnnotations: MutationAnnotations | undefined; -const constants: OrganismConstants = view.organismConstants; -const buildCollections = constants.buildResistanceMutationCollections; -if (buildCollections) { - try { - const backendService = new BackendService(getBackendHost()); - mutationAnnotations = await fetchMutationAnnotationsFromCollections( - buildCollections(getDbIdSpace()), - backendService, - ); - } catch (error) { - getInstanceLogger('GenericCompareVariantsPage').error( - `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, - ); - } -} +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- diff --git a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsPage.astro b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsPage.astro index cf2d6c6af..322033fba 100644 --- a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsPage.astro +++ b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsPage.astro @@ -1,15 +1,8 @@ --- -import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; - import { GenericSequencingEffortsReactPage } from './GenericSequencingEffortsReactPage'; -import { BackendService } from '../../../backendApi/backendService.ts'; -import { isStaging, getDashboardsConfig, getBackendHost } from '../../../config'; +import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { getInstanceLogger } from '../../../logger.ts'; -import { getDbIdSpace } from '../../../types/dbIdSpace'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage'; -import { fetchMutationAnnotationsFromCollections } from '../../../util/resistanceMutations'; -import type { OrganismConstants } from '../../../views/OrganismConstants'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { sequencingEffortsViewKey } from '../../../views/viewKeys'; @@ -22,22 +15,7 @@ const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${sequencingEffortsViewKey}`); -let mutationAnnotations: MutationAnnotations | undefined; -const constants: OrganismConstants = view.organismConstants; -const buildCollections = constants.buildResistanceMutationCollections; -if (buildCollections) { - try { - const backendService = new BackendService(getBackendHost()); - mutationAnnotations = await fetchMutationAnnotationsFromCollections( - buildCollections(getDbIdSpace()), - backendService, - ); - } catch (error) { - getInstanceLogger('GenericSequencingEffortsPage').error( - `Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`, - ); - } -} +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- diff --git a/website/src/util/fetchOrganismMutationAnnotations.ts b/website/src/util/fetchOrganismMutationAnnotations.ts new file mode 100644 index 000000000..ab74bfded --- /dev/null +++ b/website/src/util/fetchOrganismMutationAnnotations.ts @@ -0,0 +1,30 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import { BackendService } from '../backendApi/backendService.ts'; +import { getBackendHost } from '../config'; +import { getInstanceLogger } from '../logger.ts'; +import { getErrorLogMessage } from './getErrorLogMessage'; +import { fetchMutationAnnotationsFromCollections } from './resistanceMutations'; +import { getDbIdSpace } from '../types/dbIdSpace'; +import type { OrganismConstants } from '../views/OrganismConstants'; + +// Used by the five generic Astro page components to fetch resistance mutation +// annotations server-side before rendering, tolerating fetch failures gracefully. +const logger = getInstanceLogger('fetchOrganismMutationAnnotations'); + +export async function fetchOrganismMutationAnnotations( + constants: OrganismConstants, + organism: string, +): Promise { + const buildCollections = constants.buildResistanceMutationCollections; + if (!buildCollections) return undefined; + try { + return await fetchMutationAnnotationsFromCollections( + buildCollections(getDbIdSpace()), + new BackendService(getBackendHost()), + ); + } catch (error) { + logger.error(`Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`); + return undefined; + } +} From a53e8b9d12871b1d039ad337324608ed444ff3c3 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 10:58:44 +0200 Subject: [PATCH 3/9] refactor(website): remove re-export of ResistanceMutationCollectionConfig from wasapPageConfig Consumers should import directly from resistanceMutations.ts; the re-export was unused. Co-Authored-By: Claude Sonnet 4.6 --- website/src/components/views/wasap/wasapPageConfig.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index f1110d18f..d4e05c7f0 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -306,5 +306,3 @@ export type WasapFilter = { base: WasapBaseFilter; analysis: WasapAnalysisFilter; }; - -export type { ResistanceMutationCollectionConfig } from '../../../util/resistanceMutations'; From f8b29cbc4af02007a39998f1cc8095541d25d4f9 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 11:23:51 +0200 Subject: [PATCH 4/9] fix(website): wire resistance mutation annotations into COVID single-variant page The COVID single-variant page uses a custom Astro page that bypasses the generic one, so it was not fetching annotations at all. Add the fetch call and thread the mutationAnnotations prop through CovidSingleVariantReactPage. Co-Authored-By: Claude Sonnet 4.6 --- .../analyzeSingleVariant/CovidSingleVariantReactPage.tsx | 9 ++++++++- website/src/pages/covid/single-variant.astro | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx index 139c6148b..dfc1cebc2 100644 --- a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx +++ b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { CollectionsList } from './CollectionsList.tsx'; @@ -15,9 +16,14 @@ import { usePageState } from '../usePageState.ts'; export type CovidSingleVariantReactPageProps = { organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; -export const CovidSingleVariantReactPage: FC = ({ organismsConfig, isStaging }) => { +export const CovidSingleVariantReactPage: FC = ({ + organismsConfig, + isStaging, + mutationAnnotations, +}) => { const organismViewKey: OrganismViewKey = 'covid.singleVariantView'; const view = useMemo(() => new Routing(organismsConfig).getOrganismView(organismViewKey), [organismsConfig]); @@ -49,6 +55,7 @@ export const CovidSingleVariantReactPage: FC = view={view} downloadLinks={downloadLinks} organismsConfig={organismsConfig} + mutationAnnotations={mutationAnnotations} filters={ <> From c0e72d4d4f6510789e8d9a4e2e1e9deaa87165e1 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 11:36:09 +0200 Subject: [PATCH 5/9] test(website): add unit and E2E tests for resistance mutation annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests cover buildMutationAnnotations directly: empty inputs, the annotationSymbol→symbol rename, query-variant exclusion, missing aminoAcidMutations, and multi-collection ordering. E2E tests verify that COVID, RSV-A, and RSV-B single-variant pages show the expected resistance mutation set names in the filter mutations panel. Co-Authored-By: Claude Sonnet 4.6 --- website/src/util/resistanceMutations.spec.ts | 80 +++++++++++++++++++ .../resistanceMutationAnnotations.spec.ts | 35 ++++++++ 2 files changed, 115 insertions(+) create mode 100644 website/src/util/resistanceMutations.spec.ts create mode 100644 website/tests/resistanceMutationAnnotations.spec.ts diff --git a/website/src/util/resistanceMutations.spec.ts b/website/src/util/resistanceMutations.spec.ts new file mode 100644 index 000000000..01549c8b4 --- /dev/null +++ b/website/src/util/resistanceMutations.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMutationAnnotations } from './resistanceMutations.ts'; +import type { Collection } from '../types/Collection.ts'; + +describe('buildMutationAnnotations', () => { + it('returns empty array for empty inputs', () => { + expect(buildMutationAnnotations([], [])).toEqual([]); + }); + + it('maps annotationSymbol to symbol and preserves name and description', () => { + const config = { collectionId: 1, name: 'Spike', description: 'spike desc', annotationSymbol: 's' }; + const collection = { + id: 1, + name: 'Spike', + variants: [ + { + type: 'filterObject', + name: 'v1', + filterObject: { aminoAcidMutations: ['S:E484K'] }, + }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.name).toBe('Spike'); + expect(annotation.symbol).toBe('s'); + expect(annotation.description).toBe('spike desc'); + }); + + it('excludes query-type variants', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'query', name: 'ignored', countQuery: 'q' }, + { type: 'filterObject', name: 'included', filterObject: { aminoAcidMutations: ['S:E484K'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:E484K', name: 'included' }]); + }); + + it('treats missing aminoAcidMutations as empty', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'filterObject', name: 'no-mutations', filterObject: {} }, + { type: 'filterObject', name: 'has-mutation', filterObject: { aminoAcidMutations: ['S:N501Y'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:N501Y', name: 'has-mutation' }]); + }); + + it('builds one annotation per config, preserving order', () => { + const configs = [ + { collectionId: 1, name: '3CLpro', description: 'protease', annotationSymbol: 'c' }, + { collectionId: 2, name: 'Spike', description: 'spike', annotationSymbol: 's' }, + ]; + const collections = [ + { id: 1, name: '3CLpro', variants: [] } as unknown as Collection, + { id: 2, name: 'Spike', variants: [] } as unknown as Collection, + ]; + + const annotations = buildMutationAnnotations(configs, collections); + + expect(annotations).toHaveLength(2); + expect(annotations[0].name).toBe('3CLpro'); + expect(annotations[1].name).toBe('Spike'); + }); +}); diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts new file mode 100644 index 000000000..e380f1166 --- /dev/null +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; + +import { test } from './e2e.fixture.ts'; + +test.describe('Resistance mutation annotations on regular dashboards', () => { + test.setTimeout(60_000); + + test('COVID single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { + await page.goto('/covid/single-variant'); + + await page.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(page.getByText('3CLpro')).toBeVisible(); + await expect(page.getByText('RdRp')).toBeVisible(); + await expect(page.getByText('Spike')).toBeVisible(); + }); + + test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { + await page.goto('/rsv-a/single-variant'); + + await page.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(page.getByText('Nirsevimab')).toBeVisible(); + await expect(page.getByText('Palivizumab')).toBeVisible(); + }); + + test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { + await page.goto('/rsv-b/single-variant'); + + await page.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(page.getByText('Nirsevimab')).toBeVisible(); + await expect(page.getByText('Palivizumab')).toBeVisible(); + }); +}); From a3baa39fe0c1c8ba15fc4d06529501e8271f20de Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 14:50:43 +0200 Subject: [PATCH 6/9] fix(tests): select a variant before checking filter mutations panel in E2E tests The mutations-over-time component (which contains the Filter mutations button) only renders after a variant has been selected and filters applied. The previous tests clicked the button immediately on page load before the component existed. Co-Authored-By: Claude Sonnet 4.6 --- .../resistanceMutationAnnotations.spec.ts | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts index e380f1166..2fd3c571f 100644 --- a/website/tests/resistanceMutationAnnotations.spec.ts +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -1,35 +1,55 @@ import { expect } from '@playwright/test'; import { test } from './e2e.fixture.ts'; +import { organismOptions } from './helpers/organisms.ts'; +import { Organisms } from '../src/types/Organism.ts'; test.describe('Resistance mutation annotations on regular dashboards', () => { test.setTimeout(60_000); - test('COVID single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { - await page.goto('/covid/single-variant'); + test('COVID single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.covid]; + await singleVariantPage.goto(Organisms.covid); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); - await page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); - await expect(page.getByText('3CLpro')).toBeVisible(); - await expect(page.getByText('RdRp')).toBeVisible(); - await expect(page.getByText('Spike')).toBeVisible(); + await expect(singleVariantPage.page.getByText('3CLpro')).toBeVisible(); + await expect(singleVariantPage.page.getByText('RdRp')).toBeVisible(); + await expect(singleVariantPage.page.getByText('Spike')).toBeVisible(); }); - test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { - await page.goto('/rsv-a/single-variant'); + test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvA]; + await singleVariantPage.goto(Organisms.rsvA); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); - await page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); - await expect(page.getByText('Nirsevimab')).toBeVisible(); - await expect(page.getByText('Palivizumab')).toBeVisible(); + await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); + await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); }); - test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ page }) => { - await page.goto('/rsv-b/single-variant'); + test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvB]; + await singleVariantPage.goto(Organisms.rsvB); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); - await page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); - await expect(page.getByText('Nirsevimab')).toBeVisible(); - await expect(page.getByText('Palivizumab')).toBeVisible(); + await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); + await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); }); }); From 8f090bd2c38521106f138f9c6a78cddbca22a336 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 15:24:37 +0200 Subject: [PATCH 7/9] fix(tests): use .first() on filter mutations button to avoid strict mode violation Both the nucleotide and amino acid mutations-over-time components render a Filter mutations button. Both show the same annotations so clicking either is sufficient; .first() avoids Playwright's strict mode error on ambiguous locators. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/resistanceMutationAnnotations.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts index 2fd3c571f..d417fbb4b 100644 --- a/website/tests/resistanceMutationAnnotations.spec.ts +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -16,7 +16,10 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page + .getByRole('button', { name: /filter mutations/i }) + .first() + .click(); await expect(singleVariantPage.page.getByText('3CLpro')).toBeVisible(); await expect(singleVariantPage.page.getByText('RdRp')).toBeVisible(); @@ -32,7 +35,10 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page + .getByRole('button', { name: /filter mutations/i }) + .first() + .click(); await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); @@ -47,7 +53,10 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page.getByRole('button', { name: /filter mutations/i }).click(); + await singleVariantPage.page + .getByRole('button', { name: /filter mutations/i }) + .first() + .click(); await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); From 6d976c35dfd6be3a7adf08d7efe960a923198cf0 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Jul 2026 16:09:07 +0200 Subject: [PATCH 8/9] fix(tests): scope filter mutations assertions to the opened gs-mutations-over-time panel Both nuc and AA components render their filter labels in the DOM at all times (overflow-clipped when closed), so page-level getByText finds both. Scope the button click and text assertions to the first gs-mutations-over-time element to avoid strict mode violations. Co-Authored-By: Claude Sonnet 4.6 --- .../resistanceMutationAnnotations.spec.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts index d417fbb4b..fc94b3dfe 100644 --- a/website/tests/resistanceMutationAnnotations.spec.ts +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -16,14 +16,12 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page - .getByRole('button', { name: /filter mutations/i }) - .first() - .click(); + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); - await expect(singleVariantPage.page.getByText('3CLpro')).toBeVisible(); - await expect(singleVariantPage.page.getByText('RdRp')).toBeVisible(); - await expect(singleVariantPage.page.getByText('Spike')).toBeVisible(); + await expect(panel.getByText('3CLpro')).toBeVisible(); + await expect(panel.getByText('RdRp')).toBeVisible(); + await expect(panel.getByText('Spike')).toBeVisible(); }); test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ @@ -35,13 +33,11 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page - .getByRole('button', { name: /filter mutations/i }) - .first() - .click(); + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); - await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); - await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); }); test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ @@ -53,12 +49,10 @@ test.describe('Resistance mutation annotations on regular dashboards', () => { await singleVariantPage.selectVariant(options); await singleVariantPage.submitFilters(); - await singleVariantPage.page - .getByRole('button', { name: /filter mutations/i }) - .first() - .click(); + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); - await expect(singleVariantPage.page.getByText('Nirsevimab')).toBeVisible(); - await expect(singleVariantPage.page.getByText('Palivizumab')).toBeVisible(); + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); }); }); From b71d4870919cd8b49e4112720624f4a15ed8150a Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 30 Jul 2026 10:54:28 +0200 Subject: [PATCH 9/9] fix review stuff --- website/src/util/fetchOrganismMutationAnnotations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/util/fetchOrganismMutationAnnotations.ts b/website/src/util/fetchOrganismMutationAnnotations.ts index ab74bfded..51beee493 100644 --- a/website/src/util/fetchOrganismMutationAnnotations.ts +++ b/website/src/util/fetchOrganismMutationAnnotations.ts @@ -8,8 +8,6 @@ import { fetchMutationAnnotationsFromCollections } from './resistanceMutations'; import { getDbIdSpace } from '../types/dbIdSpace'; import type { OrganismConstants } from '../views/OrganismConstants'; -// Used by the five generic Astro page components to fetch resistance mutation -// annotations server-side before rendering, tolerating fetch failures gracefully. const logger = getInstanceLogger('fetchOrganismMutationAnnotations'); export async function fetchOrganismMutationAnnotations( @@ -17,7 +15,9 @@ export async function fetchOrganismMutationAnnotations( organism: string, ): Promise { const buildCollections = constants.buildResistanceMutationCollections; - if (!buildCollections) return undefined; + if (!buildCollections) { + return undefined; + } try { return await fetchMutationAnnotationsFromCollections( buildCollections(getDbIdSpace()),