diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9fd8c31f1..9f94a8874 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,8 @@ updates: interval: weekly commit-message: prefix: "chore(github-actions)" + cooldown: + default-days: 7 - package-ecosystem: npm directory: website/ schedule: @@ -16,6 +18,8 @@ updates: commit-message: prefix: "chore(website)" target-branch: "main" + cooldown: + default-days: 7 - package-ecosystem: npm directory: website/ schedule: @@ -27,6 +31,8 @@ updates: - "patch" commit-message: prefix: "chore(website)" + cooldown: + default-days: 7 - package-ecosystem: gradle directory: backend/ schedule: @@ -38,3 +44,5 @@ updates: - "patch" commit-message: prefix: "chore(backend)" + cooldown: + default-days: 7 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5eb1736cd..1f67161c1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -70,6 +70,22 @@ jobs: env: BACKEND_URL: http://localhost:9021 + - name: Collect container logs + if: ${{ always() && !cancelled() }} + run: | + mkdir -p /tmp/container-logs + services="$(docker compose -f ../docker-compose.yml config --services || true)" + for service in $services; do + docker compose -f ../docker-compose.yml logs --no-color "$service" > "/tmp/container-logs/${service}.log" || true + done + + - uses: actions/upload-artifact@v7 + if: ${{ always() && !cancelled() }} + with: + name: container-logs + path: /tmp/container-logs/ + retention-days: 7 + - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: diff --git a/.github/workflows/rebaseProd.yml b/.github/workflows/rebaseProd.yml index 7b2cff63b..8ce6f792f 100644 --- a/.github/workflows/rebaseProd.yml +++ b/.github/workflows/rebaseProd.yml @@ -26,9 +26,9 @@ jobs: echo "PR to update prod from main already exists - skipping creation" else body="This pull request updates the \`prod\` branch with the latest changes from the \`main\` branch. - - ### Make sure to merge this creating a merge commit. - **Do not squash-merge** this PR. **Do not rebase and merge**." - + + # ⚠️ Do not squash-merge! ⚠️ + Make sure to merge this creating a merge commit." + gh pr create --base prod --head main --title "chore: update prod from main" --body "$body" fi diff --git a/collection-seeding/README.md b/collection-seeding/README.md index 3ed909a5f..5a6a7d853 100644 --- a/collection-seeding/README.md +++ b/collection-seeding/README.md @@ -5,6 +5,8 @@ Seeds the backend with example collections: - **covid-resistance-mutations** — resistance mutation data for 3CLpro, RdRp, and Spike mAb - **covid-pango-lineages** — one collection per pango lineage, with nucleotide substitutions as variants - **covid-pango-lineages-sample** — same as above but limited to 10 lineages, for quick testing +- **rsv-a-resistance-mutations** — RSV-A F protein resistance mutations against Nirsevimab and Palivizumab (fetched live from ViralZone) +- **rsv-b-resistance-mutations** — RSV-B F protein resistance mutations against Nirsevimab and Palivizumab (fetched live from ViralZone) The script is idempotent — re-running it will create new collections or update existing ones (matched by name). If a collection's name changes in the source, the old entry is orphaned and a new one is created. @@ -30,11 +32,18 @@ Then use the provided tasks: ```bash pixi run seed # all sources -pixi run seed-resistance # resistance mutations only +pixi run seed-resistance # COVID resistance mutations only pixi run seed-lineages # pango lineages only pixi run seed-lineages-sample # first 10 pango lineages (quick test) ``` +RSV sources don't have dedicated tasks — use `--source` directly: + +```bash +pixi run seed --source rsv-a-resistance-mutations +pixi run seed --source rsv-b-resistance-mutations +``` + To target a different backend: ```bash diff --git a/collection-seeding/sources/pango_lineages.py b/collection-seeding/sources/covid_pango_lineages.py similarity index 93% rename from collection-seeding/sources/pango_lineages.py rename to collection-seeding/sources/covid_pango_lineages.py index 048d89b64..78ccbd7d1 100644 --- a/collection-seeding/sources/pango_lineages.py +++ b/collection-seeding/sources/covid_pango_lineages.py @@ -9,7 +9,7 @@ ) -class PangoLineagesSource(Source): +class CovidPangoLineagesSource(Source): """Source: Pango lineage definitions from corneliusroemer/pango-sequences. Creates one collection per lineage, with nucleotide substitutions as variants. @@ -82,8 +82,8 @@ def _build_collection(self, entry: dict) -> Collection: } -class PangoLineagesSampleSource(PangoLineagesSource): - """Same as PangoLineagesSource but limited to the first 10 lineages, for quick testing.""" +class CovidPangoLineagesSampleSource(CovidPangoLineagesSource): + """Same as CovidPangoLineagesSource but limited to the first 10 lineages, for quick testing.""" name = "covid-pango-lineages-sample" include_in_default_run = False diff --git a/collection-seeding/sources/resistance_mutations.py b/collection-seeding/sources/covid_resistance_mutations.py similarity index 99% rename from collection-seeding/sources/resistance_mutations.py rename to collection-seeding/sources/covid_resistance_mutations.py index 30ca66501..0f656043b 100644 --- a/collection-seeding/sources/resistance_mutations.py +++ b/collection-seeding/sources/covid_resistance_mutations.py @@ -2,7 +2,7 @@ from sources import Source -class ResistanceMutationsSource(Source): +class CovidResistanceMutationsSource(Source): """Source: SARS-CoV-2 antiviral resistance mutations (ported from seed.mjs). Three collections covering 3CLpro, RdRp, and Spike mAb resistance mutations diff --git a/collection-seeding/sources/registry.py b/collection-seeding/sources/registry.py index 88d704005..d3915a1d8 100644 --- a/collection-seeding/sources/registry.py +++ b/collection-seeding/sources/registry.py @@ -4,12 +4,15 @@ place that needs to change — seed.py discovers sources exclusively through this list. """ -from sources.pango_lineages import PangoLineagesSource, PangoLineagesSampleSource -from sources.resistance_mutations import ResistanceMutationsSource +from sources.covid_pango_lineages import CovidPangoLineagesSource, CovidPangoLineagesSampleSource +from sources.covid_resistance_mutations import CovidResistanceMutationsSource +from sources.rsv_resistance_mutations import RsvAResistanceMutationsSource, RsvBResistanceMutationsSource from sources import Source ALL_SOURCES: list[type[Source]] = [ - ResistanceMutationsSource, - PangoLineagesSource, - PangoLineagesSampleSource, + CovidResistanceMutationsSource, + RsvAResistanceMutationsSource, + RsvBResistanceMutationsSource, + CovidPangoLineagesSource, + CovidPangoLineagesSampleSource, ] diff --git a/collection-seeding/sources/rsv_resistance_mutations.py b/collection-seeding/sources/rsv_resistance_mutations.py new file mode 100644 index 000000000..e84518e5f --- /dev/null +++ b/collection-seeding/sources/rsv_resistance_mutations.py @@ -0,0 +1,81 @@ +import re +import requests + +from models import Collection, Variant +from sources import Source + +DATA_URL = "https://viralzone.expasy.org/resources/RSV/F_RSV_human.txt" + +# Regex for row parsing. The file is a TSV file, but sometimes also uses spaces. +# Columns 1 (A/B) and 2 (mutation) never contain spaces; column 3 (comment) may. +_ROW_RE = re.compile(r"^\s*([AB])\s+(\S+)\s+(.+)") + + +def _fetch_rows() -> list[tuple[str, list[str], str, str]]: + response = requests.get(DATA_URL, timeout=60) + response.raise_for_status() + return _parse_rows(response.text) + + +def _parse_rows(text: str) -> list[tuple[str, list[str], str, str]]: + """Parse ViralZone RSV resistance text into (rsv_type, aa_mutations, antibody, resistance_type) tuples.""" + rows = [] + for line in text.splitlines(): + m = _ROW_RE.match(line) + if not m: + continue + rsv_type, aa_str, comment = m.group(1), m.group(2), m.group(3).strip() + aa_mutations = [f"F:{part}" for part in aa_str.split("+")] + if "Nirsevimab" in comment: + antibody = "Nirsevimab" + elif "Palivizumab" in comment: + antibody = "Palivizumab" + else: + continue + resistance_type = "Partial resistance" if "Partial resistance" in comment else "Resistance" + rows.append((rsv_type, aa_mutations, antibody, resistance_type)) + return rows + + +def _build_collections(rsv_type: str, organism: str, owned_tag: str) -> list[Collection]: + all_rows = _fetch_rows() + type_rows = [(aa, ab, res) for (t, aa, ab, res) in all_rows if t == rsv_type] + collections = [] + for antibody in ("Nirsevimab", "Palivizumab"): + variants: list[Variant] = [ + { + "type": "filterObject", + "name": res_type, + "filterObject": {"aminoAcidMutations": aa}, + } + for (aa, ab, res_type) in type_rows + if ab == antibody + ] + collections.append({ + "name": f"{antibody} resistance mutations", + "organism": organism, + "description": ( + f"RSV F protein resistance mutations against {antibody} " + f"as per ViralZone (https://viralzone.expasy.org/11605). {owned_tag}" + ), + "variants": variants, + }) + return collections + + +class RsvAResistanceMutationsSource(Source): + name = "rsv-a-resistance-mutations" + organism = "rsvA" + owned_tag = "#resistance-mutation" + + def get_collections(self) -> list[Collection]: + return _build_collections("A", self.organism, self.owned_tag) + + +class RsvBResistanceMutationsSource(Source): + name = "rsv-b-resistance-mutations" + organism = "rsvB" + owned_tag = "#resistance-mutation" + + def get_collections(self) -> list[Collection]: + return _build_collections("B", self.organism, self.owned_tag) diff --git a/collection-seeding/tests/test_pango_lineages.py b/collection-seeding/tests/test_pango_lineages.py index 057301e30..420e306e6 100644 --- a/collection-seeding/tests/test_pango_lineages.py +++ b/collection-seeding/tests/test_pango_lineages.py @@ -1,6 +1,6 @@ import responses as rsps_lib -from sources.pango_lineages import PangoLineagesSource, DATA_URL +from sources.covid_pango_lineages import CovidPangoLineagesSource, DATA_URL SAMPLE_DATA = { "BA.2": { @@ -40,20 +40,20 @@ def test_name(): - assert PangoLineagesSource.name == "covid-pango-lineages" + assert CovidPangoLineagesSource.name == "covid-pango-lineages" # --- _build_collection --- def test_build_collection_basic(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) assert col["name"] == "BA.2" assert col["organism"] == "covid" def test_build_collection_description_format(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) assert "BA.2" in col["description"] assert "BA" in col["description"] # parent assert "22C" in col["description"] # clade @@ -61,18 +61,18 @@ def test_build_collection_description_format(): def test_build_collection_missing_fields_use_defaults(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) assert "—" in col["description"] # parent and clade fallback assert "unknown" in col["description"] # date fallback def test_build_collection_always_four_variants(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) assert len(col["variants"]) == 4 def test_build_collection_variant_names(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) names = [v["name"] for v in col["variants"]] assert names == [ "Nucleotide substitutions", @@ -83,7 +83,7 @@ def test_build_collection_variant_names(): def test_build_collection_variant_filter_keys(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) variants = col["variants"] assert "nucleotideMutations" in variants[0]["filterObject"] assert "aminoAcidMutations" in variants[1]["filterObject"] @@ -92,7 +92,7 @@ def test_build_collection_variant_filter_keys(): def test_build_collection_variant_contents(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) variants = col["variants"] assert variants[0]["filterObject"]["nucleotideMutations"] == ["C241T", "A23403G"] assert variants[1]["filterObject"]["aminoAcidMutations"] == ["S:N501Y"] @@ -101,7 +101,7 @@ def test_build_collection_variant_contents(): def test_build_collection_filters_blank_subs(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) # nucSubstitutions has ["C241T", "A23403G", ""] — blank should be dropped nuc = col["variants"][0]["filterObject"]["nucleotideMutations"] assert "" not in nuc @@ -109,7 +109,7 @@ def test_build_collection_filters_blank_subs(): def test_build_collection_empty_lists_when_all_blanks(): - col = PangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) assert len(col["variants"]) == 4 for v in col["variants"]: lists = list(v["filterObject"].values()) @@ -122,7 +122,7 @@ def test_build_collection_empty_lists_when_all_blanks(): @rsps_lib.activate def test_get_collections_fetches_data_url(): rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) - PangoLineagesSource().get_collections() + CovidPangoLineagesSource().get_collections() assert len(rsps_lib.calls) == 1 assert rsps_lib.calls[0].request.url == DATA_URL @@ -130,7 +130,7 @@ def test_get_collections_fetches_data_url(): @rsps_lib.activate def test_get_collections_includes_all_lineages(): rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) - cols = PangoLineagesSource().get_collections() + cols = CovidPangoLineagesSource().get_collections() # All lineages included regardless of empty subs names = [c["name"] for c in cols] assert "BA.2" in names @@ -141,12 +141,12 @@ def test_get_collections_includes_all_lineages(): @rsps_lib.activate def test_get_collections_respects_limit(): rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) - cols = PangoLineagesSource(limit=1).get_collections() + cols = CovidPangoLineagesSource(limit=1).get_collections() assert len(cols) <= 1 @rsps_lib.activate def test_get_collections_no_limit_returns_all(): rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) - cols = PangoLineagesSource().get_collections() + cols = CovidPangoLineagesSource().get_collections() assert len(cols) == 3 diff --git a/collection-seeding/tests/test_resistance_mutations.py b/collection-seeding/tests/test_resistance_mutations.py index c708f7673..382ede697 100644 --- a/collection-seeding/tests/test_resistance_mutations.py +++ b/collection-seeding/tests/test_resistance_mutations.py @@ -1,8 +1,8 @@ -from sources.resistance_mutations import ResistanceMutationsSource, _mature_name +from sources.covid_resistance_mutations import CovidResistanceMutationsSource, _mature_name def test_name(): - assert ResistanceMutationsSource.name == "covid-resistance-mutations" + assert CovidResistanceMutationsSource.name == "covid-resistance-mutations" # --- _mature_name --- @@ -31,17 +31,17 @@ def test_mature_name_deletion(): def test_get_collections_returns_three(): - cols = ResistanceMutationsSource().get_collections() + cols = CovidResistanceMutationsSource().get_collections() assert len(cols) == 3 def test_get_collections_all_covid(): - for col in ResistanceMutationsSource().get_collections(): + for col in CovidResistanceMutationsSource().get_collections(): assert col["organism"] == "covid" def test_get_collections_variant_structure(): - for col in ResistanceMutationsSource().get_collections(): + for col in CovidResistanceMutationsSource().get_collections(): assert col["variants"], f"'{col['name']}' has no variants" for v in col["variants"]: assert v["type"] == "filterObject" diff --git a/collection-seeding/tests/test_rsv_resistance_mutations.py b/collection-seeding/tests/test_rsv_resistance_mutations.py new file mode 100644 index 000000000..d0b0ffe16 --- /dev/null +++ b/collection-seeding/tests/test_rsv_resistance_mutations.py @@ -0,0 +1,155 @@ +import responses as rsps_lib + +from sources.rsv_resistance_mutations import ( + DATA_URL, + RsvAResistanceMutationsSource, + RsvBResistanceMutationsSource, + _parse_rows, +) + +SAMPLE_TEXT = ( + "#Last update\t14.11.25\n" + "Type\tAA\t\tComment\n" + "\n" + "A\tN67I+N208Y\tNirsevimab Resistance\n" + "A K68E Nirsevimab Partial resistance\t\t\n" + "A\tK272M\t\tPalivizumab Resistance\n" + "B\tI64T\t\tNirsevimab Resistance\t\t\n" + "B\tK65Q\t\tNirsevimab Partial resistance\n" + "B\tK272Q\t\tPalivizumab Resistance\t\n" +) + + +# --- _parse_rows --- + + +def test_parse_rows_single_mutation(): + rows = _parse_rows("B\tI64T\t\tNirsevimab Resistance") + assert rows == [("B", ["F:I64T"], "Nirsevimab", "Resistance")] + + +def test_parse_rows_combo_mutation(): + rows = _parse_rows("A\tN67I+N208Y\tNirsevimab Resistance") + assert rows == [("A", ["F:N67I", "F:N208Y"], "Nirsevimab", "Resistance")] + + +def test_parse_rows_partial_resistance(): + rows = _parse_rows("A\tK68E\t\tNirsevimab Partial resistance") + assert rows[0][3] == "Partial resistance" + + +def test_parse_rows_palivizumab(): + rows = _parse_rows("A\tK272M\t\tPalivizumab Resistance") + assert rows[0][2] == "Palivizumab" + + +def test_parse_rows_with_spaces(): + rows = _parse_rows("A K272M Palivizumab Resistance") + assert rows[0][2] == "Palivizumab" + + +def test_parse_rows_skips_comment_lines(): + rows = _parse_rows(SAMPLE_TEXT) + assert all(r[0] in ("A", "B") for r in rows) + + +def test_parse_rows_skips_header_and_blank_lines(): + rows = _parse_rows(SAMPLE_TEXT) + assert len(rows) == 6 + + +# --- source names --- + + +def test_rsv_a_source_name(): + assert RsvAResistanceMutationsSource.name == "rsv-a-resistance-mutations" + + +def test_rsv_b_source_name(): + assert RsvBResistanceMutationsSource.name == "rsv-b-resistance-mutations" + + +# --- get_collections --- + + +@rsps_lib.activate +def test_rsv_a_returns_two_collections(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvAResistanceMutationsSource().get_collections() + assert len(cols) == 2 + + +@rsps_lib.activate +def test_rsv_a_organism(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvAResistanceMutationsSource().get_collections() + assert all(c["organism"] == "rsvA" for c in cols) + + +@rsps_lib.activate +def test_rsv_b_returns_two_collections(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvBResistanceMutationsSource().get_collections() + assert len(cols) == 2 + + +@rsps_lib.activate +def test_rsv_b_organism(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvBResistanceMutationsSource().get_collections() + assert all(c["organism"] == "rsvB" for c in cols) + + +@rsps_lib.activate +def test_collection_names_are_nirsevimab_and_palivizumab(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvAResistanceMutationsSource().get_collections() + names = {c["name"] for c in cols} + assert names == {"Nirsevimab resistance mutations", "Palivizumab resistance mutations"} + + +@rsps_lib.activate +def test_variant_names_are_resistance_types(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvAResistanceMutationsSource().get_collections() + for col in cols: + for v in col["variants"]: + assert v["name"] in ("Resistance", "Partial resistance") + + +@rsps_lib.activate +def test_variants_have_amino_acid_mutations(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvBResistanceMutationsSource().get_collections() + for col in cols: + for v in col["variants"]: + assert "aminoAcidMutations" in v["filterObject"] + assert len(v["filterObject"]["aminoAcidMutations"]) >= 1 + + +@rsps_lib.activate +def test_combo_mutation_produces_multiple_aa_entries(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvAResistanceMutationsSource().get_collections() + nirsevimab_col = next(c for c in cols if "Nirsevimab" in c["name"]) + combo_variant = next( + v for v in nirsevimab_col["variants"] + if len(v["filterObject"]["aminoAcidMutations"]) > 1 + ) + assert combo_variant["filterObject"]["aminoAcidMutations"] == ["F:N67I", "F:N208Y"] + + +@rsps_lib.activate +def test_only_rsv_b_rows_in_rsv_b_collections(): + rsps_lib.add(rsps_lib.GET, DATA_URL, body=SAMPLE_TEXT, status=200) + cols = RsvBResistanceMutationsSource().get_collections() + # RSV-B Nirsevimab should have I64T and K65Q (not A's K68E) + nirsevimab_col = next(c for c in cols if "Nirsevimab" in c["name"]) + all_aa = [ + aa + for v in nirsevimab_col["variants"] + for aa in v["filterObject"]["aminoAcidMutations"] + ] + assert "F:I64T" in all_aa + assert "F:K65Q" in all_aa + assert "F:K68E" not in all_aa # RSV-A only diff --git a/website/package-lock.json b/website/package-lock.json index 51e83bdfd..9db2ea4ba 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -10,16 +10,17 @@ "dependencies": { "@astrojs/node": "^9.5.3", "@genspectrum/dashboard-components": "^1.17.0", - "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query": "^5.101.0", "astro": "^5.18.1", - "axios": "^1.16.0", - "better-auth": "^1.6.9", + "axios": "^1.16.1", + "better-auth": "^1.6.14", "cookie": "^1.1.1", - "dayjs": "^1.11.20", - "katex": "^0.16.45", + "dayjs": "^1.11.21", + "downshift": "^9.3.3", + "katex": "^0.17.0", "patch-package": "^8.0.1", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-katex": "^3.1.0", "react-toastify": "^11.1.0", "uuid": "^14.0.0", @@ -36,21 +37,21 @@ "@iconify/tailwind4": "^1.2.3", "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", - "@tanstack/eslint-plugin-query": "^5.100.10", + "@tanstack/eslint-plugin-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.5.2", - "@types/react": "^19.0.0", + "@types/node": "^24.12.4", + "@types/react": "^19.2.16", "@types/react-dom": "^19.0.0", "@types/react-katex": "^3.0.4", "@types/topojson-specification": "^1.0.5", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", "@vitest/browser": "^3.2.4", "astro-eslint-parser": "^1.4.0", - "daisyui": "^5.5.19", + "daisyui": "^5.5.20", "dotenv": "^16.5.0", "eslint": "^9.39.2", "eslint-plugin-astro": "^1.7.0", @@ -64,7 +65,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.0.9", "typescript": "^5.9.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.60.1", "vitest": "^3.2.4", "vitest-browser-react": "^1.0.1", "zod": "^3.25.74" @@ -124,8 +125,7 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { "version": "0.7.5", @@ -289,7 +289,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -316,7 +316,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -593,140 +592,11 @@ "node": ">=6.9.0" } }, - "node_modules/@better-auth/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.9.tgz", - "integrity": "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.39.0", - "@standard-schema/spec": "^1.1.0", - "zod": "^4.3.6" - }, - "peerDependencies": { - "@better-auth/utils": "0.4.0", - "@better-fetch/fetch": "1.1.21", - "@cloudflare/workers-types": ">=4", - "@opentelemetry/api": "^1.9.0", - "better-call": "1.3.5", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@better-auth/core/node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@better-auth/drizzle-adapter": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.9.tgz", - "integrity": "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0", - "drizzle-orm": "^0.45.2" - }, - "peerDependenciesMeta": { - "drizzle-orm": { - "optional": true - } - } - }, - "node_modules/@better-auth/kysely-adapter": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.9.tgz", - "integrity": "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0", - "kysely": "^0.28.14" - }, - "peerDependenciesMeta": { - "kysely": { - "optional": true - } - } - }, - "node_modules/@better-auth/memory-adapter": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.9.tgz", - "integrity": "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0" - } - }, - "node_modules/@better-auth/mongo-adapter": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.9.tgz", - "integrity": "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0", - "mongodb": "^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "mongodb": { - "optional": true - } - } - }, - "node_modules/@better-auth/prisma-adapter": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.9.tgz", - "integrity": "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@prisma/client": { - "optional": true - }, - "prisma": { - "optional": true - } - } - }, - "node_modules/@better-auth/telemetry": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.9.tgz", - "integrity": "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.9", - "@better-auth/utils": "0.4.0", - "@better-fetch/fetch": "1.1.21" - } - }, "node_modules/@better-auth/utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", - "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.1.tgz", + "integrity": "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "^2.0.1" } @@ -734,8 +604,7 @@ "node_modules/@better-fetch/fetch": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==", - "peer": true + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", @@ -2072,7 +1941,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2082,7 +1951,7 @@ "version": "6.0.12", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@inquirer/core": "^11.1.9", @@ -2104,7 +1973,7 @@ "version": "11.1.9", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.5", @@ -2131,7 +2000,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2141,7 +2010,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2247,7 +2116,7 @@ "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -2327,14 +2196,14 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@open-draft/logger": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", @@ -2345,13 +2214,13 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -2396,7 +2265,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@rolldown/pluginutils": { @@ -3156,9 +3025,9 @@ } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.100.10.tgz", - "integrity": "sha512-Ddou3agTWv5rvHSBby4yHlugUFHVh0nyo2fyoZ81qSxaTBIwNCoPgpiJhjo5QkThrH1wGC7k548BcMTszZCkBw==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.0.tgz", + "integrity": "sha512-wsfg821y4yw21J7nKI2oM5yyGSz3vASXqgWbmWCXZpnyY9ObLrBCcXivwZKj4YHF2fUWiqoOIRX2pbE79cf6gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3179,9 +3048,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", "license": "MIT", "funding": { "type": "github", @@ -3189,12 +3058,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", - "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.10" + "@tanstack/query-core": "5.101.0" }, "funding": { "type": "github", @@ -3221,9 +3090,8 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3297,7 +3165,7 @@ "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12", @@ -3311,7 +3179,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -3446,23 +3314,21 @@ } }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3473,7 +3339,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3492,7 +3357,7 @@ "version": "2.4.10", "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3502,7 +3367,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/topojson-specification": { @@ -3534,17 +3399,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3557,7 +3422,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3573,17 +3438,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3599,14 +3463,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3621,14 +3485,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3639,9 +3503,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -3656,15 +3520,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3681,9 +3545,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -3695,16 +3559,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3723,16 +3587,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3747,13 +3611,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3818,9 +3682,8 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -4075,7 +3938,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4093,6 +3955,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4210,7 +4084,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -4401,7 +4275,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.1.tgz", "integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", @@ -5179,13 +5052,14 @@ } }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -5222,26 +5096,26 @@ "license": "MIT" }, "node_modules/better-auth": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.9.tgz", - "integrity": "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.9", - "@better-auth/drizzle-adapter": "1.6.9", - "@better-auth/kysely-adapter": "1.6.9", - "@better-auth/memory-adapter": "1.6.9", - "@better-auth/mongo-adapter": "1.6.9", - "@better-auth/prisma-adapter": "1.6.9", - "@better-auth/telemetry": "1.6.9", - "@better-auth/utils": "0.4.0", + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.14.tgz", + "integrity": "sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.14", + "@better-auth/drizzle-adapter": "1.6.14", + "@better-auth/kysely-adapter": "1.6.14", + "@better-auth/memory-adapter": "1.6.14", + "@better-auth/mongo-adapter": "1.6.14", + "@better-auth/prisma-adapter": "1.6.14", + "@better-auth/telemetry": "1.6.14", + "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", - "kysely": "^0.28.14", + "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, @@ -5326,30 +5200,129 @@ } } }, - "node_modules/better-auth/node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.14.tgz", + "integrity": "sha512-12cA7tnR4Wyb3nLpPmeq/Id7QNB+4OhjbzuX7sIhqglgXGjyT5iiNpe2lx/8FF532sHC450Yx1850salCYbkzw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.1", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } } }, - "node_modules/better-auth/node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.14.tgz", + "integrity": "sha512-lYs1jDudriKYMXNcLFLAvEvOEKbeKBFdDciG4H8qZhV+3+yghGC3f/H5qtgTDc8mGBPV+2tEvVgYqReurOSmNw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } } }, - "node_modules/better-call": { + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.14.tgz", + "integrity": "sha512-A2+381gYADuZpgd98XQ39bnxLzbT03wnnDmSQIXp7XcE3hF093mGMk6rxlAhENVHH7JL2B0Tv2la2o6n+6ppyQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.14.tgz", + "integrity": "sha512-frtBTozi8qsBlypxp33dkiIZT2IOMvix3oh2qTTcBkK11ISsRSTUUadl7DbwXri2AEoooShsH6PSAput920J3Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.14.tgz", + "integrity": "sha512-meaZx712k9c0Cl6urwYZRNa3mAy3/leaYiSNt+hVaCOEPlgTDxzmYMNACvTTYXgh4eCpDVf5G7ZMEYBtejKQdw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.14.tgz", + "integrity": "sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.14.tgz", + "integrity": "sha512-ALi3cEx5eyrFY+TeAdhc1uq8FqJyGvzgvIo7GQZOqGqLZxHY9nte44WN++jBFGJJbsW3e4cgLj8dQK291s6wWQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.14", + "@better-auth/utils": "0.4.1", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/better-auth/node_modules/better-call": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", "license": "MIT", - "peer": true, "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", @@ -5365,11 +5338,23 @@ } } }, - "node_modules/better-call/node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "license": "MIT" + "node_modules/better-auth/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } }, "node_modules/boolbase": { "version": "1.0.0", @@ -5466,7 +5451,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5658,7 +5642,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -5744,7 +5727,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">= 12" @@ -5754,7 +5737,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5769,14 +5752,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -5791,7 +5774,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5804,7 +5787,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6157,9 +6140,9 @@ } }, "node_modules/daisyui": { - "version": "5.5.19", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", - "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "version": "5.5.20", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.20.tgz", + "integrity": "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ==", "dev": true, "license": "MIT", "funding": { @@ -6221,9 +6204,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { @@ -6420,7 +6403,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dom-serializer": { @@ -6513,9 +6496,9 @@ } }, "node_modules/downshift": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.3.2.tgz", - "integrity": "sha512-5VD0WZLQDhipWiDU+K5ili3VDhGrXwlvOlSaSG1Cb0eS4XpssxVuoD09JNgju+bAzxB2Wvlwx+FwTE/FNdrqow==", + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.3.3.tgz", + "integrity": "sha512-otjI/WtoFcIXwPOyIQYkbrAZJA2Gjz67F8ENOh1RvmJalOg6fk0KEx4ljJSFuJB54ryc6doymPkCM+GjAZ7zjA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", @@ -6852,7 +6835,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6883,7 +6866,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7445,14 +7427,14 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-string-width": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-string-truncated-width": "^3.0.2" @@ -7479,7 +7461,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -7791,7 +7773,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7935,7 +7917,7 @@ "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -8237,7 +8219,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/set-cookie-parser": "^2.4.10", @@ -8248,7 +8230,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/html-escaper": { @@ -8293,6 +8275,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8652,7 +8647,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-number": { @@ -8905,16 +8900,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9044,9 +9029,9 @@ } }, "node_modules/katex": { - "version": "0.16.45", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", - "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -9104,13 +9089,12 @@ "license": "MIT" }, "node_modules/kysely": { - "version": "0.28.16", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.16.tgz", - "integrity": "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==", + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.2.tgz", + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==", "license": "MIT", - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/leaflet": { @@ -9497,7 +9481,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -10490,7 +10474,7 @@ "version": "2.14.6", "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -10535,14 +10519,14 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/msw/node_modules/type-fest": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -10565,7 +10549,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -10600,7 +10584,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -10924,7 +10907,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/own-keys": { @@ -11175,7 +11158,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/pathe": { @@ -11235,7 +11218,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -11254,7 +11237,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -11341,7 +11324,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11358,7 +11340,6 @@ "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", @@ -11451,7 +11432,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -11466,7 +11447,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -11479,7 +11460,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/prismjs": { @@ -11518,7 +11499,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -11597,26 +11577,24 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-is": { @@ -11638,6 +11616,31 @@ "react": ">=15.3.2 <20" } }, + "node_modules/react-katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/react-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -11924,7 +11927,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12036,7 +12039,7 @@ "version": "0.11.11", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/reusify": { @@ -12055,7 +12058,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -12304,6 +12306,12 @@ "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12525,7 +12533,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -12548,7 +12556,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -12655,7 +12663,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string_decoder": { @@ -12969,7 +12977,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -12983,8 +12991,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -13076,7 +13083,7 @@ "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.28" @@ -13089,7 +13096,7 @@ "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tmp": { @@ -13140,7 +13147,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -13150,7 +13157,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -13368,7 +13375,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13388,16 +13394,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13449,9 +13455,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true, "license": "MIT" }, @@ -13621,7 +13627,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/kettanaito" @@ -13734,7 +13740,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13866,7 +13871,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -14378,7 +14382,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -14471,7 +14474,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -14499,7 +14502,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=10" @@ -14556,7 +14559,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14614,7 +14616,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -14642,14 +14644,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14664,7 +14666,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14717,7 +14719,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/website/package.json b/website/package.json index ab76c1d72..8a0a94ab2 100644 --- a/website/package.json +++ b/website/package.json @@ -26,16 +26,17 @@ "dependencies": { "@astrojs/node": "^9.5.3", "@genspectrum/dashboard-components": "^1.17.0", - "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query": "^5.101.0", "astro": "^5.18.1", - "axios": "^1.16.0", - "better-auth": "^1.6.9", + "axios": "^1.16.1", + "better-auth": "^1.6.14", "cookie": "^1.1.1", - "dayjs": "^1.11.20", - "katex": "^0.16.45", + "dayjs": "^1.11.21", + "katex": "^0.17.0", + "downshift": "^9.3.3", "patch-package": "^8.0.1", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-katex": "^3.1.0", "react-toastify": "^11.1.0", "uuid": "^14.0.0", @@ -52,21 +53,21 @@ "@iconify/tailwind4": "^1.2.3", "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", - "@tanstack/eslint-plugin-query": "^5.100.10", + "@tanstack/eslint-plugin-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.5.2", - "@types/react": "^19.0.0", + "@types/node": "^24.12.4", + "@types/react": "^19.2.16", "@types/react-dom": "^19.0.0", "@types/react-katex": "^3.0.4", "@types/topojson-specification": "^1.0.5", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", "@vitest/browser": "^3.2.4", "astro-eslint-parser": "^1.4.0", - "daisyui": "^5.5.19", + "daisyui": "^5.5.20", "dotenv": "^16.5.0", "eslint": "^9.39.2", "eslint-plugin-astro": "^1.7.0", @@ -80,7 +81,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.0.9", "typescript": "^5.9.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.60.1", "vitest": "^3.2.4", "vitest-browser-react": "^1.0.1", "zod": "^3.25.74" diff --git a/website/src/backendApi/backendService.ts b/website/src/backendApi/backendService.ts index 8019e6d9d..dbe332519 100644 --- a/website/src/backendApi/backendService.ts +++ b/website/src/backendApi/backendService.ts @@ -177,8 +177,21 @@ export class BackendService extends ApiService { return this.get({ url: `/users/${id}`, schema: publicUserSchema }); } - public async getCollectionSummaries(requestParams: { organism?: string; excludeSystemCollections?: boolean } = {}) { - return this.get({ url: '/collections', requestParams, schema: z.array(collectionSummarySchema) }); + public async getCollectionSummaries({ + organism, + userId, + excludeSystemCollections, + }: { organism?: string; userId?: number; excludeSystemCollections?: boolean } = {}) { + const requestParams: Record = {}; + if (organism !== undefined) requestParams.organism = organism; + if (userId !== undefined) requestParams.userId = String(userId); + if (excludeSystemCollections !== undefined) + requestParams.excludeSystemCollections = String(excludeSystemCollections); + return this.get({ + url: '/collections', + requestParams: Object.keys(requestParams).length > 0 ? requestParams : undefined, + schema: z.array(collectionSummarySchema), + }); } public async getCollections({ organism }: { organism?: string } = {}) { diff --git a/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx b/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx index c75b1b337..715d4940f 100644 --- a/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx +++ b/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx @@ -144,12 +144,12 @@ export function ExplorationModeInfo() {
  • Variant Explorer: track variant-specific - mutations over time. Variant-specific mutations are computed based on user parameters and filtering - clinical sequences from{' '} + mutations over time. Variants can be defined in two ways: by selecting a predefined variant from a + curated list, or by filtering clinical sequences from{' '} CovSpectrum - - . + {' '} + based on user parameters.
  • Untracked Mutations: novel mutations not yet diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 5f5c483ff..f968b786d 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { type Dispatch, type SetStateAction, useState } from 'react'; +import { getBackendServiceForClientside } from '../../../backendApi/backendService'; import { ApplyFilterButton } from '../ApplyFilterButton'; import { DynamicDateFilter } from '../DynamicDateFilter'; import { SelectorHeadline } from '../SelectorHeadline'; @@ -101,6 +102,24 @@ export function WasapPageStateSelector({ }, }); + const predefinedVariantsQueryResult = useQuery({ + enabled: config.variantAnalysisModeEnabled && config.predefinedVariantsSource !== undefined, + queryKey: ['predefinedVariants', config.variantAnalysisModeEnabled && config.predefinedVariantsSource], + queryFn: async () => { + if (!config.variantAnalysisModeEnabled || config.predefinedVariantsSource === undefined) { + throw Error( + 'This predefined variants query was called despite it being disabled. This should not happen.', + ); + } + const { collectionsUserId, collectionsTag } = config.predefinedVariantsSource; + const collections = await getBackendServiceForClientside().getCollectionSummaries({ + userId: collectionsUserId, + organism: config.internalName, + }); + return collections.filter((c) => c.description?.includes(collectionsTag) ?? false); + }, + }); + return (
    Filter dataset @@ -181,6 +200,12 @@ export function WasapPageStateSelector({ setPageState={setVariantFilter} clinicalSequenceLapisBaseUrl={config.clinicalLapis.lapisBaseUrl} clinicalSequenceLapisLineageField={config.clinicalLapis.lineageField} + predefinedVariantsQueryResult={ + config.predefinedVariantsSource !== undefined + ? predefinedVariantsQueryResult + : undefined + } + predefinedVariantsLabel={config.predefinedVariantsSource?.variantSourceLabel} /> ); case 'resistance': diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx index dca132918..e3c3e69ff 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx @@ -1,17 +1,26 @@ -import { userEvent } from '@vitest/browser/context'; +import { type UseQueryResult } from '@tanstack/react-query'; +import { page, userEvent } from '@vitest/browser/context'; import { describe, expect, vi } from 'vitest'; import { render } from 'vitest-browser-react'; import { VariantExplorerFilter } from './VariantExplorerFilter'; import { DUMMY_LAPIS_URL, type LapisRouteMocker } from '../../../../../routeMocker'; import { it } from '../../../../../test-extend'; +import type { CollectionSummary } from '../../../../types/Collection'; import type { WasapVariantFilter } from '../../../views/wasap/wasapPageConfig'; const DUMMY_LAPIS_URL_2 = 'http://lapis2.dummy'; +const DUMMY_COLLECTIONS: CollectionSummary[] = [ + { id: 1, name: 'XBB.1.5', ownedBy: 1, organism: 'SARS-CoV-2', description: null, variantCount: 5 }, + { id: 2, name: 'JN.1', ownedBy: 1, organism: 'SARS-CoV-2', description: null, variantCount: 3 }, +]; +const mockPredefinedQueryResult = { data: DUMMY_COLLECTIONS } as unknown as UseQueryResult; + describe('VariantExplorerFilter', () => { const defaultPageState: WasapVariantFilter = { mode: 'variant', + signatureType: 'computed', sequenceType: 'nucleotide', variant: undefined, minProportion: 0.05, @@ -20,6 +29,11 @@ describe('VariantExplorerFilter', () => { timeFrame: 'all', }; + const predefinedPageState: WasapVariantFilter = { + ...defaultPageState, + signatureType: 'predefined', + }; + it('calls setPageState when changing sequence type', async ({ routeMockers: { lapis } }) => { setupLapisMocks(lapis); const mockSetPageState = vi.fn(); @@ -98,6 +112,87 @@ describe('VariantExplorerFilter', () => { minProportion: 0.1, }); }); + + it('calls setPageState when switching to predefined signature type', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + render( + + + , + ); + + const variantSourceSelect = page.getByRole('combobox').first(); + await variantSourceSelect.selectOptions('predefined'); + + expect(mockSetPageState).toHaveBeenCalledWith({ + ...defaultPageState, + signatureType: 'predefined', + }); + }); + + it('calls setPageState when selecting a predefined collection', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + const { getByRole } = render( + + + , + ); + + const collectionInput = page.getByPlaceholder('Select variant'); + await collectionInput.click(); + await userEvent.type(collectionInput, 'XBB'); + + const option = await vi.waitFor(() => getByRole('option', { name: 'XBB.1.5', exact: true })); + await option.click(); + + await vi.waitFor(() => { + expect(mockSetPageState).toHaveBeenCalledWith({ + ...predefinedPageState, + collectionId: 1, + }); + }); + }); + + it('calls setPageState when toggling "Mutation not in parent"', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + const { getByLabelText } = render( + + + , + ); + + const checkbox = getByLabelText('Mutation not in parent'); + await checkbox.click(); + + expect(mockSetPageState).toHaveBeenCalledWith({ + ...predefinedPageState, + newMutationsOnly: true, + }); + }); }); function setupLapisMocks(lapisRouteMocker: LapisRouteMocker) { diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx index 635f01fb7..6739cf1b5 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx @@ -1,13 +1,19 @@ +import { type UseQueryResult } from '@tanstack/react-query'; +import { useId } from 'react'; + import { Inset } from '../../../../styles/Inset'; +import { type CollectionSummary } from '../../../../types/Collection'; import { GsLineageFilter } from '../../../genspectrum/GsLineageFilter'; import { VARIANT_TIME_FRAME, variantTimeFrameLabel, + type SignatureType, type VariantTimeFrame, type WasapVariantFilter, } from '../../../views/wasap/wasapPageConfig'; import { SelectorHeadline } from '../../SelectorHeadline'; import { DefineClinicalSignatureInfo } from '../InfoBlocks'; +import { CollectionCombobox } from '../utils/CollectionCombobox'; import { LabeledField } from '../utils/LabeledField'; import { NumericInput } from '../utils/NumericInput'; import { SequenceTypeSelector } from '../utils/SequenceTypeSelector'; @@ -21,6 +27,8 @@ interface VariantExplorerFilterProps { */ clinicalSequenceLapisBaseUrl: string; clinicalSequenceLapisLineageField: string; + predefinedVariantsQueryResult?: UseQueryResult; + predefinedVariantsLabel?: string; } export function VariantExplorerFilter({ @@ -28,69 +36,148 @@ export function VariantExplorerFilter({ setPageState, clinicalSequenceLapisBaseUrl, clinicalSequenceLapisLineageField, + predefinedVariantsQueryResult, + predefinedVariantsLabel = 'Predefined', }: VariantExplorerFilterProps) { + const handleSignatureTypeChange = (newType: SignatureType) => { + setPageState({ ...pageState, signatureType: newType }); + }; + return ( <> setPageState({ ...pageState, sequenceType })} /> - - }>Define Clinical Signature - - - { - setPageState({ ...pageState, variant: lineages[clinicalSequenceLapisLineageField] }); - }} - hideCounts={true} - /> - - - setPageState({ ...pageState, minProportion: v })} - /> - setPageState({ ...pageState, minCount: Math.round(v) })} - /> - setPageState({ ...pageState, minJaccard: v })} - /> - + {predefinedVariantsQueryResult !== undefined && ( + - + )} + {pageState.signatureType === 'predefined' && ( + + )} + {pageState.signatureType === 'computed' && ( + + }> + Define Clinical Signature + + + + { + setPageState({ + ...pageState, + variant: lineages[clinicalSequenceLapisLineageField], + }); + }} + hideCounts={true} + /> + + + setPageState({ ...pageState, minProportion: v })} + /> + setPageState({ ...pageState, minCount: Math.round(v) })} + /> + setPageState({ ...pageState, minJaccard: v })} + /> + + + + + )} ); } + +function PredefinedSignature({ + pageState, + setPageState, + predefinedVariantsQueryResult, +}: { + pageState: WasapVariantFilter; + setPageState: (newState: WasapVariantFilter) => void; + predefinedVariantsQueryResult: UseQueryResult | undefined; +}) { + const collections = predefinedVariantsQueryResult?.data ?? []; + const selectedCollection = collections.find((c) => c.id === pageState.collectionId) ?? null; + const newMutationsCheckBoxId = useId(); + + return ( + + + setPageState({ ...pageState, collectionId: c?.id })} + /> + +
    + setPageState({ ...pageState, newMutationsOnly: e.target.checked })} + /> +
    + +
    +
    +
    + ); +} diff --git a/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx new file mode 100644 index 000000000..1e887fbd9 --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx @@ -0,0 +1,155 @@ +import { userEvent } from '@vitest/browser/context'; +import { describe, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { CollectionCombobox } from './CollectionCombobox'; +import { it } from '../../../../../test-extend'; +import type { CollectionSummary } from '../../../../types/Collection'; + +const makeCollection = (id: number, name: string): CollectionSummary => ({ + id, + name, + ownedBy: 1, + organism: 'test', + description: null, + variantCount: 0, +}); + +const collections = [makeCollection(1, 'Alpha'), makeCollection(2, 'Beta'), makeCollection(3, 'Gamma')]; + +describe('CollectionCombobox', () => { + describe('onInputBlur', () => { + it('calls onChange(null) when blurred with empty input', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill(''); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('calls onChange with matched collection when blurred with an exact name', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Beta'); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(collections[1]); + }); + + it('calls onChange with matched collection when input has surrounding whitespace', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill(' Beta '); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(collections[1]); + }); + + it('shows error state and does not call onChange when blurred with an unrecognised name', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Unknown'); + await userEvent.tab(); + + expect(onChange).not.toHaveBeenCalled(); + const wrapper = getByRole('combobox').element().closest('.input'); + await expect.element(wrapper as HTMLElement).toHaveClass('input-error'); + }); + + it('clears error state when user starts typing after an invalid blur', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Unknown'); + await userEvent.tab(); + + const wrapper = getByRole('combobox').element().closest('.input'); + await expect.element(wrapper as HTMLElement).toHaveClass('input-error'); + + await getByRole('combobox').fill('A'); + await expect.element(wrapper as HTMLElement).not.toHaveClass('input-error'); + }); + }); + + describe('clear button', () => { + it('is not rendered when input is empty', () => { + const { getByLabelText } = render( + , + ); + + expect(getByLabelText('clear selection').elements()).toHaveLength(0); + }); + + it('is visible when a value is selected', async () => { + const { getByLabelText } = render( + , + ); + + await expect.element(getByLabelText('clear selection')).toBeVisible(); + }); + + it('clears the input and calls onChange(null) when clicked', async () => { + const onChange = vi.fn(); + const { getByLabelText, getByRole } = render( + , + ); + + await getByLabelText('clear selection').click(); + + expect(onChange).toHaveBeenCalledWith(null); + await expect.element(getByRole('combobox')).toHaveValue(''); + }); + + it('disappears after being clicked', async () => { + const onChange = vi.fn(); + const { getByLabelText } = render( + , + ); + + await getByLabelText('clear selection').click(); + + expect(getByLabelText('clear selection').elements()).toHaveLength(0); + }); + }); + + describe('filtering', () => { + it('shows only matching items when typing', async () => { + const { getByRole, getByText } = render( + , + ); + + await getByRole('button', { name: 'toggle menu' }).click(); + await getByRole('combobox').fill('Al'); + + await expect.element(getByText('Alpha')).toBeVisible(); + expect(getByText('Beta').elements()).toHaveLength(0); + expect(getByText('Gamma').elements()).toHaveLength(0); + }); + + it('shows "No variants found" when no collections match the input', async () => { + const { getByRole, getByText } = render( + , + ); + + await getByRole('button', { name: 'toggle menu' }).click(); + await getByRole('combobox').fill('zzz'); + + await expect.element(getByText('No variants found.')).toBeVisible(); + }); + }); +}); diff --git a/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx new file mode 100644 index 000000000..cb22c2ada --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx @@ -0,0 +1,127 @@ +import { useCombobox } from 'downshift'; +import { useMemo, useRef, useState } from 'react'; + +import { type CollectionSummary } from '../../../../types/Collection'; + +export function CollectionCombobox({ + collections, + value, + onChange, + placeholderText = 'Select variant', +}: { + collections: CollectionSummary[]; + value: CollectionSummary | null; + onChange: (item: CollectionSummary | null) => void; + placeholderText?: string; +}) { + const [inputFilter, setInputFilter] = useState(() => value?.name ?? ''); + const [inputIsInvalid, setInputIsInvalid] = useState(false); + + const sortedCollections = useMemo( + () => [...collections].sort((a, b) => a.name.localeCompare(b.name)), + [collections], + ); + + const filteredCollections = useMemo( + () => sortedCollections.filter((c) => c.name.toLowerCase().includes(inputFilter.toLowerCase())), + [sortedCollections, inputFilter], + ); + + const buttonRef = useRef(null); + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + inputValue, + closeMenu, + reset, + } = useCombobox({ + items: filteredCollections, + itemToString: (item) => item?.name ?? '', + selectedItem: value, + onInputValueChange({ inputValue }) { + setInputIsInvalid(false); + setInputFilter(inputValue.trim()); + }, + onSelectedItemChange({ selectedItem }) { + onChange(selectedItem ?? null); + }, + }); + + const onInputBlur = () => { + if (inputValue === '') { + onChange(null); + return; + } + const match = collections.find((c) => c.name === inputValue.trim()); + if (match !== undefined) { + onChange(match); + return; + } + setInputIsInvalid(true); + }; + + return ( +
    +
    { + if (e.relatedTarget !== buttonRef.current) { + closeMenu(); + } + }} + > + + {inputValue !== '' && ( + + )} + +
    +
      + {filteredCollections.length > 0 ? ( + filteredCollections.map((item, index) => ( +
    • + {item.name} +
    • + )) + ) : ( +
    • No variants found.
    • + )} +
    +
    + ); +} diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 85b5be6a4..f7de97c3e 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -1,15 +1,8 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { type FC } from 'react'; -import { CollectionInfo } from './components/CollectionInfo'; -import { NoDataHelperText } from './components/NoDataHelperText'; -import { VariantFetchInfo } from './components/VariantFetchInfo'; -import { WasapStats } from './components/WasapStats'; -import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; -import type { ResistanceData } from './resistanceData'; -import { useWasapPageData } from './useWasapPageData'; -import type { WasapPageConfig } from './wasapPageConfig'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; +import { getClientLogger } from '../../../clientLogger'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs.tsx'; import { DataPageLayout } from '../../../layouts/OrganismPage/DataPageLayout.tsx'; import { dataOrigins } from '../../../types/dataOrigins.ts'; @@ -20,6 +13,16 @@ import { GsMutationsOverTime } from '../../genspectrum/GsMutationsOverTime'; import { GsQueriesOverTime } from '../../genspectrum/GsQueriesOverTime.tsx'; import { WasapPageStateSelector } from '../../pageStateSelectors/wasap/WasapPageStateSelector'; import { usePageState } from '../usePageState.ts'; +import { CollectionInfo } from './components/CollectionInfo'; +import { NoDataHelperText } from './components/NoDataHelperText'; +import { VariantFetchInfo } from './components/VariantFetchInfo'; +import { WasapStats } from './components/WasapStats'; +import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; +import type { ResistanceData } from './resistanceData'; +import { useWasapPageData } from './useWasapPageData'; +import type { WasapPageConfig } from './wasapPageConfig'; + +const logger = getClientLogger('WasapPage'); export type WasapPageProps = { config: WasapPageConfig; @@ -37,7 +40,13 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = const { mutationAnnotations, displayMutationsBySet } = resistanceData; // fetch which mutations should be analyzed - const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); + const { data, isPending, isError, error } = useWasapPageData(config, displayMutationsBySet, analysis); + + useEffect(() => { + if (error) { + logger.error(`Failed to fetch wasap page data: ${error instanceof Error ? error.message : String(error)}`); + } + }, [error]); const initialMeanProportionInterval = getInitialMeanProportionInterval(analysis); @@ -77,7 +86,16 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = />
    {isError ? ( - There was an error fetching the data to display. + analysis.mode === 'variant' && + analysis.signatureType === 'predefined' && + analysis.collectionId === undefined ? ( +
    +

    No variant selected

    +

    Please select a variant from the filter panel.

    +
    + ) : ( + There was an error fetching the data to display. + ) ) : isPending ? ( ) : ( @@ -101,15 +119,17 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = customColumns={data.customColumns} /> )} - {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( - - )} + {analysis.mode === 'variant' && + analysis.signatureType === 'computed' && + config.variantAnalysisModeEnabled && ( + + )} ) : ( <> diff --git a/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts index 03f38a240..521a2e8d9 100644 --- a/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts +++ b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts @@ -27,6 +27,7 @@ describe('getInitialMeanProportionInterval', () => { test('other analysis states initially show the full mean proportion range', () => { const analysis: WasapAnalysisFilter = { mode: 'variant', + signatureType: 'computed', sequenceType: 'nucleotide', variant: 'XFG*', minProportion: 0.8, diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 5799daf5e..9d69bde1e 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -12,6 +12,7 @@ import type { WasapUntrackedFilter, WasapVariantFilter, } from './wasapPageConfig'; +import { getBackendServiceForClientside } from '../../../backendApi/backendService'; import { getCollection } from '../../../covspectrum/getCollection'; import { detailedMutationsToQuery } from '../../../covspectrum/variantConversionUtil'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; @@ -68,6 +69,21 @@ function fetchManualModeData(config: WasapPageConfig, analysis: WasapManualFilte async function fetchVariantModeData( config: WasapPageConfig, analysis: WasapVariantFilter, +): Promise { + if (!config.variantAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'variant' mode is not enabled."); + } + switch (analysis.signatureType) { + case 'computed': + return fetchVariantComputedModeData(config, analysis); + case 'predefined': + return fetchVariantPredefinedModeData(analysis); + } +} + +async function fetchVariantComputedModeData( + config: WasapPageConfig, + analysis: WasapVariantFilter, ): Promise { if (!config.variantAnalysisModeEnabled) { throw Error("Cannot fetch data, 'variant' mode is not enabled."); @@ -97,6 +113,36 @@ async function fetchVariantModeData( }; } +async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Promise { + if (analysis.collectionId === undefined) { + throw new Error('No collection selected for predefined variant mode.'); + } + const collection = await getBackendServiceForClientside().getCollection({ id: String(analysis.collectionId) }); + + // These names match the variant names hardcoded in the collection seeder. + let variantName: string; + if (analysis.sequenceType === 'nucleotide') { + variantName = analysis.newMutationsOnly ? 'New nucleotide substitutions' : 'Nucleotide substitutions'; + } else { + variantName = analysis.newMutationsOnly ? 'New amino acid substitutions' : 'Amino acid substitutions'; + } + + const variant = collection.variants.find((v) => v.name === variantName); + if (!variant) { + throw new Error(`Variant "${variantName}" not found in collection ${collection.id}.`); + } + if (variant.type !== 'filterObject') { + throw new Error(`Variant "${variantName}" in collection ${collection.id} is not a filterObject variant.`); + } + + const mutations = + analysis.sequenceType === 'nucleotide' + ? (variant.filterObject.nucleotideMutations ?? []) + : (variant.filterObject.aminoAcidMutations ?? []); + + return { type: 'mutations', displayMutations: mutations }; +} + function fetchResistanceModeData( displayMutationsBySet: Record, analysis: WasapResistanceFilter, diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 2c4cbfe69..3cde304db 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -9,6 +9,11 @@ export type WasapPageConfig = WasapPageConfigBase & AnalysisModeConfigs; * Base settings that apply to all modes. */ export type WasapPageConfigBase = { + /** + * The internal identifier of the organism, i.e. 'covid'. Used as a key in maps and API parameters. + */ + internalName: string; + /** * The name of the organism, i.e. 'Sars-CoV-2' */ @@ -70,6 +75,11 @@ type VariantAnalysisModeConfig = } | { variantAnalysisModeEnabled: true; + predefinedVariantsSource?: { + collectionsUserId: number; + collectionsTag: string; + variantSourceLabel?: string; + }; clinicalLapis: { lapisBaseUrl: string; dateField: string; @@ -195,14 +205,25 @@ export function variantTimeFrameLabel(timeFrame: VariantTimeFrame): string { } } +/** + * The type of variant mutation signature. `predefined` is a pre-defined list pulled from online, + * `computed` computes the list of signature mutations for a variant based on user parameters. + */ +export type SignatureType = 'computed' | 'predefined'; + export type WasapVariantFilter = { mode: 'variant'; + signatureType: SignatureType; sequenceType: SequenceType; + // computed signature fields variant?: string; minProportion: number; minCount: number; minJaccard: number; timeFrame: VariantTimeFrame; + // predefined signature fields + collectionId?: number; + newMutationsOnly?: boolean; }; export type WasapResistanceFilter = { diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 8e10fa1d5..949d28601 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -15,6 +15,7 @@ export const wastewaterPathFragment = 'swiss-wastewater'; export const wastewaterOrganismConfigs: Record = { [wastewaterOrganisms.covid]: { + internalName: wastewaterOrganisms.covid, name: 'SARS-CoV-2', path: `/${wastewaterPathFragment}/covid`, description: 'Analyze SARS-CoV-2 data that was collected by the WISE project.', @@ -56,6 +57,11 @@ export const wastewaterOrganismConfigs: Record { 'granularity=week&' + 'analysisMode=variant&' + 'sequenceType=nucleotide&' + + 'signatureType=computed&' + 'variant=BA.2*&' + 'minProportion=0.5&' + 'minCount=10&' + @@ -267,6 +270,56 @@ describe('WasapPageStateHandler', () => { expect(analysis2.minProportion).toBe(analysis1.minProportion); expect(analysis2.minJaccard).toBe(analysis1.minJaccard); }); + + it('defaults signatureType to computed when absent from URL', () => { + const url = '/wastewater/covid?analysisMode=variant&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('computed'); + }); + + it('parses and encodes predefined variant filter with collectionId (round-trip)', () => { + const url = + '/wastewater/covid?' + + 'locationName=Z%C3%BCrich+%28ZH%29&' + + 'granularity=day&' + + 'analysisMode=variant&' + + 'sequenceType=nucleotide&' + + 'signatureType=predefined&' + + 'collectionId=42&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('variant'); + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('predefined'); + expect(analysis.collectionId).toBe(42); + expect(analysis.newMutationsOnly).toBe(false); + + const newUrl = handler.toUrl(filter); + expect(newUrl).toBe(url); + }); + + it('parses and encodes newMutationsOnly=true in predefined variant mode (round-trip)', () => { + const url = + '/wastewater/covid?' + + 'locationName=Z%C3%BCrich+%28ZH%29&' + + 'granularity=day&' + + 'analysisMode=variant&' + + 'sequenceType=nucleotide&' + + 'signatureType=predefined&' + + 'collectionId=42&' + + 'newMutationsOnly=true&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('variant'); + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('predefined'); + expect(analysis.newMutationsOnly).toBe(true); + + const newUrl = handler.toUrl(filter); + expect(newUrl).toBe(url); + }); }); describe('resistance mode', () => { diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts index 37f6909a4..f4b4569ad 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts @@ -7,6 +7,7 @@ import type { BaselineFilterConfig } from '../../components/pageStateSelectors/B import { enabledAnalysisModes, type ExcludeSetName, + type SignatureType, type VariantTimeFrame, type WasapAnalysisFilter, type WasapAnalysisMode, @@ -52,12 +53,15 @@ export class WasapPageStateHandler implements PageStateHandler { mutations: texts.mutations?.split('|'), }; break; - case 'variant': + case 'variant': { if (!this.config.variantAnalysisModeEnabled) { throw Error("The 'variant' analysis mode is not enabled."); } analysis = { mode, + signatureType: + (texts.signatureType as SignatureType | undefined) ?? + this.config.filterDefaults.variant.signatureType, sequenceType: providedSequenceType ?? this.config.filterDefaults.variant.sequenceType, variant: texts.variant ?? this.config.filterDefaults.variant.variant, minProportion: Number(texts.minProportion ?? this.config.filterDefaults.variant.minProportion), @@ -66,8 +70,13 @@ export class WasapPageStateHandler implements PageStateHandler { timeFrame: (texts.timeFrame as VariantTimeFrame | undefined) ?? this.config.filterDefaults.variant.timeFrame, + collectionId: texts.collectionId + ? Number(texts.collectionId) + : this.config.filterDefaults.variant.collectionId, + newMutationsOnly: texts.newMutationsOnly === 'true', }; break; + } case 'resistance': if (!this.config.resistanceAnalysisModeEnabled) { throw Error("The 'resistance' analysis mode is not enabled."); @@ -135,11 +144,23 @@ export class WasapPageStateHandler implements PageStateHandler { break; case 'variant': setSearchFromString(search, 'sequenceType', analysis.sequenceType); - setSearchFromString(search, 'variant', analysis.variant); - setSearchFromString(search, 'minProportion', String(analysis.minProportion)); - setSearchFromString(search, 'minCount', String(analysis.minCount)); - setSearchFromString(search, 'minJaccard', String(analysis.minJaccard)); - setSearchFromString(search, 'timeFrame', analysis.timeFrame); + setSearchFromString(search, 'signatureType', analysis.signatureType); + if (analysis.signatureType === 'predefined') { + setSearchFromString( + search, + 'collectionId', + analysis.collectionId ? String(analysis.collectionId) : undefined, + ); + if (analysis.newMutationsOnly) { + setSearchFromString(search, 'newMutationsOnly', 'true'); + } + } else { + setSearchFromString(search, 'variant', analysis.variant); + setSearchFromString(search, 'minProportion', String(analysis.minProportion)); + setSearchFromString(search, 'minCount', String(analysis.minCount)); + setSearchFromString(search, 'minJaccard', String(analysis.minJaccard)); + setSearchFromString(search, 'timeFrame', analysis.timeFrame); + } break; case 'resistance': setSearchFromString(search, 'resistanceSet', analysis.resistanceSet); @@ -236,5 +257,13 @@ function generateWasapFilterConfig(pageConfig: WasapPageConfig): BaselineFilterC type: 'text', lapisField: 'collectionId', }, + { + type: 'text', + lapisField: 'signatureType', + }, + { + type: 'text', + lapisField: 'newMutationsOnly', + }, ]; } diff --git a/website/tests/ViewPage.ts b/website/tests/ViewPage.ts index b2988f69b..27aaaf547 100644 --- a/website/tests/ViewPage.ts +++ b/website/tests/ViewPage.ts @@ -2,6 +2,11 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { type Organism } from '../src/types/Organism.ts'; +export async function expectToSeeNoComponentErrors(page: Page) { + await expect(page.getByText('Error -', { exact: false })).not.toBeVisible(); + await expect(page.getByText('Something went wrong', { exact: false })).not.toBeVisible(); +} + export abstract class ViewPage { constructor(public readonly page: Page) {} @@ -16,8 +21,7 @@ export abstract class ViewPage { } public async expectToSeeNoComponentErrors() { - await expect(this.page.getByText('Error -', { exact: false })).not.toBeVisible(); - await expect(this.page.getByText('Something went wrong', { exact: false })).not.toBeVisible(); + await expectToSeeNoComponentErrors(this.page); } public async selectDateRange(dateRangeOption: string) { diff --git a/website/tests/WasapPage.ts b/website/tests/WasapPage.ts new file mode 100644 index 000000000..6c44db876 --- /dev/null +++ b/website/tests/WasapPage.ts @@ -0,0 +1,44 @@ +import { type Page } from '@playwright/test'; + +import { expectToSeeNoComponentErrors } from './ViewPage.ts'; +import { type WastewaterOrganismName, wastewaterOrganismConfigs } from '../src/types/wastewaterConfig.ts'; + +export class WasapPage { + constructor(public readonly page: Page) {} + + public async goto(organism: WastewaterOrganismName) { + await this.page.goto(wastewaterOrganismConfigs[organism].path); + } + + public async submitFilters() { + await this.page.getByRole('button', { name: 'Apply filters' }).click(); + } + + public get mutationsOverTimeHeading() { + return this.page.getByRole('heading', { name: 'mutations over time' }); + } + + public get filterDatasetHeading() { + return this.page.getByRole('heading', { name: 'Filter dataset' }); + } + + public get mutationSelectionHeading() { + return this.page.getByRole('heading', { name: 'Mutation selection' }); + } + + public get analysisModeSelect() { + return this.page.locator('select').filter({ has: this.page.locator('option', { hasText: 'Manual' }) }); + } + + public async selectAnalysisMode(mode: string) { + await this.analysisModeSelect.selectOption({ label: mode }); + } + + public async selectGranularity(granularity: 'Day' | 'Week') { + await this.page.getByText(granularity, { exact: true }).click(); + } + + public async expectToSeeNoComponentErrors() { + await expectToSeeNoComponentErrors(this.page); + } +} diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index daa12ab8d..cecf7f4b2 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -6,6 +6,7 @@ import { CompareVariantsPage } from './CompareVariantsPage.ts'; import { LandingPage } from './LandingPage.ts'; import { SequencingEffortsPage } from './SequencingEffortsPage.ts'; import { SingleVariantPage } from './SingleVariantPage.ts'; +import { WasapPage } from './WasapPage.ts'; import { ApiKeyPage } from './api-key/ApiKeyPage.ts'; import { CollectionDetailPage } from './collections/CollectionDetailPage.ts'; import { CollectionFormPage } from './collections/CollectionFormPage.ts'; @@ -20,6 +21,7 @@ type E2EFixture = { compareSideBySidePage: CompareSideBySidePage; singleVariantPage: SingleVariantPage; landingPage: LandingPage; + wasapPage: WasapPage; collectionDetailPage: CollectionDetailPage; collectionFormPage: CollectionFormPage; authenticatedPage: Page; @@ -46,6 +48,9 @@ export const test = base.extend({ landingPage: async ({ page }, use) => { await use(new LandingPage(page)); }, + wasapPage: async ({ page }, use) => { + await use(new WasapPage(page)); + }, collectionDetailPage: async ({ page }, use) => { await use(new CollectionDetailPage(page)); }, diff --git a/website/tests/wastewater.spec.ts b/website/tests/wastewater.spec.ts new file mode 100644 index 000000000..39d284d46 --- /dev/null +++ b/website/tests/wastewater.spec.ts @@ -0,0 +1,77 @@ +import { expect } from '@playwright/test'; + +import { expectToSeeNoComponentErrors } from './ViewPage.ts'; +import { test } from './e2e.fixture.ts'; +import { wastewaterOrganismConfigs, wastewaterOrganisms } from '../src/types/wastewaterConfig.ts'; + +test.describe('The Swiss Wastewater Overview Page', () => { + test('should show heading and links to all wastewater pages', async ({ page }) => { + await page.goto('/swiss-wastewater'); + + await expect(page).toHaveTitle('Swiss Wastewater'); + await expect(page.getByRole('heading', { name: 'Swiss Wastewater', level: 1 })).toBeVisible(); + + await expect(page.getByRole('link', { name: /^SARS-CoV-2 Analyze/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /^RSV-A Analyze/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /^RSV-B Analyze/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /^RSV \(non-interactive\)/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /^Influenza \(non-interactive\)/ })).toBeVisible(); + }); +}); + +test.describe('WASAP Pages', () => { + test.setTimeout(60_000); + + for (const organism of Object.values(wastewaterOrganisms)) { + const { name } = wastewaterOrganismConfigs[organism]; + + test.describe(name, () => { + test('should load with default filters and show mutation data', async ({ wasapPage }) => { + await wasapPage.goto(organism); + + await expect(wasapPage.page).toHaveTitle(`Swiss wastewater - ${name}`); + await expect(wasapPage.filterDatasetHeading).toBeVisible(); + await expect(wasapPage.mutationSelectionHeading).toBeVisible(); + + await wasapPage.expectToSeeNoComponentErrors(); + + await expect(wasapPage.mutationsOverTimeHeading).toBeVisible(); + }); + + test('should show mutation data after changing and applying filters', async ({ wasapPage }) => { + await wasapPage.goto(organism); + + await wasapPage.selectAnalysisMode('Variant Explorer'); + await wasapPage.selectGranularity('Week'); + await wasapPage.submitFilters(); + + await wasapPage.expectToSeeNoComponentErrors(); + await expect(wasapPage.mutationsOverTimeHeading).toBeVisible(); + }); + }); + } +}); + +test.describe('Non-interactive Wastewater Pages', () => { + test.setTimeout(60_000); + + test('RSV page should render with RSV-A and RSV-B sections', async ({ page }) => { + await page.goto('/swiss-wastewater/rsv', { timeout: 60_000 }); + + await expect(page).toHaveTitle('Swiss wastewater - RSV'); + await expect(page.getByRole('heading', { name: 'RSV-A', level: 2, exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'RSV-B', level: 2, exact: true })).toBeVisible(); + await expectToSeeNoComponentErrors(page); + }); + + test('Influenza page should render with all subtype sections', async ({ page }) => { + await page.goto('/swiss-wastewater/flu', { timeout: 60_000 }); + + await expect(page).toHaveTitle('Swiss wastewater - Influenza'); + await expect(page.getByRole('heading', { name: 'H1', level: 2, exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'N1', level: 2, exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'H3', level: 2, exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'N2', level: 2, exact: true })).toBeVisible(); + await expectToSeeNoComponentErrors(page); + }); +});