From e4c5318979b07154cf62aa92e56c2fefd0104047 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 28 Apr 2026 15:58:43 +0200 Subject: [PATCH 01/17] feat(backend): add script to seed resistance mutation collections Creates three COVID collections (3CLpro, RdRp, Spike mAb) from the resistance mutation sets defined in resistanceMutations.ts, each with one filterObject variant per individual mutation. Co-Authored-By: Claude Sonnet 4.6 --- .../create-resistance-mutation-collections.sh | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100755 backend/create-resistance-mutation-collections.sh diff --git a/backend/create-resistance-mutation-collections.sh b/backend/create-resistance-mutation-collections.sh new file mode 100755 index 00000000..1217cb5a --- /dev/null +++ b/backend/create-resistance-mutation-collections.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8080}" +USER_ID="${1:-testuser}" + +create_collection() { + local name="$1" + local body="$2" + local body_out http_code + local tmp + tmp=$(mktemp) + http_code=$(curl -s -o "$tmp" -w "%{http_code}" -X POST \ + "${BASE_URL}/collections?userId=${USER_ID}" \ + -H "Content-Type: application/json" \ + -d "$body") + body_out=$(cat "$tmp") + rm -f "$tmp" + if [ "$http_code" = "201" ]; then + local id + id=$(echo "$body_out" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + echo " OK id=$id $name" + else + echo " FAIL ($http_code) $name: $body_out" + exit 1 + fi +} + +# Build a JSON variants array from mutation strings passed as arguments. +# Each mutation becomes one filterObject variant. +build_variants_json() { + local variants="[]" + for mutation in "$@"; do + variants=$(jq -n \ + --argjson existing "$variants" \ + --arg name "$mutation" \ + '$existing + [{"type": "filterObject", "name": $name, "filterObject": {"aminoAcidMutations": [$name]}}]') + done + echo "$variants" +} + +echo "Creating resistance mutation collections against $BASE_URL as user '$USER_ID'..." +echo + +# --- 3CLpro --- +CLPRO_MUTATIONS=( + "ORF1a:T3284I" "ORF1a:T3288A" "ORF1a:T3288N" "ORF1a:T3308I" "ORF1a:D3311Y" + "ORF1a:M3312I" "ORF1a:M3312L" "ORF1a:M3312T" "ORF1a:M3312-" "ORF1a:L3313F" + "ORF1a:G3401S" "ORF1a:F3403L" "ORF1a:F3403S" "ORF1a:N3405D" "ORF1a:N3405L" + "ORF1a:N3405S" "ORF1a:G3406S" "ORF1a:S3407A" "ORF1a:S3407E" "ORF1a:S3407L" + "ORF1a:S3407P" "ORF1a:C3423F" "ORF1a:M3428R" "ORF1a:M3428T" "ORF1a:E3429A" + "ORF1a:E3429G" "ORF1a:E3429K" "ORF1a:E3429Q" "ORF1a:E3429V" "ORF1a:L3430F" + "ORF1a:P3431-" "ORF1a:T3432I" "ORF1a:H3435L" "ORF1a:H3435N" "ORF1a:H3435Q" + "ORF1a:H3435Y" "ORF1a:A3436T" "ORF1a:A3436V" "ORF1a:V3449A" "ORF1a:R3451G" + "ORF1a:R3451S" "ORF1a:Q3452I" "ORF1a:Q3452K" "ORF1a:T3453I" "ORF1a:A3454T" + "ORF1a:A3454V" "ORF1a:Q3455A" "ORF1a:Q3455C" "ORF1a:Q3455D" "ORF1a:Q3455E" + "ORF1a:Q3455F" "ORF1a:Q3455G" "ORF1a:Q3455H" "ORF1a:Q3455I" "ORF1a:Q3455K" + "ORF1a:Q3455L" "ORF1a:Q3455N" "ORF1a:Q3455P" "ORF1a:Q3455R" "ORF1a:Q3455S" + "ORF1a:Q3455T" "ORF1a:Q3455V" "ORF1a:Q3455W" "ORF1a:Q3455Y" "ORF1a:A3456P" + "ORF1a:A3457S" "ORF1a:P3515L" "ORF1a:V3560A" "ORF1a:S3564P" "ORF1a:T3567I" + "ORF1a:F3568L" +) +variants_json=$(build_variants_json "${CLPRO_MUTATIONS[@]}") +body=$(jq -n \ + --argjson variants "$variants_json" \ + '{ + "name": "3CLpro resistance mutations", + "organism": "covid", + "description": "SARS-CoV-2 3C-like protease (3CLpro/Mpro) inhibitor resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", + "variants": $variants + }') +create_collection "3CLpro resistance mutations" "$body" + +# --- RdRp --- +RDRP_MUTATIONS=( + "ORF1b:V157A" "ORF1b:V157L" "ORF1b:N189S" "ORF1b:R276C" "ORF1b:A367V" + "ORF1b:A440V" "ORF1b:F471L" "ORF1b:D475Y" "ORF1b:A517V" "ORF1b:V548L" + "ORF1b:G662S" "ORF1b:S750A" "ORF1b:V783I" "ORF1b:E787G" "ORF1b:C790F" + "ORF1b:C790R" "ORF1b:E793A" "ORF1b:E793D" "ORF1b:M915R" +) +variants_json=$(build_variants_json "${RDRP_MUTATIONS[@]}") +body=$(jq -n \ + --argjson variants "$variants_json" \ + '{ + "name": "RdRp resistance mutations", + "organism": "covid", + "description": "SARS-CoV-2 RNA-dependent RNA polymerase (RdRp) inhibitor resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", + "variants": $variants + }') +create_collection "RdRp resistance mutations" "$body" + +# --- Spike --- +SPIKE_MUTATIONS=( + "S:P337H" "S:P337L" "S:P337R" "S:P337S" "S:P337T" + "S:E340A" "S:E340D" "S:E340G" "S:E340K" "S:E340Q" "S:E340V" + "S:T345P" + "S:R346G" "S:R346I" "S:R346K" "S:R346S" "S:R346T" + "S:K356Q" "S:K356T" + "S:S371F" "S:S371L" + "S:D405E" "S:D405N" "S:E406D" + "S:K417E" "S:K417H" "S:K417I" "S:K417M" "S:K417N" "S:K417R" "S:K417S" "S:K417T" + "S:D420A" "S:D420N" + "S:N439K" + "S:N440D" "S:N440E" "S:N440I" "S:N440K" "S:N440R" "S:N440T" "S:N440Y" + "S:S443Y" + "S:K444E" "S:K444F" "S:K444I" "S:K444L" "S:K444M" "S:K444N" "S:K444R" "S:K444T" + "S:V445A" "S:V445D" "S:V445F" "S:V445I" "S:V445L" + "S:G446A" "S:G446D" "S:G446I" "S:G446N" "S:G446R" "S:G446S" "S:G446T" "S:G446V" + "S:G447C" "S:G447D" "S:G447F" "S:G447S" "S:G447V" + "S:N448D" "S:N448K" "S:N448T" "S:N448Y" + "S:Y449D" + "S:N450D" "S:N450K" + "S:L452M" "S:L452Q" "S:L452R" "S:L452W" + "S:Y453F" "S:Y453H" + "S:L455F" "S:L455M" "S:L455S" "S:L455W" + "S:F456C" "S:F456L" "S:F456V" + "S:S459P" + "S:N460D" "S:N460H" "S:N460I" "S:N460K" "S:N460S" "S:N460T" "S:N460Y" + "S:A475D" "S:A475V" + "S:G476D" "S:G476R" "S:G476T" + "S:V483A" + "S:E484A" "S:E484D" "S:E484G" "S:E484K" "S:E484P" "S:E484Q" "S:E484R" "S:E484S" "S:E484T" "S:E484V" + "S:G485D" "S:G485R" + "S:F486D" "S:F486I" "S:F486L" "S:F486N" "S:F486P" "S:F486S" "S:F486T" "S:F486V" + "S:N487D" "S:N487H" "S:N487S" + "S:Y489H" "S:Y489W" + "S:F490G" "S:F490I" "S:F490L" "S:F490R" "S:F490S" "S:F490V" "S:F490Y" + "S:Q493D" "S:Q493E" "S:Q493H" "S:Q493K" "S:Q493L" "S:Q493R" "S:Q493V" + "S:S494P" "S:S494R" + "S:G496S" + "S:Q498H" + "S:P499H" "S:P499R" "S:P499S" "S:P499T" + "S:N501T" "S:N501Y" + "S:G504C" "S:G504D" "S:G504I" "S:G504L" "S:G504N" "S:G504R" "S:G504V" + "S:P507A" + "S:N856K" "S:N969K" "S:E990A" "S:T1009I" +) +variants_json=$(build_variants_json "${SPIKE_MUTATIONS[@]}") +body=$(jq -n \ + --argjson variants "$variants_json" \ + '{ + "name": "Spike mAb resistance mutations", + "organism": "covid", + "description": "SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", + "variants": $variants + }') +create_collection "Spike mAb resistance mutations" "$body" + +echo +echo "Done." From 88f9d5a8626e89c6f8a5ffc9a569196b808bcf30 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 28 Apr 2026 17:06:05 +0200 Subject: [PATCH 02/17] initial implementation --- .../wasap/WasapPageStateSelector.tsx | 5 +- ...ResistanceMutationsFilter.browser.spec.tsx | 8 +- .../src/components/views/wasap/WasapPage.tsx | 17 +- .../views/wasap/resistanceMutations.ts | 289 +----------------- .../views/wasap/useResistanceMutationSets.ts | 52 ++++ .../views/wasap/useWasapPageData.ts | 32 +- .../components/views/wasap/wasapPageConfig.ts | 15 +- website/src/types/wastewaterConfig.spec.ts | 2 +- website/src/types/wastewaterConfig.ts | 32 +- .../WasapPageStateHandler.spec.ts | 9 +- 10 files changed, 147 insertions(+), 314 deletions(-) create mode 100644 website/src/components/views/wasap/useResistanceMutationSets.ts diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 72518f4c..330f9689 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -17,6 +17,7 @@ import { Inset } from '../../../styles/Inset'; import { recentDaysDateRangeOptions } from '../../../util/recentDaysDateRangeOptions'; import { type PageStateHandler } from '../../../views/pageStateHandlers/PageStateHandler'; import { GsTextFilter } from '../../genspectrum/GsTextFilter'; +import type { ResistanceMutationSet } from '../../views/wasap/resistanceMutations'; import { enabledAnalysisModes, type WasapAnalysisFilter, @@ -32,12 +33,14 @@ import { */ export function WasapPageStateSelector({ config, + resistanceMutationSets, pageStateHandler, initialBaseFilterState, initialAnalysisFilterState, setPageState, }: { config: WasapPageConfig; + resistanceMutationSets: ResistanceMutationSet[]; pageStateHandler: PageStateHandler; initialBaseFilterState: WasapBaseFilter; initialAnalysisFilterState: WasapAnalysisFilter; @@ -189,7 +192,7 @@ export function WasapPageStateSelector({ ); case 'untracked': diff --git a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx index 0c56e860..1a187803 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx @@ -3,7 +3,7 @@ import { render } from 'vitest-browser-react'; import { ResistanceMutationsFilter } from './ResistanceMutationsFilter'; import { it } from '../../../../../test-extend'; -import { covidResistanceMutations } from '../../../views/wasap/resistanceMutations'; +import type { ResistanceMutationSet } from '../../../views/wasap/resistanceMutations'; import type { WasapResistanceFilter } from '../../../views/wasap/wasapPageConfig'; describe('ResistanceMutationsFilter', () => { @@ -13,7 +13,11 @@ describe('ResistanceMutationsFilter', () => { resistanceSet: '3CLpro', }; - const resistanceMutationSets = covidResistanceMutations; + const resistanceMutationSets: ResistanceMutationSet[] = [ + { name: '3CLpro', annotationSymbol: 'c', description: '', mutations: [], offset: -3263 }, + { name: 'RdRp', annotationSymbol: 'r', description: '', mutations: [], offset: 9 }, + { name: 'Spike', annotationSymbol: 's', description: '', mutations: [], offset: 0 }, + ]; it('renders with initial resistance set', async () => { const mockSetPageState = vi.fn(); diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 48373be5..82e13cab 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -7,6 +7,7 @@ import { NoDataHelperText } from './components/NoDataHelperText'; import { VariantFetchInfo } from './components/VariantFetchInfo'; import { WasapStats } from './components/WasapStats'; import { toMutationAnnotations } from './resistanceMutations'; +import { useResistanceMutationSets } from './useResistanceMutationSets'; import { useWasapPageData } from './useWasapPageData'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs.tsx'; @@ -38,8 +39,12 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { setPageState, } = usePageState(pageStateHandler); + const { data: resistanceMutationSets = [] } = useResistanceMutationSets(config); + // TODO - we can probably optimize this, because this call above and the one below could be + // parallelized + // fetch which mutations should be analyzed - const { data, isPending, isError } = useWasapPageData(config, analysis); + const { data, isPending, isError } = useWasapPageData(config, resistanceMutationSets, analysis); let initialMeanProportionInterval: MeanProportionInterval = { min: 0.0, max: 1.0 }; if (analysis.mode === 'manual' && analysis.mutations === undefined) { @@ -53,13 +58,8 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { }; const memoizedMutationAnnotations = useMemo( - () => - config.resistanceAnalysisModeEnabled - ? config.resistanceMutationSets.flatMap((resistanceMutation) => - toMutationAnnotations(resistanceMutation), - ) - : [], - [config], + () => resistanceMutationSets.flatMap(toMutationAnnotations), + [resistanceMutationSets], ); return ( @@ -88,6 +88,7 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { initialBaseFilterState={base} initialAnalysisFilterState={analysis} setPageState={setPageState} + resistanceMutationSets={resistanceMutationSets} /> {isError ? ( diff --git a/website/src/components/views/wasap/resistanceMutations.ts b/website/src/components/views/wasap/resistanceMutations.ts index cb513ef1..130f01ed 100644 --- a/website/src/components/views/wasap/resistanceMutations.ts +++ b/website/src/components/views/wasap/resistanceMutations.ts @@ -1,5 +1,10 @@ import { type MutationAnnotations } from '@genspectrum/dashboard-components/util'; +// TODO - revisit this file. Can we get rid of more in here? Move all the functions elsewhere? +// Do we still need the ResistanceMutationSet type even? Because we have a different type in +// the config now, and there's quite a bit of overlap. Would be nice to maybe make them more distinct, +// or have only one type left, or comment why we need one and the other. + export type ResistanceMutationSet = { name: string; annotationSymbol: string; @@ -8,290 +13,6 @@ export type ResistanceMutationSet = { offset: number; }; -const fetchSnippet = - 'as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).'; - -export const covidResistanceMutations: ResistanceMutationSet[] = [ - { - name: '3CLpro', - annotationSymbol: 'c', - description: `SARS-CoV-2 3C-like protease (3CLpro, or Mpro for Main protease) inhibitor resistance mutation ${fetchSnippet}`, - mutations: [ - 'ORF1a:T3284I', - 'ORF1a:T3288A', - 'ORF1a:T3288N', - 'ORF1a:T3308I', - 'ORF1a:D3311Y', - 'ORF1a:M3312I', - 'ORF1a:M3312L', - 'ORF1a:M3312T', - 'ORF1a:M3312-', - 'ORF1a:L3313F', - 'ORF1a:G3401S', - 'ORF1a:F3403L', - 'ORF1a:F3403S', - 'ORF1a:N3405D', - 'ORF1a:N3405L', - 'ORF1a:N3405S', - 'ORF1a:G3406S', - 'ORF1a:S3407A', - 'ORF1a:S3407E', - 'ORF1a:S3407L', - 'ORF1a:S3407P', - 'ORF1a:C3423F', - 'ORF1a:M3428R', - 'ORF1a:M3428T', - 'ORF1a:E3429A', - 'ORF1a:E3429G', - 'ORF1a:E3429K', - 'ORF1a:E3429Q', - 'ORF1a:E3429V', - 'ORF1a:L3430F', - 'ORF1a:P3431-', - 'ORF1a:T3432I', - 'ORF1a:H3435L', - 'ORF1a:H3435N', - 'ORF1a:H3435Q', - 'ORF1a:H3435Y', - 'ORF1a:A3436T', - 'ORF1a:A3436V', - 'ORF1a:V3449A', - 'ORF1a:R3451G', - 'ORF1a:R3451S', - 'ORF1a:Q3452I', - 'ORF1a:Q3452K', - 'ORF1a:T3453I', - 'ORF1a:A3454T', - 'ORF1a:A3454V', - 'ORF1a:Q3455A', - 'ORF1a:Q3455C', - 'ORF1a:Q3455D', - 'ORF1a:Q3455E', - 'ORF1a:Q3455F', - 'ORF1a:Q3455G', - 'ORF1a:Q3455H', - 'ORF1a:Q3455I', - 'ORF1a:Q3455K', - 'ORF1a:Q3455L', - 'ORF1a:Q3455N', - 'ORF1a:Q3455P', - 'ORF1a:Q3455R', - 'ORF1a:Q3455S', - 'ORF1a:Q3455T', - 'ORF1a:Q3455V', - 'ORF1a:Q3455W', - 'ORF1a:Q3455Y', - 'ORF1a:A3456P', - 'ORF1a:A3457S', - 'ORF1a:P3515L', - 'ORF1a:V3560A', - 'ORF1a:S3564P', - 'ORF1a:T3567I', - 'ORF1a:F3568L', - ], - offset: -3263, - }, - { - name: 'RdRp', - annotationSymbol: 'r', - description: `SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation ${fetchSnippet}`, - mutations: [ - 'ORF1b:V157A', - 'ORF1b:V157L', - 'ORF1b:N189S', - 'ORF1b:R276C', - 'ORF1b:A367V', - 'ORF1b:A440V', - 'ORF1b:F471L', - 'ORF1b:D475Y', - 'ORF1b:A517V', - 'ORF1b:V548L', - 'ORF1b:G662S', - 'ORF1b:S750A', - 'ORF1b:V783I', - 'ORF1b:E787G', - 'ORF1b:C790F', - 'ORF1b:C790R', - 'ORF1b:E793A', - 'ORF1b:E793D', - 'ORF1b:M915R', - ], - offset: 9, - }, - { - name: 'Spike', - annotationSymbol: 's', - description: `SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation ${fetchSnippet}`, - mutations: [ - 'S:P337H', - 'S:P337L', - 'S:P337R', - 'S:P337S', - 'S:P337T', - 'S:E340A', - 'S:E340D', - 'S:E340G', - 'S:E340K', - 'S:E340Q', - 'S:E340V', - 'S:T345P', - 'S:R346G', - 'S:R346I', - 'S:R346K', - 'S:R346S', - 'S:R346T', - 'S:K356Q', - 'S:K356T', - 'S:S371F', - 'S:S371L', - 'S:D405E', - 'S:D405N', - 'S:E406D', - 'S:K417E', - 'S:K417H', - 'S:K417I', - 'S:K417M', - 'S:K417N', - 'S:K417R', - 'S:K417S', - 'S:K417T', - 'S:D420A', - 'S:D420N', - 'S:N439K', - 'S:N440D', - 'S:N440E', - 'S:N440I', - 'S:N440K', - 'S:N440R', - 'S:N440T', - 'S:N440Y', - 'S:S443Y', - 'S:K444E', - 'S:K444F', - 'S:K444I', - 'S:K444L', - 'S:K444M', - 'S:K444N', - 'S:K444R', - 'S:K444T', - 'S:V445A', - 'S:V445D', - 'S:V445F', - 'S:V445I', - 'S:V445L', - 'S:G446A', - 'S:G446D', - 'S:G446I', - 'S:G446N', - 'S:G446R', - 'S:G446S', - 'S:G446T', - 'S:G446V', - 'S:G447C', - 'S:G447D', - 'S:G447F', - 'S:G447S', - 'S:G447V', - 'S:N448D', - 'S:N448K', - 'S:N448T', - 'S:N448Y', - 'S:Y449D', - 'S:N450D', - 'S:N450K', - 'S:L452M', - 'S:L452Q', - 'S:L452R', - 'S:L452W', - 'S:Y453F', - 'S:Y453H', - 'S:L455F', - 'S:L455M', - 'S:L455S', - 'S:L455W', - 'S:F456C', - 'S:F456L', - 'S:F456V', - 'S:S459P', - 'S:N460D', - 'S:N460H', - 'S:N460I', - 'S:N460K', - 'S:N460S', - 'S:N460T', - 'S:N460Y', - 'S:A475D', - 'S:A475V', - 'S:G476D', - 'S:G476R', - 'S:G476T', - 'S:V483A', - 'S:E484A', - 'S:E484D', - 'S:E484G', - 'S:E484K', - 'S:E484P', - 'S:E484Q', - 'S:E484R', - 'S:E484S', - 'S:E484T', - 'S:E484V', - 'S:G485D', - 'S:G485R', - 'S:F486D', - 'S:F486I', - 'S:F486L', - 'S:F486N', - 'S:F486P', - 'S:F486S', - 'S:F486T', - 'S:F486V', - 'S:N487D', - 'S:N487H', - 'S:N487S', - 'S:Y489H', - 'S:Y489W', - 'S:F490G', - 'S:F490I', - 'S:F490L', - 'S:F490R', - 'S:F490S', - 'S:F490V', - 'S:F490Y', - 'S:Q493D', - 'S:Q493E', - 'S:Q493H', - 'S:Q493K', - 'S:Q493L', - 'S:Q493R', - 'S:Q493V', - 'S:S494P', - 'S:S494R', - 'S:G496S', - 'S:Q498H', - 'S:P499H', - 'S:P499R', - 'S:P499S', - 'S:P499T', - 'S:N501T', - 'S:N501Y', - 'S:G504C', - 'S:G504D', - 'S:G504I', - 'S:G504L', - 'S:G504N', - 'S:G504R', - 'S:G504V', - 'S:P507A', - 'S:N856K', - 'S:N969K', - 'S:E990A', - 'S:T1009I', - ], - offset: 0, - }, -]; - /** * Maps a code like ORF1a:T1234:A to RdPd:T56A, by replacing the gene with the mature name * and adjusting the position with the given offset. diff --git a/website/src/components/views/wasap/useResistanceMutationSets.ts b/website/src/components/views/wasap/useResistanceMutationSets.ts new file mode 100644 index 00000000..516d6ec0 --- /dev/null +++ b/website/src/components/views/wasap/useResistanceMutationSets.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { ResistanceMutationSet } from './resistanceMutations'; +import type { ResistanceMutationCollectionConfig, WasapPageConfig } from './wasapPageConfig'; +import { getBackendServiceForClientside } from '../../../backendApi/backendService'; +import type { Collection } from '../../../types/Collection'; + +/** + * Fetches resistance mutation sets from the backend collections API. + * Each set corresponds to one collection (e.g. 3CLpro, RdRp, Spike), with the mutation list + * derived from the filterObject variants in that collection. Results are cached indefinitely + * for the lifetime of the page, since resistance mutation lists change infrequently. + */ +export function useResistanceMutationSets(config: WasapPageConfig) { + return useQuery({ + queryKey: [ + 'resistanceCollections', + config.resistanceAnalysisModeEnabled ? config.resistanceMutationCollections.map((s) => s.collectionId) : [], + ], + queryFn: async (): Promise => { + if (!config.resistanceAnalysisModeEnabled) return []; + const backendService = getBackendServiceForClientside(); + const collections = await Promise.all( + config.resistanceMutationCollections.map((setConfig) => + backendService.getCollection({ id: String(setConfig.collectionId) }), + ), + ); + return config.resistanceMutationCollections.map((setConfig, i) => + collectionToResistanceMutationSet(setConfig, collections[i]), + ); + }, + staleTime: Infinity, + enabled: config.resistanceAnalysisModeEnabled === true, + }); +} + +function collectionToResistanceMutationSet( + config: ResistanceMutationCollectionConfig, + collection: Collection, +): ResistanceMutationSet { + const mutations = collection.variants.flatMap((variant) => + variant.type === 'filterObject' ? (variant.filterObject.aminoAcidMutations ?? []) : [], + ); + // TODO - maybe we can sort the mutations? Not sure if they are sorted already ... + return { + name: config.name, + annotationSymbol: config.annotationSymbol, + description: config.description, + offset: config.offset, + mutations, + }; +} diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 2eb629e2..af5372a6 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -2,6 +2,7 @@ import type { CustomColumn, LapisFilter } from '@genspectrum/dashboard-component import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; +import type { ResistanceMutationSet } from './resistanceMutations'; import type { VariantTimeFrame, WasapAnalysisFilter, @@ -22,22 +23,32 @@ import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; /** * Hook that fetches and returns `WasapPageData` for the W-ASAP page, * depending on the analysis mode and analysis mode settings. + * The resistanceMutationSets are needed since that is also a possible + * analysis mode. */ -export function useWasapPageData(config: WasapPageConfig, analysis: WasapAnalysisFilter) { +export function useWasapPageData( + config: WasapPageConfig, + resistanceMutationSets: ResistanceMutationSet[], + analysis: WasapAnalysisFilter, +) { return useQuery({ - queryKey: ['wasap', analysis], - queryFn: () => fetchWasapPageData(config, analysis), + queryKey: ['wasap', analysis, JSON.stringify(resistanceMutationSets)], + queryFn: () => fetchWasapPageData(config, analysis, resistanceMutationSets), }); } -async function fetchWasapPageData(config: WasapPageConfig, analysis: WasapAnalysisFilter): Promise { +async function fetchWasapPageData( + config: WasapPageConfig, + analysis: WasapAnalysisFilter, + resistanceMutationSets: ResistanceMutationSet[], +): Promise { switch (analysis.mode) { case 'manual': return fetchManualModeData(config, analysis); case 'variant': return fetchVariantModeData(config, analysis); case 'resistance': - return fetchResistanceModeData(config, analysis); + return fetchResistanceModeData(resistanceMutationSets, analysis); case 'untracked': return fetchUntrackedModeData(config, analysis); case 'collection': @@ -87,14 +98,13 @@ async function fetchVariantModeData( }; } -function fetchResistanceModeData(config: WasapPageConfig, analysis: WasapResistanceFilter): WasapMutationsData { - if (!config.resistanceAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'resistance' mode is not enabled."); - } +function fetchResistanceModeData( + resistanceMutationSets: ResistanceMutationSet[], + analysis: WasapResistanceFilter, +): WasapMutationsData { return { type: 'mutations', - displayMutations: - config.resistanceMutationSets.find((set) => set.name === analysis.resistanceSet)?.mutations ?? [], + displayMutations: resistanceMutationSets.find((set) => set.name === analysis.resistanceSet)?.mutations ?? [], }; } diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 65ec8bf2..48be70c8 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -1,7 +1,5 @@ import type { DateRangeOption, SequenceType, TemporalGranularity } from '@genspectrum/dashboard-components/util'; -import type { ResistanceMutationSet } from './resistanceMutations'; - /** * All config settings for a W-ASAP dashboard page. */ @@ -87,7 +85,7 @@ type ResistanceAnalysisModeConfig = } | { resistanceAnalysisModeEnabled: true; - resistanceMutationSets: ResistanceMutationSet[]; + resistanceMutationCollections: ResistanceMutationCollectionConfig[]; filterDefaults: { resistance: WasapResistanceFilter; }; @@ -236,3 +234,14 @@ export type WasapFilter = { base: WasapBaseFilter; analysis: WasapAnalysisFilter; }; + +/** + * Resistance mutations defined in a collection, which is specified via the collection ID. + */ +export type ResistanceMutationCollectionConfig = { + collectionId: number; + name: string; // TODO - do we want to pull this from the collection instead? + description: string; // TODO - same as above + annotationSymbol: string; + offset: number; +}; diff --git a/website/src/types/wastewaterConfig.spec.ts b/website/src/types/wastewaterConfig.spec.ts index e1b58e06..93d77433 100644 --- a/website/src/types/wastewaterConfig.spec.ts +++ b/website/src/types/wastewaterConfig.spec.ts @@ -5,7 +5,7 @@ import { wastewaterOrganismConfigs } from './wastewaterConfig'; describe.each(Object.entries(wastewaterOrganismConfigs))('wastewaterConfig %s', (_configName, config) => { test('default resistance set name is valid', () => { if (config.resistanceAnalysisModeEnabled) { - const resistanceSetNames = config.resistanceMutationSets.map((s) => s.name); + const resistanceSetNames = config.resistanceMutationCollections.map((s) => s.name); const defaultSetName = config.filterDefaults.resistance.resistanceSet; expect(resistanceSetNames).include(defaultSetName); } diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index bbca9923..69a2b34d 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -1,6 +1,6 @@ import type { MutationAnnotation } from '@genspectrum/dashboard-components/util'; -import { covidResistanceMutations } from '../components/views/wasap/resistanceMutations'; +import type { ResistanceMutationCollectionConfig } from '../components/views/wasap/wasapPageConfig'; import { VARIANT_TIME_FRAME, type WasapPageConfig } from '../components/views/wasap/wasapPageConfig'; export const wastewaterOrganisms = { @@ -29,7 +29,35 @@ export const wastewaterOrganismConfigs: RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + offset: -3263, + }, + { + collectionId: 192, + 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).', + offset: 9, + }, + { + collectionId: 193, + 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).', + offset: 0, + }, + ] satisfies ResistanceMutationCollectionConfig[], lapisBaseUrl: 'https://lapis.wasap.genspectrum.org/covid', samplingDateField: 'samplingDate', locationNameField: 'locationName', diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts index 2747ba69..135c966a 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { WasapPageStateHandler } from './WasapPageStateHandler'; -import { covidResistanceMutations } from '../../components/views/wasap/resistanceMutations'; import { VARIANT_TIME_FRAME, type WasapCollectionFilter, @@ -26,7 +25,13 @@ const config: WasapPageConfig = { variantAnalysisModeEnabled: true, resistanceAnalysisModeEnabled: true, untrackedAnalysisModeEnabled: true, - resistanceMutationSets: covidResistanceMutations, + resistanceMutationCollections: [ + // TODO - can we leave these hardcoded IDs here? Probably not? + // For the test we probably need some sort of setup so the collections are present? + { name: '3CLpro', annotationSymbol: 'c', description: '', offset: -3263, collectionId: 191 }, + { name: 'RdRp', annotationSymbol: 'r', description: '', offset: 9, collectionId: 192 }, + { name: 'Spike', annotationSymbol: 's', description: '', offset: 0, collectionId: 193 }, + ], lapisBaseUrl: 'https://lapis.wasap.genspectrum.org', samplingDateField: 'samplingDate', locationNameField: 'locationName', From e0de78cebf550f1d3eb3b83a2b35a5b4e09ea607 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 09:13:25 +0200 Subject: [PATCH 03/17] feat(backend): support session token auth in resistance collections script When SESSION_TOKEN is set, the script hits the website proxy at /api/collections (which injects the user ID from the session) and passes the auth cookie. Automatically uses the __Secure- cookie prefix for HTTPS targets. Co-Authored-By: Claude Sonnet 4.6 --- .../create-resistance-mutation-collections.sh | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/create-resistance-mutation-collections.sh b/backend/create-resistance-mutation-collections.sh index 1217cb5a..d3e7499c 100755 --- a/backend/create-resistance-mutation-collections.sh +++ b/backend/create-resistance-mutation-collections.sh @@ -3,6 +3,10 @@ set -euo pipefail BASE_URL="${BASE_URL:-http://localhost:8080}" USER_ID="${1:-testuser}" +# When SESSION_TOKEN is set, the website proxy injects the user ID — do not set it manually. +# Optional: set SESSION_TOKEN to authenticate against a deployed instance. +# The cookie name must match what the server expects (e.g. __Secure-authjs.session-token for HTTPS). +SESSION_TOKEN="${SESSION_TOKEN:-}" create_collection() { local name="$1" @@ -10,8 +14,25 @@ create_collection() { local body_out http_code local tmp tmp=$(mktemp) + + local cookie_args=() + if [ -n "$SESSION_TOKEN" ]; then + if [[ "$BASE_URL" == https://* ]]; then + cookie_args=(-b "__Secure-authjs.session-token=${SESSION_TOKEN}") + else + cookie_args=(-b "authjs.session-token=${SESSION_TOKEN}") + fi + fi + + if [ -n "$SESSION_TOKEN" ]; then + local url="${BASE_URL}/api/collections" + else + local url="${BASE_URL}/collections?userId=${USER_ID}" + fi + http_code=$(curl -s -o "$tmp" -w "%{http_code}" -X POST \ - "${BASE_URL}/collections?userId=${USER_ID}" \ + "${cookie_args[@]}" \ + "$url" \ -H "Content-Type: application/json" \ -d "$body") body_out=$(cat "$tmp") @@ -39,7 +60,11 @@ build_variants_json() { echo "$variants" } -echo "Creating resistance mutation collections against $BASE_URL as user '$USER_ID'..." +if [ -n "$SESSION_TOKEN" ]; then + echo "Creating resistance mutation collections against $BASE_URL using session token..." +else + echo "Creating resistance mutation collections against $BASE_URL as user '$USER_ID'..." +fi echo # --- 3CLpro --- From 9a95528d5bb0b9b6b717202915b6178c417d4ba1 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 09:34:26 +0200 Subject: [PATCH 04/17] feat(website): source resistance mutations from backend collections Instead of hard-coding mutation lists in the codebase, fetch them at runtime from the backend collections API (one collection per set: 3CLpro, RdRp, Spike). The seed script now uses mature protein names (offset-adjusted) as variant names, so the name calculation is no longer needed in client code. Offset and matureName are removed from the frontend entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../create-resistance-mutation-collections.sh | 49 ++++++++++++++++--- ...ResistanceMutationsFilter.browser.spec.tsx | 6 +-- .../views/wasap/resistanceMutations.ts | 29 +++++------ .../views/wasap/useResistanceMutationSets.ts | 12 +++-- .../views/wasap/useWasapPageData.ts | 5 +- .../components/views/wasap/wasapPageConfig.ts | 1 - website/src/types/wastewaterConfig.ts | 3 -- .../WasapPageStateHandler.spec.ts | 6 +-- 8 files changed, 71 insertions(+), 40 deletions(-) diff --git a/backend/create-resistance-mutation-collections.sh b/backend/create-resistance-mutation-collections.sh index d3e7499c..a7553fae 100755 --- a/backend/create-resistance-mutation-collections.sh +++ b/backend/create-resistance-mutation-collections.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +# This script creates resistance mutation collections. +# By default, it's running against a locally ran backend (localhost:8080), and you should +# set the USER_ID env var, which is then used as the user ID for creating the collections locally. +# You can also set a SESSION_TOKEN which you can take out of the cookie storage of the GenSpectrum +# website when you're logged in (__Secure-authjs.session-token). With this, you can create collections +# on staging or prod. +# +# Example calls: +# +# Local (backend running on :8080): +# USER_ID=myuser ./create-resistance-mutation-collections.sh +# +# Staging (grab __Secure-authjs.session-token from browser DevTools → Application → Cookies): +# BASE_URL=https://staging.genspectrum.org SESSION_TOKEN=eyJhbG... ./create-resistance-mutation-collections.sh + BASE_URL="${BASE_URL:-http://localhost:8080}" USER_ID="${1:-testuser}" # When SESSION_TOKEN is set, the website proxy injects the user ID — do not set it manually. @@ -8,6 +23,9 @@ USER_ID="${1:-testuser}" # The cookie name must match what the server expects (e.g. __Secure-authjs.session-token for HTTPS). SESSION_TOKEN="${SESSION_TOKEN:-}" +# TODO: We need to be able to _update_ a collection with this script. but maybe this can be a follow up issue as well. +# (I.e. we should be able to accept a collection ID which we then update instead of creating new ones all the time) + create_collection() { local name="$1" local body="$2" @@ -47,15 +65,34 @@ create_collection() { fi } +# Converts a genomic mutation code to a mature protein name with the given offset. +# E.g. mature_name "ORF1a:T3284I" "3CLpro" -3263 => "3CLpro:T21I" +mature_name() { + local mutation="$1" set_name="$2" offset="$3" + local mutation_part="${mutation#*:}" + local original_base="${mutation_part:0:1}" + local new_base="${mutation_part: -1}" + local position + position=$(echo "$mutation_part" | grep -o '[0-9]*') + echo "${set_name}:${original_base}$((position + offset))${new_base}" +} + # Build a JSON variants array from mutation strings passed as arguments. -# Each mutation becomes one filterObject variant. +# Usage: build_variants_json [ ...] +# Each mutation becomes one filterObject variant whose display name uses the +# mature protein coordinate (set_name + position adjusted by offset). build_variants_json() { + local set_name="$1" offset="$2" + shift 2 local variants="[]" for mutation in "$@"; do + local display_name + display_name=$(mature_name "$mutation" "$set_name" "$offset") variants=$(jq -n \ --argjson existing "$variants" \ - --arg name "$mutation" \ - '$existing + [{"type": "filterObject", "name": $name, "filterObject": {"aminoAcidMutations": [$name]}}]') + --arg name "$display_name" \ + --arg mutation "$mutation" \ + '$existing + [{"type": "filterObject", "name": $name, "filterObject": {"aminoAcidMutations": [$mutation]}}]') done echo "$variants" } @@ -85,7 +122,7 @@ CLPRO_MUTATIONS=( "ORF1a:A3457S" "ORF1a:P3515L" "ORF1a:V3560A" "ORF1a:S3564P" "ORF1a:T3567I" "ORF1a:F3568L" ) -variants_json=$(build_variants_json "${CLPRO_MUTATIONS[@]}") +variants_json=$(build_variants_json "3CLpro" -3263 "${CLPRO_MUTATIONS[@]}") body=$(jq -n \ --argjson variants "$variants_json" \ '{ @@ -103,7 +140,7 @@ RDRP_MUTATIONS=( "ORF1b:G662S" "ORF1b:S750A" "ORF1b:V783I" "ORF1b:E787G" "ORF1b:C790F" "ORF1b:C790R" "ORF1b:E793A" "ORF1b:E793D" "ORF1b:M915R" ) -variants_json=$(build_variants_json "${RDRP_MUTATIONS[@]}") +variants_json=$(build_variants_json "RdRp" 9 "${RDRP_MUTATIONS[@]}") body=$(jq -n \ --argjson variants "$variants_json" \ '{ @@ -160,7 +197,7 @@ SPIKE_MUTATIONS=( "S:P507A" "S:N856K" "S:N969K" "S:E990A" "S:T1009I" ) -variants_json=$(build_variants_json "${SPIKE_MUTATIONS[@]}") +variants_json=$(build_variants_json "Spike" 0 "${SPIKE_MUTATIONS[@]}") body=$(jq -n \ --argjson variants "$variants_json" \ '{ diff --git a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx index 1a187803..8994726e 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx @@ -14,9 +14,9 @@ describe('ResistanceMutationsFilter', () => { }; const resistanceMutationSets: ResistanceMutationSet[] = [ - { name: '3CLpro', annotationSymbol: 'c', description: '', mutations: [], offset: -3263 }, - { name: 'RdRp', annotationSymbol: 'r', description: '', mutations: [], offset: 9 }, - { name: 'Spike', annotationSymbol: 's', description: '', mutations: [], offset: 0 }, + { name: '3CLpro', annotationSymbol: 'c', description: '', mutations: [] }, + { name: 'RdRp', annotationSymbol: 'r', description: '', mutations: [] }, + { name: 'Spike', annotationSymbol: 's', description: '', mutations: [] }, ]; it('renders with initial resistance set', async () => { diff --git a/website/src/components/views/wasap/resistanceMutations.ts b/website/src/components/views/wasap/resistanceMutations.ts index 130f01ed..1d7d9855 100644 --- a/website/src/components/views/wasap/resistanceMutations.ts +++ b/website/src/components/views/wasap/resistanceMutations.ts @@ -5,36 +5,29 @@ import { type MutationAnnotations } from '@genspectrum/dashboard-components/util // the config now, and there's quite a bit of overlap. Would be nice to maybe make them more distinct, // or have only one type left, or comment why we need one and the other. +export type ResistanceMutationEntry = { + /** Mature protein display name, e.g. "3CLpro:T21I". */ + name: string; + /** Genomic amino acid mutation used as the LAPIS query, e.g. "ORF1a:T3284I". */ + aminoAcidMutation: string; +}; + export type ResistanceMutationSet = { name: string; annotationSymbol: string; description: string; - mutations: string[]; - offset: number; + mutations: ResistanceMutationEntry[]; }; -/** - * Maps a code like ORF1a:T1234:A to RdPd:T56A, by replacing the gene with the mature name - * and adjusting the position with the given offset. - */ -function matureName(code: string, matureName: string, offset: number): string { - const [_, mutationPart] = code.split(':'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const position = parseInt(/\d+/.exec(mutationPart)![0], 10); - const originalBase = mutationPart[0]; - const newBase = mutationPart[mutationPart.length - 1]; - return `${matureName}:${originalBase}${position + offset}${newBase} `; -} - /** * Converts a `ResistanceMutationSet` into a list of `MutationAnnotations`. * For every individual mutation, a mutation annotation will be generated (hence 1:n mapping). */ export function toMutationAnnotations(resistanceMutation: ResistanceMutationSet): MutationAnnotations { - return resistanceMutation.mutations.map((mutation) => ({ - name: matureName(mutation, resistanceMutation.name, resistanceMutation.offset), + return resistanceMutation.mutations.map(({ name, aminoAcidMutation }) => ({ + name, symbol: resistanceMutation.annotationSymbol, description: resistanceMutation.description, - aminoAcidMutations: [mutation], + aminoAcidMutations: [aminoAcidMutation], })); } diff --git a/website/src/components/views/wasap/useResistanceMutationSets.ts b/website/src/components/views/wasap/useResistanceMutationSets.ts index 516d6ec0..0216746e 100644 --- a/website/src/components/views/wasap/useResistanceMutationSets.ts +++ b/website/src/components/views/wasap/useResistanceMutationSets.ts @@ -38,15 +38,17 @@ function collectionToResistanceMutationSet( config: ResistanceMutationCollectionConfig, collection: Collection, ): ResistanceMutationSet { - const mutations = collection.variants.flatMap((variant) => - variant.type === 'filterObject' ? (variant.filterObject.aminoAcidMutations ?? []) : [], - ); - // TODO - maybe we can sort the mutations? Not sure if they are sorted already ... + const mutations = collection.variants.flatMap((variant) => { + if (variant.type !== 'filterObject') return []; + return (variant.filterObject.aminoAcidMutations ?? []).map((aminoAcidMutation) => ({ + name: variant.name, + aminoAcidMutation, + })); + }); return { name: config.name, annotationSymbol: config.annotationSymbol, description: config.description, - offset: config.offset, mutations, }; } diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index af5372a6..e828d2e8 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -104,7 +104,10 @@ function fetchResistanceModeData( ): WasapMutationsData { return { type: 'mutations', - displayMutations: resistanceMutationSets.find((set) => set.name === analysis.resistanceSet)?.mutations ?? [], + displayMutations: + resistanceMutationSets + .find((set) => set.name === analysis.resistanceSet) + ?.mutations.map((m) => m.aminoAcidMutation) ?? [], }; } diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 48be70c8..e64007b5 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -243,5 +243,4 @@ export type ResistanceMutationCollectionConfig = { name: string; // TODO - do we want to pull this from the collection instead? description: string; // TODO - same as above annotationSymbol: string; - offset: number; }; diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 69a2b34d..74247ed3 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -39,7 +39,6 @@ export const wastewaterOrganismConfigs: RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - offset: -3263, }, { collectionId: 192, @@ -47,7 +46,6 @@ export const wastewaterOrganismConfigs: RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - offset: 9, }, { collectionId: 193, @@ -55,7 +53,6 @@ export const wastewaterOrganismConfigs: RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - offset: 0, }, ] satisfies ResistanceMutationCollectionConfig[], lapisBaseUrl: 'https://lapis.wasap.genspectrum.org/covid', diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts index 135c966a..db31d0b9 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts @@ -28,9 +28,9 @@ const config: WasapPageConfig = { resistanceMutationCollections: [ // TODO - can we leave these hardcoded IDs here? Probably not? // For the test we probably need some sort of setup so the collections are present? - { name: '3CLpro', annotationSymbol: 'c', description: '', offset: -3263, collectionId: 191 }, - { name: 'RdRp', annotationSymbol: 'r', description: '', offset: 9, collectionId: 192 }, - { name: 'Spike', annotationSymbol: 's', description: '', offset: 0, collectionId: 193 }, + { name: '3CLpro', annotationSymbol: 'c', description: '', collectionId: 191 }, + { name: 'RdRp', annotationSymbol: 'r', description: '', collectionId: 192 }, + { name: 'Spike', annotationSymbol: 's', description: '', collectionId: 193 }, ], lapisBaseUrl: 'https://lapis.wasap.genspectrum.org', samplingDateField: 'samplingDate', From a5115a615a86f107a6df7d5c866e22ad71d2351e Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 10:44:41 +0200 Subject: [PATCH 05/17] refactor(website): simplify resistance mutation types and hook useResistanceMutationSets now returns ResistanceData directly (mutationAnnotations + displayMutationsBySet), removing the intermediate ResistanceMutationSet type and resistanceMutations.ts entirely. The selector/filter chain now takes plain string[] for set names. Co-Authored-By: Claude Sonnet 4.6 --- .../wasap/WasapPageStateSelector.tsx | 7 +- ...ResistanceMutationsFilter.browser.spec.tsx | 11 +-- .../filters/ResistanceMutationsFilter.tsx | 11 ++- .../src/components/views/wasap/WasapPage.tsx | 18 ++--- .../views/wasap/resistanceMutations.ts | 33 --------- .../views/wasap/useResistanceMutationSets.ts | 72 ++++++++++++------- .../views/wasap/useWasapPageData.ts | 18 ++--- 7 files changed, 70 insertions(+), 100 deletions(-) delete mode 100644 website/src/components/views/wasap/resistanceMutations.ts diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 330f9689..5f5c483f 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -17,7 +17,6 @@ import { Inset } from '../../../styles/Inset'; import { recentDaysDateRangeOptions } from '../../../util/recentDaysDateRangeOptions'; import { type PageStateHandler } from '../../../views/pageStateHandlers/PageStateHandler'; import { GsTextFilter } from '../../genspectrum/GsTextFilter'; -import type { ResistanceMutationSet } from '../../views/wasap/resistanceMutations'; import { enabledAnalysisModes, type WasapAnalysisFilter, @@ -33,14 +32,14 @@ import { */ export function WasapPageStateSelector({ config, - resistanceMutationSets, + resistanceSetNames, pageStateHandler, initialBaseFilterState, initialAnalysisFilterState, setPageState, }: { config: WasapPageConfig; - resistanceMutationSets: ResistanceMutationSet[]; + resistanceSetNames: string[]; pageStateHandler: PageStateHandler; initialBaseFilterState: WasapBaseFilter; initialAnalysisFilterState: WasapAnalysisFilter; @@ -192,7 +191,7 @@ export function WasapPageStateSelector({ ); case 'untracked': diff --git a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx index 8994726e..f389dda8 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.browser.spec.tsx @@ -3,7 +3,6 @@ import { render } from 'vitest-browser-react'; import { ResistanceMutationsFilter } from './ResistanceMutationsFilter'; import { it } from '../../../../../test-extend'; -import type { ResistanceMutationSet } from '../../../views/wasap/resistanceMutations'; import type { WasapResistanceFilter } from '../../../views/wasap/wasapPageConfig'; describe('ResistanceMutationsFilter', () => { @@ -13,11 +12,7 @@ describe('ResistanceMutationsFilter', () => { resistanceSet: '3CLpro', }; - const resistanceMutationSets: ResistanceMutationSet[] = [ - { name: '3CLpro', annotationSymbol: 'c', description: '', mutations: [] }, - { name: 'RdRp', annotationSymbol: 'r', description: '', mutations: [] }, - { name: 'Spike', annotationSymbol: 's', description: '', mutations: [] }, - ]; + const resistanceSetNames = ['3CLpro', 'RdRp', 'Spike']; it('renders with initial resistance set', async () => { const mockSetPageState = vi.fn(); @@ -26,7 +21,7 @@ describe('ResistanceMutationsFilter', () => { , ); @@ -41,7 +36,7 @@ describe('ResistanceMutationsFilter', () => { , ); diff --git a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.tsx index 5b1f5f48..6156a72f 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/ResistanceMutationsFilter.tsx @@ -1,15 +1,14 @@ -import type { ResistanceMutationSet } from '../../../views/wasap/resistanceMutations'; import type { WasapResistanceFilter } from '../../../views/wasap/wasapPageConfig'; import { LabeledField } from '../utils/LabeledField'; export function ResistanceMutationsFilter({ pageState, setPageState, - resistanceMutationSets, + resistanceSetNames, }: { pageState: WasapResistanceFilter; setPageState: (newState: WasapResistanceFilter) => void; - resistanceMutationSets: ResistanceMutationSet[]; + resistanceSetNames: string[]; }) { return ( @@ -18,9 +17,9 @@ export function ResistanceMutationsFilter({ value={pageState.resistanceSet} onChange={(e) => setPageState({ ...pageState, resistanceSet: e.target.value })} > - {resistanceMutationSets.map((set) => ( - ))} diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 82e13cab..15938d10 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -6,7 +6,6 @@ import { CollectionInfo } from './components/CollectionInfo'; import { NoDataHelperText } from './components/NoDataHelperText'; import { VariantFetchInfo } from './components/VariantFetchInfo'; import { WasapStats } from './components/WasapStats'; -import { toMutationAnnotations } from './resistanceMutations'; import { useResistanceMutationSets } from './useResistanceMutationSets'; import { useWasapPageData } from './useWasapPageData'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; @@ -39,12 +38,12 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { setPageState, } = usePageState(pageStateHandler); - const { data: resistanceMutationSets = [] } = useResistanceMutationSets(config); - // TODO - we can probably optimize this, because this call above and the one below could be - // parallelized + const { data: { mutationAnnotations = [], displayMutationsBySet = {} } = {} } = useResistanceMutationSets(config); + // TODO, would be great to have useResistanceMutationSets and useWasapPageData fetched in parallel. + // could be done if we pass a promise into useWasapPageData. // fetch which mutations should be analyzed - const { data, isPending, isError } = useWasapPageData(config, resistanceMutationSets, analysis); + const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); let initialMeanProportionInterval: MeanProportionInterval = { min: 0.0, max: 1.0 }; if (analysis.mode === 'manual' && analysis.mutations === undefined) { @@ -57,11 +56,6 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { ...(base.samplingDate?.dateTo && { samplingDateTo: base.samplingDate.dateTo }), }; - const memoizedMutationAnnotations = useMemo( - () => resistanceMutationSets.flatMap(toMutationAnnotations), - [resistanceMutationSets], - ); - return ( = ({ wastewaterOrganism }) => { >
@@ -88,7 +82,7 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { initialBaseFilterState={base} initialAnalysisFilterState={analysis} setPageState={setPageState} - resistanceMutationSets={resistanceMutationSets} + resistanceSetNames={Object.keys(displayMutationsBySet)} />
{isError ? ( diff --git a/website/src/components/views/wasap/resistanceMutations.ts b/website/src/components/views/wasap/resistanceMutations.ts deleted file mode 100644 index 1d7d9855..00000000 --- a/website/src/components/views/wasap/resistanceMutations.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type MutationAnnotations } from '@genspectrum/dashboard-components/util'; - -// TODO - revisit this file. Can we get rid of more in here? Move all the functions elsewhere? -// Do we still need the ResistanceMutationSet type even? Because we have a different type in -// the config now, and there's quite a bit of overlap. Would be nice to maybe make them more distinct, -// or have only one type left, or comment why we need one and the other. - -export type ResistanceMutationEntry = { - /** Mature protein display name, e.g. "3CLpro:T21I". */ - name: string; - /** Genomic amino acid mutation used as the LAPIS query, e.g. "ORF1a:T3284I". */ - aminoAcidMutation: string; -}; - -export type ResistanceMutationSet = { - name: string; - annotationSymbol: string; - description: string; - mutations: ResistanceMutationEntry[]; -}; - -/** - * Converts a `ResistanceMutationSet` into a list of `MutationAnnotations`. - * For every individual mutation, a mutation annotation will be generated (hence 1:n mapping). - */ -export function toMutationAnnotations(resistanceMutation: ResistanceMutationSet): MutationAnnotations { - return resistanceMutation.mutations.map(({ name, aminoAcidMutation }) => ({ - name, - symbol: resistanceMutation.annotationSymbol, - description: resistanceMutation.description, - aminoAcidMutations: [aminoAcidMutation], - })); -} diff --git a/website/src/components/views/wasap/useResistanceMutationSets.ts b/website/src/components/views/wasap/useResistanceMutationSets.ts index 0216746e..3012223f 100644 --- a/website/src/components/views/wasap/useResistanceMutationSets.ts +++ b/website/src/components/views/wasap/useResistanceMutationSets.ts @@ -1,15 +1,22 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { useQuery } from '@tanstack/react-query'; -import type { ResistanceMutationSet } from './resistanceMutations'; import type { ResistanceMutationCollectionConfig, WasapPageConfig } from './wasapPageConfig'; import { getBackendServiceForClientside } from '../../../backendApi/backendService'; import type { Collection } from '../../../types/Collection'; +export type ResistanceData = { + /** Flat list of mutation annotations for the genome viewer. */ + mutationAnnotations: MutationAnnotations; + /** Map from set name (e.g. "3CLpro") to the genomic amino acid mutations to display. */ + displayMutationsBySet: Record; +}; + /** - * Fetches resistance mutation sets from the backend collections API. - * Each set corresponds to one collection (e.g. 3CLpro, RdRp, Spike), with the mutation list - * derived from the filterObject variants in that collection. Results are cached indefinitely - * for the lifetime of the page, since resistance mutation lists change infrequently. + * Fetches resistance mutation data from the backend collections API. + * Each configured collection (e.g. 3CLpro, RdRp, Spike) is fetched in parallel and mapped into: + * - mutationAnnotations: ready to pass to for genome view annotations + * - displayMutationsBySet: keyed by set name, for use in the resistance analysis mode */ export function useResistanceMutationSets(config: WasapPageConfig) { return useQuery({ @@ -17,38 +24,51 @@ export function useResistanceMutationSets(config: WasapPageConfig) { 'resistanceCollections', config.resistanceAnalysisModeEnabled ? config.resistanceMutationCollections.map((s) => s.collectionId) : [], ], - queryFn: async (): Promise => { - if (!config.resistanceAnalysisModeEnabled) return []; + queryFn: async (): Promise => { + if (!config.resistanceAnalysisModeEnabled) { + return { mutationAnnotations: [], displayMutationsBySet: {} }; + } const backendService = getBackendServiceForClientside(); const collections = await Promise.all( config.resistanceMutationCollections.map((setConfig) => backendService.getCollection({ id: String(setConfig.collectionId) }), ), ); - return config.resistanceMutationCollections.map((setConfig, i) => - collectionToResistanceMutationSet(setConfig, collections[i]), - ); + return buildResistanceData(config.resistanceMutationCollections, collections); }, + // TODO - infinity is probably excessive, around 1h is maybe fine? staleTime: Infinity, enabled: config.resistanceAnalysisModeEnabled === true, }); } -function collectionToResistanceMutationSet( - config: ResistanceMutationCollectionConfig, - collection: Collection, -): ResistanceMutationSet { - const mutations = collection.variants.flatMap((variant) => { - if (variant.type !== 'filterObject') return []; - return (variant.filterObject.aminoAcidMutations ?? []).map((aminoAcidMutation) => ({ - name: variant.name, - aminoAcidMutation, - })); +function buildResistanceData( + setConfigs: ResistanceMutationCollectionConfig[], + collections: Collection[], +): ResistanceData { + const mutationAnnotations: MutationAnnotations = []; + const displayMutationsBySet: Record = {}; + + setConfigs.forEach((setConfig, i) => { + const entries = collections[i].variants.flatMap((variant) => { + if (variant.type !== 'filterObject') return []; + return (variant.filterObject.aminoAcidMutations ?? []).map((aminoAcidMutation) => ({ + displayName: variant.name, + aminoAcidMutation, + })); + }); + + displayMutationsBySet[setConfig.name] = entries.map((e) => e.aminoAcidMutation); + + mutationAnnotations.push( + ...entries.map(({ displayName, aminoAcidMutation }) => ({ + name: displayName, + symbol: setConfig.annotationSymbol, + description: setConfig.description, + aminoAcidMutations: [aminoAcidMutation], + })), + ); }); - return { - name: config.name, - annotationSymbol: config.annotationSymbol, - description: config.description, - mutations, - }; + + return { mutationAnnotations, displayMutationsBySet }; } diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index e828d2e8..bff0e49e 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -2,7 +2,6 @@ import type { CustomColumn, LapisFilter } from '@genspectrum/dashboard-component import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import type { ResistanceMutationSet } from './resistanceMutations'; import type { VariantTimeFrame, WasapAnalysisFilter, @@ -28,19 +27,19 @@ import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; */ export function useWasapPageData( config: WasapPageConfig, - resistanceMutationSets: ResistanceMutationSet[], + resistanceMutationsBySet: Record, analysis: WasapAnalysisFilter, ) { return useQuery({ - queryKey: ['wasap', analysis, JSON.stringify(resistanceMutationSets)], - queryFn: () => fetchWasapPageData(config, analysis, resistanceMutationSets), + queryKey: ['wasap', analysis, resistanceMutationsBySet], + queryFn: () => fetchWasapPageData(config, resistanceMutationsBySet, analysis), }); } async function fetchWasapPageData( config: WasapPageConfig, + resistanceMutationsBySet: Record, analysis: WasapAnalysisFilter, - resistanceMutationSets: ResistanceMutationSet[], ): Promise { switch (analysis.mode) { case 'manual': @@ -48,7 +47,7 @@ async function fetchWasapPageData( case 'variant': return fetchVariantModeData(config, analysis); case 'resistance': - return fetchResistanceModeData(resistanceMutationSets, analysis); + return fetchResistanceModeData(resistanceMutationsBySet, analysis); case 'untracked': return fetchUntrackedModeData(config, analysis); case 'collection': @@ -99,15 +98,12 @@ async function fetchVariantModeData( } function fetchResistanceModeData( - resistanceMutationSets: ResistanceMutationSet[], + displayMutationsBySet: Record, analysis: WasapResistanceFilter, ): WasapMutationsData { return { type: 'mutations', - displayMutations: - resistanceMutationSets - .find((set) => set.name === analysis.resistanceSet) - ?.mutations.map((m) => m.aminoAcidMutation) ?? [], + displayMutations: displayMutationsBySet[analysis.resistanceSet ?? ''] ?? [], }; } From 7ec3e98e8b622cc676a0a430131a11bf9580a955 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 13:31:02 +0200 Subject: [PATCH 06/17] chore(backend): remove resistance mutation collection seeding script The example data seeder on feat/example-data-seeder handles this now. Co-Authored-By: Claude Sonnet 4.6 --- .../create-resistance-mutation-collections.sh | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100755 backend/create-resistance-mutation-collections.sh diff --git a/backend/create-resistance-mutation-collections.sh b/backend/create-resistance-mutation-collections.sh deleted file mode 100755 index a7553fae..00000000 --- a/backend/create-resistance-mutation-collections.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# This script creates resistance mutation collections. -# By default, it's running against a locally ran backend (localhost:8080), and you should -# set the USER_ID env var, which is then used as the user ID for creating the collections locally. -# You can also set a SESSION_TOKEN which you can take out of the cookie storage of the GenSpectrum -# website when you're logged in (__Secure-authjs.session-token). With this, you can create collections -# on staging or prod. -# -# Example calls: -# -# Local (backend running on :8080): -# USER_ID=myuser ./create-resistance-mutation-collections.sh -# -# Staging (grab __Secure-authjs.session-token from browser DevTools → Application → Cookies): -# BASE_URL=https://staging.genspectrum.org SESSION_TOKEN=eyJhbG... ./create-resistance-mutation-collections.sh - -BASE_URL="${BASE_URL:-http://localhost:8080}" -USER_ID="${1:-testuser}" -# When SESSION_TOKEN is set, the website proxy injects the user ID — do not set it manually. -# Optional: set SESSION_TOKEN to authenticate against a deployed instance. -# The cookie name must match what the server expects (e.g. __Secure-authjs.session-token for HTTPS). -SESSION_TOKEN="${SESSION_TOKEN:-}" - -# TODO: We need to be able to _update_ a collection with this script. but maybe this can be a follow up issue as well. -# (I.e. we should be able to accept a collection ID which we then update instead of creating new ones all the time) - -create_collection() { - local name="$1" - local body="$2" - local body_out http_code - local tmp - tmp=$(mktemp) - - local cookie_args=() - if [ -n "$SESSION_TOKEN" ]; then - if [[ "$BASE_URL" == https://* ]]; then - cookie_args=(-b "__Secure-authjs.session-token=${SESSION_TOKEN}") - else - cookie_args=(-b "authjs.session-token=${SESSION_TOKEN}") - fi - fi - - if [ -n "$SESSION_TOKEN" ]; then - local url="${BASE_URL}/api/collections" - else - local url="${BASE_URL}/collections?userId=${USER_ID}" - fi - - http_code=$(curl -s -o "$tmp" -w "%{http_code}" -X POST \ - "${cookie_args[@]}" \ - "$url" \ - -H "Content-Type: application/json" \ - -d "$body") - body_out=$(cat "$tmp") - rm -f "$tmp" - if [ "$http_code" = "201" ]; then - local id - id=$(echo "$body_out" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) - echo " OK id=$id $name" - else - echo " FAIL ($http_code) $name: $body_out" - exit 1 - fi -} - -# Converts a genomic mutation code to a mature protein name with the given offset. -# E.g. mature_name "ORF1a:T3284I" "3CLpro" -3263 => "3CLpro:T21I" -mature_name() { - local mutation="$1" set_name="$2" offset="$3" - local mutation_part="${mutation#*:}" - local original_base="${mutation_part:0:1}" - local new_base="${mutation_part: -1}" - local position - position=$(echo "$mutation_part" | grep -o '[0-9]*') - echo "${set_name}:${original_base}$((position + offset))${new_base}" -} - -# Build a JSON variants array from mutation strings passed as arguments. -# Usage: build_variants_json [ ...] -# Each mutation becomes one filterObject variant whose display name uses the -# mature protein coordinate (set_name + position adjusted by offset). -build_variants_json() { - local set_name="$1" offset="$2" - shift 2 - local variants="[]" - for mutation in "$@"; do - local display_name - display_name=$(mature_name "$mutation" "$set_name" "$offset") - variants=$(jq -n \ - --argjson existing "$variants" \ - --arg name "$display_name" \ - --arg mutation "$mutation" \ - '$existing + [{"type": "filterObject", "name": $name, "filterObject": {"aminoAcidMutations": [$mutation]}}]') - done - echo "$variants" -} - -if [ -n "$SESSION_TOKEN" ]; then - echo "Creating resistance mutation collections against $BASE_URL using session token..." -else - echo "Creating resistance mutation collections against $BASE_URL as user '$USER_ID'..." -fi -echo - -# --- 3CLpro --- -CLPRO_MUTATIONS=( - "ORF1a:T3284I" "ORF1a:T3288A" "ORF1a:T3288N" "ORF1a:T3308I" "ORF1a:D3311Y" - "ORF1a:M3312I" "ORF1a:M3312L" "ORF1a:M3312T" "ORF1a:M3312-" "ORF1a:L3313F" - "ORF1a:G3401S" "ORF1a:F3403L" "ORF1a:F3403S" "ORF1a:N3405D" "ORF1a:N3405L" - "ORF1a:N3405S" "ORF1a:G3406S" "ORF1a:S3407A" "ORF1a:S3407E" "ORF1a:S3407L" - "ORF1a:S3407P" "ORF1a:C3423F" "ORF1a:M3428R" "ORF1a:M3428T" "ORF1a:E3429A" - "ORF1a:E3429G" "ORF1a:E3429K" "ORF1a:E3429Q" "ORF1a:E3429V" "ORF1a:L3430F" - "ORF1a:P3431-" "ORF1a:T3432I" "ORF1a:H3435L" "ORF1a:H3435N" "ORF1a:H3435Q" - "ORF1a:H3435Y" "ORF1a:A3436T" "ORF1a:A3436V" "ORF1a:V3449A" "ORF1a:R3451G" - "ORF1a:R3451S" "ORF1a:Q3452I" "ORF1a:Q3452K" "ORF1a:T3453I" "ORF1a:A3454T" - "ORF1a:A3454V" "ORF1a:Q3455A" "ORF1a:Q3455C" "ORF1a:Q3455D" "ORF1a:Q3455E" - "ORF1a:Q3455F" "ORF1a:Q3455G" "ORF1a:Q3455H" "ORF1a:Q3455I" "ORF1a:Q3455K" - "ORF1a:Q3455L" "ORF1a:Q3455N" "ORF1a:Q3455P" "ORF1a:Q3455R" "ORF1a:Q3455S" - "ORF1a:Q3455T" "ORF1a:Q3455V" "ORF1a:Q3455W" "ORF1a:Q3455Y" "ORF1a:A3456P" - "ORF1a:A3457S" "ORF1a:P3515L" "ORF1a:V3560A" "ORF1a:S3564P" "ORF1a:T3567I" - "ORF1a:F3568L" -) -variants_json=$(build_variants_json "3CLpro" -3263 "${CLPRO_MUTATIONS[@]}") -body=$(jq -n \ - --argjson variants "$variants_json" \ - '{ - "name": "3CLpro resistance mutations", - "organism": "covid", - "description": "SARS-CoV-2 3C-like protease (3CLpro/Mpro) inhibitor resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", - "variants": $variants - }') -create_collection "3CLpro resistance mutations" "$body" - -# --- RdRp --- -RDRP_MUTATIONS=( - "ORF1b:V157A" "ORF1b:V157L" "ORF1b:N189S" "ORF1b:R276C" "ORF1b:A367V" - "ORF1b:A440V" "ORF1b:F471L" "ORF1b:D475Y" "ORF1b:A517V" "ORF1b:V548L" - "ORF1b:G662S" "ORF1b:S750A" "ORF1b:V783I" "ORF1b:E787G" "ORF1b:C790F" - "ORF1b:C790R" "ORF1b:E793A" "ORF1b:E793D" "ORF1b:M915R" -) -variants_json=$(build_variants_json "RdRp" 9 "${RDRP_MUTATIONS[@]}") -body=$(jq -n \ - --argjson variants "$variants_json" \ - '{ - "name": "RdRp resistance mutations", - "organism": "covid", - "description": "SARS-CoV-2 RNA-dependent RNA polymerase (RdRp) inhibitor resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", - "variants": $variants - }') -create_collection "RdRp resistance mutations" "$body" - -# --- Spike --- -SPIKE_MUTATIONS=( - "S:P337H" "S:P337L" "S:P337R" "S:P337S" "S:P337T" - "S:E340A" "S:E340D" "S:E340G" "S:E340K" "S:E340Q" "S:E340V" - "S:T345P" - "S:R346G" "S:R346I" "S:R346K" "S:R346S" "S:R346T" - "S:K356Q" "S:K356T" - "S:S371F" "S:S371L" - "S:D405E" "S:D405N" "S:E406D" - "S:K417E" "S:K417H" "S:K417I" "S:K417M" "S:K417N" "S:K417R" "S:K417S" "S:K417T" - "S:D420A" "S:D420N" - "S:N439K" - "S:N440D" "S:N440E" "S:N440I" "S:N440K" "S:N440R" "S:N440T" "S:N440Y" - "S:S443Y" - "S:K444E" "S:K444F" "S:K444I" "S:K444L" "S:K444M" "S:K444N" "S:K444R" "S:K444T" - "S:V445A" "S:V445D" "S:V445F" "S:V445I" "S:V445L" - "S:G446A" "S:G446D" "S:G446I" "S:G446N" "S:G446R" "S:G446S" "S:G446T" "S:G446V" - "S:G447C" "S:G447D" "S:G447F" "S:G447S" "S:G447V" - "S:N448D" "S:N448K" "S:N448T" "S:N448Y" - "S:Y449D" - "S:N450D" "S:N450K" - "S:L452M" "S:L452Q" "S:L452R" "S:L452W" - "S:Y453F" "S:Y453H" - "S:L455F" "S:L455M" "S:L455S" "S:L455W" - "S:F456C" "S:F456L" "S:F456V" - "S:S459P" - "S:N460D" "S:N460H" "S:N460I" "S:N460K" "S:N460S" "S:N460T" "S:N460Y" - "S:A475D" "S:A475V" - "S:G476D" "S:G476R" "S:G476T" - "S:V483A" - "S:E484A" "S:E484D" "S:E484G" "S:E484K" "S:E484P" "S:E484Q" "S:E484R" "S:E484S" "S:E484T" "S:E484V" - "S:G485D" "S:G485R" - "S:F486D" "S:F486I" "S:F486L" "S:F486N" "S:F486P" "S:F486S" "S:F486T" "S:F486V" - "S:N487D" "S:N487H" "S:N487S" - "S:Y489H" "S:Y489W" - "S:F490G" "S:F490I" "S:F490L" "S:F490R" "S:F490S" "S:F490V" "S:F490Y" - "S:Q493D" "S:Q493E" "S:Q493H" "S:Q493K" "S:Q493L" "S:Q493R" "S:Q493V" - "S:S494P" "S:S494R" - "S:G496S" - "S:Q498H" - "S:P499H" "S:P499R" "S:P499S" "S:P499T" - "S:N501T" "S:N501Y" - "S:G504C" "S:G504D" "S:G504I" "S:G504L" "S:G504N" "S:G504R" "S:G504V" - "S:P507A" - "S:N856K" "S:N969K" "S:E990A" "S:T1009I" -) -variants_json=$(build_variants_json "Spike" 0 "${SPIKE_MUTATIONS[@]}") -body=$(jq -n \ - --argjson variants "$variants_json" \ - '{ - "name": "Spike mAb resistance mutations", - "organism": "covid", - "description": "SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutations as per Stanford Coronavirus Antiviral & Resistance database (last updated 21 August 2024).", - "variants": $variants - }') -create_collection "Spike mAb resistance mutations" "$body" - -echo -echo "Done." From 1a13ad15470e17466e646425001deb57c2bf0615 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 13:46:41 +0200 Subject: [PATCH 07/17] feat(website): resolve wasap config server-side to support staging overrides Add wastewaterOrganismStagingConfigs with staging resistance collection IDs (1/2/3 for 3CLpro/RdRp/Spike). Wasap.astro now selects prod vs staging config and passes it directly to WasapPage, removing the env dependency from React. Co-Authored-By: Claude Sonnet 4.6 --- website/src/components/views/wasap/Wasap.astro | 13 +++++++++---- .../src/components/views/wasap/WasapPage.tsx | 12 ++++-------- website/src/types/wastewaterConfig.ts | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 4535ed13..87c20b59 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -1,16 +1,21 @@ --- import { WasapPage } from './WasapPage'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; -import { wastewaterOrganismConfigs, type WastewaterOrganismName } from '../../../types/wastewaterConfig'; +import { isStaging } from '../../../config.ts'; +import { + wastewaterOrganismConfigs, + wastewaterOrganismStagingConfigs, + type WastewaterOrganismName, +} from '../../../types/wastewaterConfig'; type Props = { wastewaterOrganism: WastewaterOrganismName; }; const { wastewaterOrganism } = Astro.props; -const { name } = wastewaterOrganismConfigs[wastewaterOrganism]; +const config = (isStaging() ? wastewaterOrganismStagingConfigs : wastewaterOrganismConfigs)[wastewaterOrganism]; --- - - + + diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 15938d10..fd23bd1c 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -12,11 +12,8 @@ import { withQueryProvider } from '../../../backendApi/withQueryProvider'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs.tsx'; import { DataPageLayout } from '../../../layouts/OrganismPage/DataPageLayout.tsx'; import { dataOrigins } from '../../../types/dataOrigins.ts'; -import { - wastewaterBreadcrumb, - wastewaterOrganismConfigs, - type WastewaterOrganismName, -} from '../../../types/wastewaterConfig'; +import { wastewaterBreadcrumb } from '../../../types/wastewaterConfig'; +import type { WasapPageConfig } from './wasapPageConfig'; import { Loading } from '../../../util/Loading'; import { WasapPageStateHandler } from '../../../views/pageStateHandlers/WasapPageStateHandler'; import { GsMutationsOverTime } from '../../genspectrum/GsMutationsOverTime'; @@ -25,11 +22,10 @@ import { WasapPageStateSelector } from '../../pageStateSelectors/wasap/WasapPage import { usePageState } from '../usePageState.ts'; export type WasapPageProps = { - wastewaterOrganism: WastewaterOrganismName; + config: WasapPageConfig; }; -export const WasapPageInner: FC = ({ wastewaterOrganism }) => { - const config = wastewaterOrganismConfigs[wastewaterOrganism]; +export const WasapPageInner: FC = ({ config }) => { // initialize page state from the URL const pageStateHandler = useMemo(() => new WasapPageStateHandler(config), [config]); diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 74247ed3..01b12c53 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -185,6 +185,23 @@ export const wastewaterOrganismConfigs: Record = { '3CLpro': 1, RdRp: 2, Spike: 3 }; + return { + ...config, + resistanceMutationCollections: config.resistanceMutationCollections.map((set) => ({ + ...set, + collectionId: stagingIds[set.name] ?? set.collectionId, + })), + }; +} + +export const wastewaterOrganismStagingConfigs: Record = { + ...wastewaterOrganismConfigs, + [wastewaterOrganisms.covid]: withResistanceCollectionOverrides(wastewaterOrganismConfigs[wastewaterOrganisms.covid]), +}; + export const wastewaterConfig = { menuListEntryDecoration: 'decoration-teal', backgroundColor: 'bg-tealMuted', From 6f56af55643a89f97c3ae8ff4a1636bcb2524535 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 13:49:03 +0200 Subject: [PATCH 08/17] chore(website): remove resolved TODOs and set 1h stale time for resistance collections Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/views/wasap/useResistanceMutationSets.ts | 3 +-- website/src/components/views/wasap/wasapPageConfig.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/website/src/components/views/wasap/useResistanceMutationSets.ts b/website/src/components/views/wasap/useResistanceMutationSets.ts index 3012223f..32aec8a1 100644 --- a/website/src/components/views/wasap/useResistanceMutationSets.ts +++ b/website/src/components/views/wasap/useResistanceMutationSets.ts @@ -36,8 +36,7 @@ export function useResistanceMutationSets(config: WasapPageConfig) { ); return buildResistanceData(config.resistanceMutationCollections, collections); }, - // TODO - infinity is probably excessive, around 1h is maybe fine? - staleTime: Infinity, + staleTime: 60 * 60 * 1000, enabled: config.resistanceAnalysisModeEnabled === true, }); } diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index e64007b5..b4f9b030 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -240,7 +240,7 @@ export type WasapFilter = { */ export type ResistanceMutationCollectionConfig = { collectionId: number; - name: string; // TODO - do we want to pull this from the collection instead? - description: string; // TODO - same as above + name: string; + description: string; annotationSymbol: string; }; From c59d24fcdd47912a3752b61afa2af1876ec1d32b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 14:00:44 +0200 Subject: [PATCH 09/17] fix(website): set correct prod resistance collection IDs (1, 2, 3) IDs confirmed at https://genspectrum.org/api/collections Co-Authored-By: Claude Sonnet 4.6 --- website/src/types/wastewaterConfig.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 01b12c53..d0435a6f 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -30,25 +30,22 @@ export const wastewaterOrganismConfigs: RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', }, { - collectionId: 192, + collectionId: 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: 193, + collectionId: 3, name: 'Spike', annotationSymbol: 's', description: From 831288a7fd0ef429410efb4879359a423cc0b70f Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 14:02:07 +0200 Subject: [PATCH 10/17] chore(website): update test fixture collection IDs to match prod (1, 2, 3) Co-Authored-By: Claude Sonnet 4.6 --- .../views/pageStateHandlers/WasapPageStateHandler.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts index db31d0b9..37d58027 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.spec.ts @@ -26,11 +26,9 @@ const config: WasapPageConfig = { resistanceAnalysisModeEnabled: true, untrackedAnalysisModeEnabled: true, resistanceMutationCollections: [ - // TODO - can we leave these hardcoded IDs here? Probably not? - // For the test we probably need some sort of setup so the collections are present? - { name: '3CLpro', annotationSymbol: 'c', description: '', collectionId: 191 }, - { name: 'RdRp', annotationSymbol: 'r', description: '', collectionId: 192 }, - { name: 'Spike', annotationSymbol: 's', description: '', collectionId: 193 }, + { name: '3CLpro', annotationSymbol: 'c', description: '', collectionId: 1 }, + { name: 'RdRp', annotationSymbol: 'r', description: '', collectionId: 2 }, + { name: 'Spike', annotationSymbol: 's', description: '', collectionId: 3 }, ], lapisBaseUrl: 'https://lapis.wasap.genspectrum.org', samplingDateField: 'samplingDate', From 45caf50e67ba6fcd1aed12e23a9b95c50ba03968 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 14:07:01 +0200 Subject: [PATCH 11/17] chore(website): remove parallel fetching TODO from WasapPage Co-Authored-By: Claude Sonnet 4.6 --- website/src/components/views/wasap/WasapPage.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index fd23bd1c..3b4d4474 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -35,9 +35,6 @@ export const WasapPageInner: FC = ({ config }) => { } = usePageState(pageStateHandler); const { data: { mutationAnnotations = [], displayMutationsBySet = {} } = {} } = useResistanceMutationSets(config); - // TODO, would be great to have useResistanceMutationSets and useWasapPageData fetched in parallel. - // could be done if we pass a promise into useWasapPageData. - // fetch which mutations should be analyzed const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); From 8ad8ec2c9fe0225e868056819cf965f0de61a4c6 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 14:12:06 +0200 Subject: [PATCH 12/17] format --- website/src/components/views/wasap/Wasap.astro | 2 +- website/src/components/views/wasap/WasapPage.tsx | 2 +- website/src/types/wastewaterConfig.ts | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 87c20b59..6597fdea 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -1,7 +1,7 @@ --- import { WasapPage } from './WasapPage'; -import BaseLayout from '../../../layouts/base/BaseLayout.astro'; import { isStaging } from '../../../config.ts'; +import BaseLayout from '../../../layouts/base/BaseLayout.astro'; import { wastewaterOrganismConfigs, wastewaterOrganismStagingConfigs, diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 3b4d4474..85a960c9 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -8,12 +8,12 @@ import { VariantFetchInfo } from './components/VariantFetchInfo'; import { WasapStats } from './components/WasapStats'; import { useResistanceMutationSets } from './useResistanceMutationSets'; import { useWasapPageData } from './useWasapPageData'; +import type { WasapPageConfig } from './wasapPageConfig'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs.tsx'; import { DataPageLayout } from '../../../layouts/OrganismPage/DataPageLayout.tsx'; import { dataOrigins } from '../../../types/dataOrigins.ts'; import { wastewaterBreadcrumb } from '../../../types/wastewaterConfig'; -import type { WasapPageConfig } from './wasapPageConfig'; import { Loading } from '../../../util/Loading'; import { WasapPageStateHandler } from '../../../views/pageStateHandlers/WasapPageStateHandler'; import { GsMutationsOverTime } from '../../genspectrum/GsMutationsOverTime'; diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index d0435a6f..07dbb835 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -184,7 +184,8 @@ export const wastewaterOrganismConfigs: Record = { '3CLpro': 1, RdRp: 2, Spike: 3 }; + // eslint-disable-next-line @typescript-eslint/naming-convention + const stagingIds: Record = { '3CLpro': 1, 'RdRp': 2, 'Spike': 3 }; return { ...config, resistanceMutationCollections: config.resistanceMutationCollections.map((set) => ({ @@ -196,7 +197,9 @@ function withResistanceCollectionOverrides(config: WasapPageConfig): WasapPageCo export const wastewaterOrganismStagingConfigs: Record = { ...wastewaterOrganismConfigs, - [wastewaterOrganisms.covid]: withResistanceCollectionOverrides(wastewaterOrganismConfigs[wastewaterOrganisms.covid]), + [wastewaterOrganisms.covid]: withResistanceCollectionOverrides( + wastewaterOrganismConfigs[wastewaterOrganisms.covid], + ), }; export const wastewaterConfig = { From 2b507415ab037bd979a6e666960035a0133296f6 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 29 Apr 2026 14:23:18 +0200 Subject: [PATCH 13/17] refactor(website): fetch resistance data server-side in Wasap.astro Moves resistance collection fetching from a React Query hook to the Astro SSR layer, so data is available before the page reaches the browser. WasapPage now receives resistanceData as a prop instead of fetching it client-side. Removes useResistanceMutationSets hook; logic lives in resistanceData.ts as a plain async function. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/views/wasap/Wasap.astro | 7 ++- .../src/components/views/wasap/WasapPage.tsx | 7 +-- ...tanceMutationSets.ts => resistanceData.ts} | 43 ++++++------------- 3 files changed, 23 insertions(+), 34 deletions(-) rename website/src/components/views/wasap/{useResistanceMutationSets.ts => resistanceData.ts} (52%) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 6597fdea..1fbce93c 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -1,6 +1,8 @@ --- import { WasapPage } from './WasapPage'; -import { isStaging } from '../../../config.ts'; +import { fetchResistanceData } from './resistanceData'; +import { BackendService } from '../../../backendApi/backendService.ts'; +import { isStaging, getBackendHost } from '../../../config.ts'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; import { wastewaterOrganismConfigs, @@ -14,8 +16,9 @@ type Props = { const { wastewaterOrganism } = Astro.props; const config = (isStaging() ? wastewaterOrganismStagingConfigs : wastewaterOrganismConfigs)[wastewaterOrganism]; +const resistanceData = await fetchResistanceData(config, new BackendService(getBackendHost())); --- - + diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 85a960c9..59de2744 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -6,7 +6,7 @@ import { CollectionInfo } from './components/CollectionInfo'; import { NoDataHelperText } from './components/NoDataHelperText'; import { VariantFetchInfo } from './components/VariantFetchInfo'; import { WasapStats } from './components/WasapStats'; -import { useResistanceMutationSets } from './useResistanceMutationSets'; +import type { ResistanceData } from './resistanceData'; import { useWasapPageData } from './useWasapPageData'; import type { WasapPageConfig } from './wasapPageConfig'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; @@ -23,9 +23,10 @@ import { usePageState } from '../usePageState.ts'; export type WasapPageProps = { config: WasapPageConfig; + resistanceData: ResistanceData; }; -export const WasapPageInner: FC = ({ config }) => { +export const WasapPageInner: FC = ({ config, resistanceData }) => { // initialize page state from the URL const pageStateHandler = useMemo(() => new WasapPageStateHandler(config), [config]); @@ -34,7 +35,7 @@ export const WasapPageInner: FC = ({ config }) => { setPageState, } = usePageState(pageStateHandler); - const { data: { mutationAnnotations = [], displayMutationsBySet = {} } = {} } = useResistanceMutationSets(config); + const { mutationAnnotations, displayMutationsBySet } = resistanceData; // fetch which mutations should be analyzed const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); diff --git a/website/src/components/views/wasap/useResistanceMutationSets.ts b/website/src/components/views/wasap/resistanceData.ts similarity index 52% rename from website/src/components/views/wasap/useResistanceMutationSets.ts rename to website/src/components/views/wasap/resistanceData.ts index 32aec8a1..4a7fcc30 100644 --- a/website/src/components/views/wasap/useResistanceMutationSets.ts +++ b/website/src/components/views/wasap/resistanceData.ts @@ -1,8 +1,7 @@ import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; -import { useQuery } from '@tanstack/react-query'; import type { ResistanceMutationCollectionConfig, WasapPageConfig } from './wasapPageConfig'; -import { getBackendServiceForClientside } from '../../../backendApi/backendService'; +import type { BackendService } from '../../../backendApi/backendService'; import type { Collection } from '../../../types/Collection'; export type ResistanceData = { @@ -12,33 +11,19 @@ export type ResistanceData = { displayMutationsBySet: Record; }; -/** - * Fetches resistance mutation data from the backend collections API. - * Each configured collection (e.g. 3CLpro, RdRp, Spike) is fetched in parallel and mapped into: - * - mutationAnnotations: ready to pass to for genome view annotations - * - displayMutationsBySet: keyed by set name, for use in the resistance analysis mode - */ -export function useResistanceMutationSets(config: WasapPageConfig) { - return useQuery({ - queryKey: [ - 'resistanceCollections', - config.resistanceAnalysisModeEnabled ? config.resistanceMutationCollections.map((s) => s.collectionId) : [], - ], - queryFn: async (): Promise => { - if (!config.resistanceAnalysisModeEnabled) { - return { mutationAnnotations: [], displayMutationsBySet: {} }; - } - const backendService = getBackendServiceForClientside(); - const collections = await Promise.all( - config.resistanceMutationCollections.map((setConfig) => - backendService.getCollection({ id: String(setConfig.collectionId) }), - ), - ); - return buildResistanceData(config.resistanceMutationCollections, collections); - }, - staleTime: 60 * 60 * 1000, - enabled: config.resistanceAnalysisModeEnabled === true, - }); +export async function fetchResistanceData( + config: WasapPageConfig, + backendService: BackendService, +): Promise { + if (!config.resistanceAnalysisModeEnabled) { + return { mutationAnnotations: [], displayMutationsBySet: {} }; + } + const collections = await Promise.all( + config.resistanceMutationCollections.map((setConfig) => + backendService.getCollection({ id: String(setConfig.collectionId) }), + ), + ); + return buildResistanceData(config.resistanceMutationCollections, collections); } function buildResistanceData( From a7d8212b4461b9c99c8266135041b5643e46d1f4 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 30 Apr 2026 12:10:09 +0100 Subject: [PATCH 14/17] fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- website/src/components/views/wasap/useWasapPageData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index bff0e49e..69219681 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -22,8 +22,8 @@ import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; /** * Hook that fetches and returns `WasapPageData` for the W-ASAP page, * depending on the analysis mode and analysis mode settings. - * The resistanceMutationSets are needed since that is also a possible - * analysis mode. + * The `resistanceMutationsBySet` data, derived from server-fetched collections, + * is needed because resistance is also a possible analysis mode. */ export function useWasapPageData( config: WasapPageConfig, From c396696295fa8779db3fa4b710de44145bbbfe3a Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 30 Apr 2026 12:11:46 +0100 Subject: [PATCH 15/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- website/src/components/views/wasap/Wasap.astro | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 1fbce93c..7e3ee945 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -16,7 +16,18 @@ type Props = { const { wastewaterOrganism } = Astro.props; const config = (isStaging() ? wastewaterOrganismStagingConfigs : wastewaterOrganismConfigs)[wastewaterOrganism]; -const resistanceData = await fetchResistanceData(config, new BackendService(getBackendHost())); +const backendService = new BackendService(getBackendHost()); +let resistanceData: Awaited> = + [] as Awaited>; + +try { + resistanceData = await fetchResistanceData(config, backendService); +} catch (error) { + console.error('Failed to fetch resistance data for WASAP page', { + wastewaterOrganism, + error, + }); +} --- From 98c8df86da19e5179758ab127786e6ac5ff92230 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 30 Apr 2026 13:19:37 +0200 Subject: [PATCH 16/17] fix log --- website/src/components/views/wasap/Wasap.astro | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 7e3ee945..9f8c89d2 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -4,6 +4,7 @@ import { fetchResistanceData } from './resistanceData'; import { BackendService } from '../../../backendApi/backendService.ts'; import { isStaging, getBackendHost } from '../../../config.ts'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { getInstanceLogger } from '../../../logger.ts'; import { wastewaterOrganismConfigs, wastewaterOrganismStagingConfigs, @@ -17,13 +18,14 @@ type Props = { const { wastewaterOrganism } = Astro.props; const config = (isStaging() ? wastewaterOrganismStagingConfigs : wastewaterOrganismConfigs)[wastewaterOrganism]; const backendService = new BackendService(getBackendHost()); -let resistanceData: Awaited> = - [] as Awaited>; +let resistanceData: Awaited> = [] as Awaited< + ReturnType +>; try { resistanceData = await fetchResistanceData(config, backendService); } catch (error) { - console.error('Failed to fetch resistance data for WASAP page', { + getInstanceLogger('WasapPage').error('Failed to fetch resistance data for WASAP page', { wastewaterOrganism, error, }); From 3ca2a5cdfeab13702a7dd6b6be2883a0421366ca Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 30 Apr 2026 13:28:57 +0200 Subject: [PATCH 17/17] foo --- website/src/components/views/wasap/Wasap.astro | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 9f8c89d2..9d09ead0 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -1,6 +1,6 @@ --- import { WasapPage } from './WasapPage'; -import { fetchResistanceData } from './resistanceData'; +import { fetchResistanceData, type ResistanceData } from './resistanceData'; import { BackendService } from '../../../backendApi/backendService.ts'; import { isStaging, getBackendHost } from '../../../config.ts'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; @@ -18,17 +18,14 @@ type Props = { const { wastewaterOrganism } = Astro.props; const config = (isStaging() ? wastewaterOrganismStagingConfigs : wastewaterOrganismConfigs)[wastewaterOrganism]; const backendService = new BackendService(getBackendHost()); -let resistanceData: Awaited> = [] as Awaited< - ReturnType ->; +let resistanceData: ResistanceData = { mutationAnnotations: [], displayMutationsBySet: {} }; try { resistanceData = await fetchResistanceData(config, backendService); } catch (error) { - getInstanceLogger('WasapPage').error('Failed to fetch resistance data for WASAP page', { - wastewaterOrganism, - error, - }); + getInstanceLogger('WasapPage').error( + `Failed to fetch resistance data for WASAP page (organism: ${wastewaterOrganism}): ${error}`, + ); } ---