From 12848b34e0350f07e57bcd75eef24d7fc8e3cb6f Mon Sep 17 00:00:00 2001 From: Vishakh Date: Tue, 2 Jun 2026 15:11:18 -0400 Subject: [PATCH 01/19] Removed intro redirect. --- app/landing-client.tsx | 196 +++++++++-------------------------------- next-env.d.ts | 2 +- 2 files changed, 41 insertions(+), 157 deletions(-) diff --git a/app/landing-client.tsx b/app/landing-client.tsx index 44f01f1..d3551d2 100644 --- a/app/landing-client.tsx +++ b/app/landing-client.tsx @@ -1,15 +1,11 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { useGenotype } from "./components/UserDataUpload"; -import { trackGetStartedClicked, trackIntroModalShown } from "@/lib/analytics"; +import { trackGetStartedClicked } from "@/lib/analytics"; const INSTRUCTIONAL_VIDEO_URL = "https://youtu.be/1mqLYTAOK90"; const SCHEDULE_CALL_URL = "https://calendar.app.google/eVDN4d44GreUjR8p8"; -const NEW_USER_CHOICE_STORAGE_KEY = "new_user_choice_completed"; -const MOTHBALLED_ONBOARDING_STORAGE_KEY = "conversion_onboarding_completed"; const introCopy = [ { @@ -30,163 +26,51 @@ const introCopy = [ }, ]; -function NewUserChoiceModal({ - isOpen, - onClose, - onTryChat, -}: { - isOpen: boolean; - onClose: () => void; - onTryChat: () => void; -}) { - const [countdown, setCountdown] = useState(5); - const onTryChatRef = useRef(onTryChat); - onTryChatRef.current = onTryChat; - - useEffect(() => { - if (!isOpen) return; - setCountdown(5); - const interval = setInterval(() => { - setCountdown(prev => (prev <= 1 ? 0 : prev - 1)); - }, 1000); - return () => clearInterval(interval); - }, [isOpen]); - - useEffect(() => { - if (countdown === 0 && isOpen) { - onTryChatRef.current(); - } - }, [countdown, isOpen]); - - if (!isOpen) return null; - - return ( -
-
-
-
-

- We are automatically redirecting you to our DNA Chat page so you can see our app in action. -

-
{countdown}
- - -
-
-
-
- ); -} - export default function LandingClient() { - const router = useRouter(); - const searchParams = useSearchParams(); const { error } = useGenotype(); - const [showWelcomeChoice, setShowWelcomeChoice] = useState(false); - - const completeWelcomeChoice = useCallback(() => { - localStorage.setItem(NEW_USER_CHOICE_STORAGE_KEY, "true"); - localStorage.setItem(MOTHBALLED_ONBOARDING_STORAGE_KEY, "true"); - setShowWelcomeChoice(false); - }, []); - - useEffect(() => { - if (typeof window === "undefined") return; - - const completed = localStorage.getItem(NEW_USER_CHOICE_STORAGE_KEY) === "true"; - const forceOpen = searchParams.get("onboarding") === "1"; - - if (forceOpen || !completed) { - setShowWelcomeChoice(true); - trackIntroModalShown(); - } - - if (forceOpen) { - const url = new URL(window.location.href); - url.searchParams.delete("onboarding"); - window.history.replaceState({}, "", url.toString()); - } - }, [searchParams]); - - useEffect(() => { - const handleOpen = () => { - setShowWelcomeChoice(true); - }; - - window.addEventListener("openConversionOnboarding", handleOpen as EventListener); - window.addEventListener("openNewUserChoiceModal", handleOpen as EventListener); - return () => { - window.removeEventListener("openConversionOnboarding", handleOpen as EventListener); - window.removeEventListener("openNewUserChoiceModal", handleOpen as EventListener); - }; - }, []); return ( - <> - { - completeWelcomeChoice(); - trackGetStartedClicked("try_dna_chat_directly"); - router.push("/dna-chat?sample=1"); - }} - /> - -
-
-
-

Understand your DNA without giving it away.

+
+
+
+

Understand your DNA without giving it away.

+ +
+ {introCopy.map((item) => ( +

+ {item.label} + {item.text} +

+ ))} +
-
- {introCopy.map((item) => ( -

- {item.label} - {item.text} -

- ))} -
+ {error &&

{error}

} +
- {error &&

{error}

} +
-
- + +
+
); } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 76078b2d4ba29c1d81ad896264bae39e0f9ba736 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Tue, 2 Jun 2026 15:35:25 -0400 Subject: [PATCH 02/19] =?UTF-8?q?=E2=80=A2=20Rename=20the=20Explore=20page?= =?UTF-8?q?=20to=20Browse.=20=E2=80=A2=20Rename=20DNA=20Chat=20to=20Chat?= =?UTF-8?q?=20=E2=80=A2=20Rename=20Overview=20Report=20to=20Analyze=20?= =?UTF-8?q?=E2=80=A2=20Add=20a=20new=20Explore=20page=20which=20lets=20the?= =?UTF-8?q?=20user=20navigate=20to=20a=20random=20study=20page.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/browse/layout.tsx | 20 + app/browse/page.tsx | 1193 +++++++++++++++++++++++++++++++ app/components/MenuBar.tsx | 19 +- app/dna-chat/layout.tsx | 6 +- app/explore/layout.tsx | 10 +- app/explore/page.tsx | 1202 +------------------------------- app/overview-report/layout.tsx | 6 +- app/page.tsx | 2 +- app/sitemap.ts | 3 +- app/study/[id]/page.tsx | 10 +- 10 files changed, 1273 insertions(+), 1198 deletions(-) create mode 100644 app/browse/layout.tsx create mode 100644 app/browse/page.tsx diff --git a/app/browse/layout.tsx b/app/browse/layout.tsx new file mode 100644 index 0000000..b547329 --- /dev/null +++ b/app/browse/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Browse GWAS Studies - Monadic DNA Explorer", + description: "Search and filter millions of genetic associations from the GWAS Catalog. Upload your DNA data to see personalized results.", + keywords: ["GWAS", "genetic studies", "DNA research", "genome-wide association", "genetic variants", "SNP analysis"], + openGraph: { + title: "Browse GWAS Studies - Monadic DNA Explorer", + description: "Search millions of genetic associations and analyze your DNA data", + type: "website", + }, +}; + +export default function ExploreLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/browse/page.tsx b/app/browse/page.tsx new file mode 100644 index 0000000..875cd70 --- /dev/null +++ b/app/browse/page.tsx @@ -0,0 +1,1193 @@ +"use client"; + +import { useEffect, useMemo, useState, useCallback, useRef, startTransition, memo } from "react"; +import Link from "next/link"; +import { useGenotype } from "../components/UserDataUpload"; +import { useResults } from "../components/ResultsContext"; +import { useAuth } from "../components/AuthProvider"; +import { RunAllIcon } from "../components/Icons"; +import StudyResultReveal from "../components/StudyResultReveal"; +import MenuBar from "../components/MenuBar"; +import VariantChips from "../components/VariantChips"; +import Footer from "../components/Footer"; +import DisclaimerModal from "../components/DisclaimerModal"; +import TermsAcceptanceModal from "../components/TermsAcceptanceModal"; +import RunAllModal from "../components/RunAllModal"; +import GuidedTour, { hasCompletedTour } from "../components/GuidedTour"; +import { exploreTour } from "../components/tours/tourContent"; +import { hasMatchingSNPs } from "@/lib/snp-utils"; +import { analyzeStudyClientSide } from "@/lib/risk-calculator"; +import { isDevModeEnabled } from "@/lib/dev-mode"; +import { hasValidPromoAccess, clearPromoAccess } from "@/lib/promo-access"; +import { + trackSearch, + trackRunAllCompleted, + trackRunAllFailed, + trackRunAllStarted, + trackQueryRun, + trackExploreTabViewed, + trackSearchModeChanged, +} from "@/lib/analytics"; + +// Note: Metadata must be exported from layout.tsx or a server component +// This page is a client component and cannot export metadata + +type SortOption = "relevance" | "power" | "recent" | "alphabetical"; +type SortDirection = "asc" | "desc"; +type ConfidenceBand = "high" | "medium" | "low"; + +type Filters = { + search: string; + searchMode: "similarity" | "exact"; + trait: string; + minSampleSize: string; + maxPValue: string; + excludeLowQuality: boolean; + excludeMissingGenotype: boolean; + requireUserSNPs: boolean; + sort: SortOption; + sortDirection: SortDirection; + limit: number; + confidenceBand: ConfidenceBand | null; + offset: number; +}; + +type Study = { + id: number; + study_accession: string | null; + study: string | null; + disease_trait: string | null; + mapped_trait: string | null; + mapped_trait_uri: string | null; + mapped_gene: string | null; + first_author: string | null; + date: string | null; + journal: string | null; + pubmedid: string | null; + link: string | null; + initial_sample_size: string | null; + replication_sample_size: string | null; + p_value: string | null; + pvalue_mlog: string | null; + or_or_beta: string | null; + ci_text: string | null; + risk_allele_frequency: string | null; + strongest_snp_risk_allele: string | null; + snps: string | null; + sampleSize: number | null; + sampleSizeLabel: string; + pValueNumeric: number | null; + pValueLabel: string; + logPValue: number | null; + qualityFlags: Array<{ message: string; severity: string }>; + isLowQuality: boolean; + confidenceBand: ConfidenceBand; + publicationDate: number | null; + similarity?: number; // Semantic search similarity score (0-1, higher is more similar) + isAnalyzable: boolean; + nonAnalyzableReason?: string; +}; + +type StudiesResponse = { + data: Study[]; + total: number; + limit: number; + truncated: boolean; + sourceCount: number; + error?: string; +}; + +type QualitySummary = { + high: number; + medium: number; + low: number; + flagged: number; +}; + +const defaultFilters: Filters = { + search: "sleep", + searchMode: "similarity", + trait: "", + minSampleSize: "500", + maxPValue: "5e-8", + excludeLowQuality: true, + excludeMissingGenotype: true, + requireUserSNPs: false, + sort: "relevance", + sortDirection: "desc", + limit: 1000, + confidenceBand: null, + offset: 0, +}; + + +function InfoIcon({ text }: { text: string }) { + return ( + + ⓘ + + ); +} + +function parseVariantIds(snps: string | null): string[] { + if (!snps) { + return []; + } + return snps + .split(/[;,\s]+/) + .map((id) => id.trim()) + .filter(Boolean); +} + +function getRelevanceCategory(logPValue: number | null): { label: string; className: string } { + if (logPValue === null) return { label: "", className: "" }; + if (logPValue >= 9) return { label: "strong", className: "relevance-strong" }; + if (logPValue >= 7) return { label: "moderate", className: "relevance-moderate" }; + return { label: "weak", className: "relevance-weak" }; +} + +function getPowerCategory(sampleSize: number | null): { label: string; className: string } { + if (sampleSize === null) return { label: "", className: "" }; + if (sampleSize >= 50000) return { label: "large study", className: "power-large" }; + if (sampleSize >= 5000) return { label: "medium study", className: "power-medium" }; + if (sampleSize >= 1000) return { label: "small study", className: "power-small" }; + return { label: "very small", className: "power-very-small" }; +} + +function getEffectCategory(effectStr: string | null): { label: string; className: string } { + if (!effectStr) return { label: "", className: "" }; + const effect = parseFloat(effectStr); + if (isNaN(effect)) return { label: "", className: "" }; + + // Check if this looks like an odds ratio (typically > 0.5 and < 10) + // vs a beta coefficient (can be any value, often small) + const likelyOR = effect > 0.5 && effect < 10; + + if (likelyOR) { + if (Math.abs(effect - 1.0) < 0.05) return { label: "no effect", className: "effect-none" }; + if (effect < 1.0) { + if (effect <= 0.67) return { label: "protective", className: "effect-protective" }; + return { label: "slightly protective", className: "effect-slight-protective" }; + } + if (effect >= 2.0) return { label: "large effect", className: "effect-large" }; + if (effect >= 1.5) return { label: "moderate effect", className: "effect-moderate" }; + return { label: "small effect", className: "effect-small" }; + } + + // For beta coefficients, we can't easily categorize without trait context + return { label: "", className: "" }; +} + +function buildQuery(filters: Filters): string { + const params = new URLSearchParams(); + params.set("limit", String(filters.limit)); + params.set("offset", String(filters.offset)); + params.set("sort", filters.sort); + params.set("direction", filters.sortDirection); + params.set("excludeLowQuality", String(filters.excludeLowQuality)); + params.set("excludeMissingGenotype", String(filters.excludeMissingGenotype)); + if (filters.search.trim()) { + params.set("search", filters.search.trim()); + params.set("searchMode", filters.searchMode); + } + if (filters.trait) { + params.set("trait", filters.trait); + } + if (filters.minSampleSize.trim()) { + params.set("minSampleSize", filters.minSampleSize.trim()); + } + if (filters.maxPValue.trim()) { + params.set("maxPValue", filters.maxPValue.trim()); + } + if (filters.confidenceBand) { + params.set("confidenceBand", filters.confidenceBand); + } + return params.toString(); +} + +type DebouncedTextInputProps = { + id: string; + type?: "text" | "search"; + placeholder?: string; + list?: string; + value: string; + delay?: number; + onDebouncedChange: (value: string) => void; +}; + +const DebouncedTextInput = memo(function DebouncedTextInput({ + id, + type = "text", + placeholder, + list, + value, + delay = 500, + onDebouncedChange, +}: DebouncedTextInputProps) { + const inputRef = useRef(null); + const timerRef = useRef(null); + const committedValueRef = useRef(value); + + useEffect(() => { + committedValueRef.current = value; + + if (inputRef.current && inputRef.current.value !== value) { + inputRef.current.value = value; + } + }, [value]); + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + const scheduleCommit = useCallback((nextValue: string) => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + if (nextValue !== committedValueRef.current) { + onDebouncedChange(nextValue); + } + }, delay); + }, [delay, onDebouncedChange]); + + const commitImmediately = useCallback(() => { + const nextValue = inputRef.current?.value ?? ""; + + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (nextValue !== committedValueRef.current) { + onDebouncedChange(nextValue); + } + }, [onDebouncedChange]); + + return ( + scheduleCommit(event.target.value)} + onBlur={commitImmediately} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitImmediately(); + } + }} + /> + ); +}); + +function ExplorePage() { + const { genotypeData, isUploaded, setOnDataLoadedCallback } = useGenotype(); + const { setOnResultsLoadedCallback, addResult, addResultsBatch, hasResult } = useResults(); + const resultsContext = useResults(); + const { isAuthenticated, hasActiveSubscription } = useAuth(); + + // Track client-side mounting to prevent hydration errors + const [mounted, setMounted] = useState(false); + + // Track if search change is user-initiated (for Reddit analytics) + const userInitiatedSearchRef = useRef(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Track Explore page view + useEffect(() => { + if (mounted) { + trackExploreTabViewed(); + } + }, [mounted]); + + // Auto-show guided tour on first visit + useEffect(() => { + if (mounted && !hasCompletedTour(exploreTour.id)) { + setTourOpen(true); + } + }, [mounted]); + + const [filters, setFilters] = useState(defaultFilters); + const scrollPositionRef = useRef(0); + const isLoadingMoreRef = useRef(false); + const [traits, setTraits] = useState([]); + const [studies, setStudies] = useState([]); + const [meta, setMeta] = useState>({ + total: 0, + limit: defaultFilters.limit, + truncated: false, + sourceCount: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [sectionCollapsed, setSectionCollapsed] = useState(false); + const [showTermsModal, setShowTermsModal] = useState(false); + const [isRunningAll, setIsRunningAll] = useState(false); + const [runAllProgress, setRunAllProgress] = useState({ current: 0, total: 0 }); + const [showRunAllModal, setShowRunAllModal] = useState(false); + const [showRunAllDisclaimer, setShowRunAllDisclaimer] = useState(false); + const [tourOpen, setTourOpen] = useState(false); + const [runAllStatus, setRunAllStatus] = useState<{ + phase: 'fetching' | 'downloading' | 'decompressing' | 'parsing' | 'storing' | 'analyzing' | 'embeddings' | 'complete' | 'error'; + fetchedBatches: number; + totalStudiesFetched: number; + totalInDatabase: number; + matchingStudies: number; + processedCount: number; + totalToProcess: number; + matchCount: number; + startTime?: number; + elapsedSeconds?: number; + etaSeconds?: number; + errorMessage?: string; + }>({ + phase: 'fetching', + fetchedBatches: 0, + totalStudiesFetched: 0, + totalInDatabase: 0, + matchingStudies: 0, + processedCount: 0, + totalToProcess: 0, + matchCount: 0, + }); + const [loadTime, setLoadTime] = useState(null); + + // Terms modal opens after tour (or immediately if tour already completed) + const openTermsIfNeeded = useCallback(() => { + const termsAccepted = localStorage.getItem('terms_accepted'); + if (!termsAccepted) { + setShowTermsModal(true); + } + }, []); + + useEffect(() => { + if (!mounted) return; + if (hasCompletedTour(exploreTour.id)) { + openTermsIfNeeded(); + } + // Otherwise, terms will open via the tour's onClose handler + }, [mounted, openTermsIfNeeded]); + + const updateFilter = useCallback((key: Key, value: Filters[Key]) => { + setFilters((prev) => { + const next = { ...prev, [key]: value }; + if (key !== "confidenceBand") { + next.confidenceBand = null; + } + + // Reset offset to 0 when any filter changes (except offset, sort, sortDirection, limit) + // This ensures "Load More" starts fresh when user changes search/filters + const shouldResetOffset = key !== 'offset' && key !== 'sort' && key !== 'sortDirection' && key !== 'limit'; + if (shouldResetOffset) { + next.offset = 0; + } + + // Filter tracking removed for simplified analytics + + return next; + }); + }, []); + + const handleDebouncedSearchChange = useCallback((value: string) => { + if (value !== filters.search) { + userInitiatedSearchRef.current = true; + startTransition(() => { + updateFilter("search", value); + }); + } + }, [filters.search, updateFilter]); + + const handleDebouncedTraitChange = useCallback((value: string) => { + if (value !== filters.trait) { + startTransition(() => { + updateFilter("trait", value); + }); + } + }, [filters.trait, updateFilter]); + + // Set up callback to auto-check "Only my variants" when genotype data is loaded + useEffect(() => { + setOnDataLoadedCallback(() => { + updateFilter("requireUserSNPs", true); + }); + }, [setOnDataLoadedCallback, updateFilter]); + + useEffect(() => { + let active = true; + fetch("/api/traits") + .then(async (response) => { + if (!response.ok) { + throw new Error("Unable to load traits"); + } + const payload = (await response.json()) as { traits: string[]; error?: string }; + if (!active) return; + if (payload.error) { + throw new Error(payload.error); + } + setTraits(payload.traits ?? []); + }) + .catch(() => { + if (!active) return; + setTraits([]); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + const controller = new AbortController(); + const query = buildQuery(filters); + const startTime = performance.now(); + setLoading(true); + setError(null); + + fetch(`/api/studies?${query}`, { signal: controller.signal }) + .then(async (response) => { + const apiDuration = performance.now() - startTime; + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error ?? "Failed to load studies"); + } + const payload = (await response.json()) as StudiesResponse; + if (payload.error) { + throw new Error(payload.error); + } + + let filteredData = payload.data ?? []; + + // Client-side filtering for user SNPs + if (filters.requireUserSNPs && genotypeData) { + filteredData = filteredData.filter(study => { + // STRICT MODE: Only show studies where user has the specific SNP with the specific allele + const hasUserSNPs = hasMatchingSNPs(genotypeData, study.snps, study.strongest_snp_risk_allele, true); + if (!hasUserSNPs) return false; + + // If "Require genotype" is also enabled, ensure the study has genotype data + if (filters.excludeMissingGenotype) { + const hasGenotype = study.strongest_snp_risk_allele && + study.strongest_snp_risk_allele.trim().length > 0 && + study.strongest_snp_risk_allele.trim() !== '?' && + study.strongest_snp_risk_allele.trim() !== 'NR' && + !study.strongest_snp_risk_allele.includes('?'); + return hasGenotype; + } + + return true; + }); + } + + const endTime = performance.now(); + const totalLoadTime = endTime - startTime; + setLoadTime(totalLoadTime); + + // Track search if there's a search query and it was user-initiated + if (filters.search.trim() && userInitiatedSearchRef.current) { + trackSearch(filters.search, filteredData.length, totalLoadTime); + userInitiatedSearchRef.current = false; // Reset flag after tracking + } + + // Append results if offset > 0 (Load More), otherwise replace + if (filters.offset > 0) { + setStudies(prev => [...prev, ...filteredData]); + setMeta(prev => ({ + total: prev.total + filteredData.length, + limit: payload.limit ?? filters.limit, + truncated: payload.truncated ?? false, + sourceCount: payload.sourceCount ?? 0, + })); + } else { + setStudies(filteredData); + setMeta({ + total: filteredData.length, + limit: payload.limit ?? filters.limit, + truncated: payload.truncated ?? false, + sourceCount: payload.sourceCount ?? 0, + }); + } + }) + .catch((err) => { + if (controller.signal.aborted) { + return; + } + setError(err instanceof Error ? err.message : "Failed to load studies"); + setStudies([]); + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + // Restore scroll position after loading more results + if (isLoadingMoreRef.current) { + requestAnimationFrame(() => { + window.scrollTo(0, scrollPositionRef.current); + isLoadingMoreRef.current = false; + }); + } + } + }); + + return () => controller.abort(); + }, [filters, genotypeData]); + + const qualitySummary = useMemo(() => { + return studies.reduce( + (acc, study) => { + acc[study.confidenceBand] += 1; + if (study.isLowQuality) { + acc.flagged += 1; + } + return acc; + }, + { high: 0, medium: 0, low: 0, flagged: 0 }, + ); + }, [studies]); + + const resetFilters = () => { + setFilters(defaultFilters); + userInitiatedSearchRef.current = false; + }; + + + const handleColumnSort = (sortKey: SortOption) => { + const newDirection = filters.sort === sortKey + ? (filters.sortDirection === "asc" ? "desc" : "asc") + : "desc"; + + if (filters.sort === sortKey) { + // Same column clicked, toggle direction + updateFilter("sortDirection", newDirection); + } else { + // New column clicked, set to desc (most common use case) + updateFilter("sort", sortKey); + updateFilter("sortDirection", newDirection); + } + }; + + const handleStudyColumnSort = () => { + // Study column cycles between alphabetical and recent + if (filters.sort === "alphabetical") { + handleColumnSort("recent"); + } else if (filters.sort === "recent") { + // Toggle direction for recent + const newDirection = filters.sortDirection === "asc" ? "desc" : "asc"; + updateFilter("sortDirection", newDirection); + } else { + // Start with alphabetical + handleColumnSort("alphabetical"); + } + }; + + const handleRunAll = () => { + if (!genotypeData || genotypeData.size === 0) { + trackRunAllFailed("explore", "no_genotype_data"); + alert("No SNPs found in your genetic data"); + return; + } + + // Show disclaimer first + setShowRunAllDisclaimer(true); + }; + + const handleRunAllDisclaimerAccept = async () => { + setShowRunAllDisclaimer(false); + + // Check if we need to download the catalog first + const { gwasDB } = await import('@/lib/gwas-db'); + const metadata = await gwasDB.getMetadata(); + + if (!metadata) { + const confirmDownload = window.confirm( + `First-time setup: Download ~54MB GWAS Catalog data?\n\n` + + `This will be cached locally for instant future analysis.\n` + + `Estimated storage: ~500MB after decompression.\n\n` + + `Continue?` + ); + if (!confirmDownload) return; + } else { + const confirmRun = window.confirm( + `Analyze all ${metadata.totalStudies.toLocaleString()} studies where you have matching SNPs?\n\n` + + `Using cached data from ${new Date(metadata.downloadDate).toLocaleDateString()}\n\n` + + `Continue?` + ); + if (!confirmRun) return; + } + + // Initialize and show modal + setIsRunningAll(true); + setShowRunAllModal(true); + const startTime = Date.now(); + setRunAllStatus({ + phase: 'fetching', + fetchedBatches: 0, + totalStudiesFetched: 0, + totalInDatabase: 0, + matchingStudies: 0, + processedCount: 0, + totalToProcess: 0, + matchCount: 0, + startTime, + }); + setRunAllProgress({ current: 0, total: 0 }); + + // Track Run All started (with estimated study count) + trackRunAllStarted(metadata?.totalStudies || 0); + + try { + // Check if genotype data is loaded + if (!genotypeData) { + throw new Error('No genotype data loaded. Please upload your genetic data first.'); + } + + // Use IndexedDB-based implementation + const { runAllAnalysisIndexed } = await import('@/lib/run-all-indexed'); + + const results = await runAllAnalysisIndexed( + genotypeData, + (progress) => { + setRunAllStatus(prev => ({ + ...prev, + phase: progress.phase, + totalStudiesFetched: progress.loaded, + totalInDatabase: progress.total, + matchingStudies: progress.matchingStudies, + matchCount: progress.matchCount, + elapsedSeconds: progress.elapsedSeconds, + fetchedBatches: 0, + processedCount: progress.matchingStudies, + totalToProcess: progress.matchingStudies, + })); + }, + hasResult + ); + + // Add all results in one efficient batch operation + console.log(`Adding ${results.length} results to the results manager...`); + const startAdd = Date.now(); + await addResultsBatch(results); // Embeddings will be fetched on-demand during LLM analysis + const addTime = Date.now() - startAdd; + console.log(`Finished adding ${results.length} results in ${addTime}ms`); + trackRunAllCompleted(metadata?.totalStudies || 0, results.length, results.length, "explore"); + + // Notify MenuBar that cache has been updated + window.dispatchEvent(new CustomEvent('cacheUpdated')); + } catch (error) { + console.error('Run All failed:', error); + trackRunAllFailed("explore", error instanceof Error ? error.message : "run_all_failed"); + setRunAllStatus(prev => ({ + ...prev, + phase: 'error', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + })); + } finally { + setIsRunningAll(false); + } + }; + + const summaryText = useMemo(() => { + if (error) { + return error; + } + if (loading) { + return "Loading studies…"; + } + if (studies.length === 0) { + return "No studies match the current filters."; + } + const parts = [ + `${studies.length} of ${meta.total} quality-filtered studies`, + `${meta.sourceCount.toLocaleString()} matches before quality filters`, + ]; + if (loadTime !== null) { + parts.push(`loaded in ${Math.round(loadTime)}ms`); + } + const breakdown: string[] = []; + if (qualitySummary.high > 0) { + breakdown.push(`${qualitySummary.high} high`); + } + if (qualitySummary.medium > 0) { + breakdown.push(`${qualitySummary.medium} medium`); + } + if ((qualitySummary.low > 0 && !filters.excludeLowQuality) || filters.confidenceBand === "low") { + breakdown.push(`${qualitySummary.low} low`); + } + if (breakdown.length > 0) { + parts.push(`Confidence mix: ${breakdown.join(", ")}`); + } + if (meta.truncated) { + parts.push(`showing the top ${meta.limit}`); + } + if (qualitySummary.flagged > 0 && !filters.excludeLowQuality) { + parts.push(`${qualitySummary.flagged} flagged as lower confidence`); + } + return parts.join(" · "); + }, [ + studies.length, + meta, + loading, + error, + loadTime, + qualitySummary.high, + qualitySummary.medium, + qualitySummary.low, + qualitySummary.flagged, + filters.excludeLowQuality, + filters.confidenceBand, + ]); + + return ( +
+ setShowTermsModal(false)} + /> + + +
+
+
+
+ {!sectionCollapsed && ( + <> +

Study Filters

+

Filter genetic association studies by various criteria.

+ + )} + {sectionCollapsed &&

Study Filters

} +
+
+ {!sectionCollapsed && ( + + )} + {!sectionCollapsed && ( + + )} + +
+
+ {!sectionCollapsed && ( +
+
+
+ + +
+ + +
+
+
+ + + + {traits.map((traitOption) => ( + +
+
+ + updateFilter("minSampleSize", event.target.value)} + /> +
+
+
+
+ + +
+
+ + +
+
+ updateFilter("excludeMissingGenotype", event.target.checked)} + /> + +
+ {isUploaded && ( +
+ updateFilter("requireUserSNPs", event.target.checked)} + /> + +
+ )} +
+
+ )} +
+ +
+

{summaryText}

+ +
+ +
+
+ + + + + {studies.some(s => s.similarity !== undefined) && ( + + )} + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && studies.length === 0 && ( + + + + )} + {!loading && + studies.map((study, index) => { + const trait = study.disease_trait ?? study.mapped_trait ?? "-"; + const mappedTrait = study.mapped_trait ?? "-"; + const date = study.publicationDate + ? new Date(study.publicationDate).toLocaleDateString() + : study.date + ? new Date(study.date).toLocaleDateString() || study.date + : "-"; + const relevance = study.logPValue ? study.logPValue.toFixed(2) : "-"; + const power = study.sampleSizeLabel ?? "-"; + const effect = study.or_or_beta ?? "-"; + const relevanceCategory = getRelevanceCategory(study.logPValue); + const powerCategory = getPowerCategory(study.sampleSize); + const effectCategory = getEffectCategory(study.or_or_beta); + const gwasLink = study.study_accession + ? `https://www.ebi.ac.uk/gwas/studies/${study.study_accession}` + : null; + const studyLink = + gwasLink || study.link || (study.pubmedid ? `https://pubmed.ncbi.nlm.nih.gov/${study.pubmedid}` : null); + const variantIds = parseVariantIds(study.snps); + const variantGenotype = study.strongest_snp_risk_allele?.trim() ?? ""; + const hasGenotype = variantGenotype.length > 0; + const confidenceLabel = + study.confidenceBand === "high" + ? "High confidence" + : study.confidenceBand === "medium" + ? "Medium confidence" + : "Lower confidence"; + return ( + + + {study.similarity !== undefined && ( + + )} + + + + + + + + + + ); + })} + +
+ Study {filters.sort === "recent" && "(by date)"} + + {(filters.sort === "alphabetical" || filters.sort === "recent") && ( + {filters.sortDirection === "asc" ? " ↑" : " ↓"} + )} + + Similarity + + + Trait + + Mapped Trait + + Variant + handleColumnSort("relevance")} + > + Relevance + + {filters.sort === "relevance" && ( + {filters.sortDirection === "asc" ? " ↑" : " ↓"} + )} + handleColumnSort("power")} + > + Power + + {filters.sort === "power" && ( + {filters.sortDirection === "asc" ? " ↑" : " ↓"} + )} + + Effect + + Quality + + Your Result +
s.similarity !== undefined) ? 10 : 9} className="loading-row"> + Loading… +
s.similarity !== undefined) ? 10 : 9} className="empty-row"> + No studies found. Try widening your filters. +
+
+ + {study.study ?? "Untitled study"} + +
+
+ {study.first_author ?? "Unknown author"} + {date} + {study.study_accession && {study.study_accession}} + {study.mapped_gene && Gene: {study.mapped_gene}} +
+
+ {study.similarity.toFixed(3)} + {trait}{mappedTrait} + + + {relevance} + {relevanceCategory.label && ( + {relevanceCategory.label} + )} + {study.pValueNumeric !== null && ( + p = {study.pValueLabel} + )} + + {power} + {powerCategory.label && ( + {powerCategory.label} + )} + {study.initial_sample_size && ( + Initial: {study.initial_sample_size} + )} + {study.replication_sample_size && ( + Replication: {study.replication_sample_size} + )} + + {effect} + {effectCategory.label && ( + {effectCategory.label} + )} + {study.risk_allele_frequency && ( + RAF: {study.risk_allele_frequency} + )} + +
+ {confidenceLabel} + {study.qualityFlags.length > 0 && ( +
+ {study.qualityFlags.map((flag, index) => ( + + {flag.message} + + ))} +
+ )} +
+
+ +
+
+ + {/* Load More Button */} + {!loading && studies.length > 0 && studies.length < meta.sourceCount && ( +
+

+ Showing {studies.length.toLocaleString()} of {meta.sourceCount.toLocaleString()} matches +

+ +
+ )} +
+
+
+ setShowRunAllDisclaimer(false)} + type="initial" + onAccept={handleRunAllDisclaimerAccept} + /> + setShowRunAllModal(false)} + status={runAllStatus} + /> + { setTourOpen(false); openTermsIfNeeded(); }} /> +
+ ); +} + +export default function ExplorePageWrapper() { + return ; +} diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index 7e20df0..cba3cdd 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -404,18 +404,18 @@ export default function MenuBar() { Home - Explore + Browse - DNA Chat + Chat - Overview Report + Analyze Premium + + Explore + diff --git a/app/dna-chat/layout.tsx b/app/dna-chat/layout.tsx index fead5fd..58844b7 100644 --- a/app/dna-chat/layout.tsx +++ b/app/dna-chat/layout.tsx @@ -1,14 +1,14 @@ import type { Metadata } from "next"; export const metadata: Metadata = { - title: "DNA Chat - Monadic DNA Explorer", + title: "Chat - Monadic DNA Explorer", description: "Ask private AI questions about your saved genetic results. Your DNA data never leaves your device.", keywords: ["DNA chat", "genetic AI", "private DNA analysis", "personal genomics AI", "DNA questions"], alternates: { canonical: "https://explorer.monadicdna.com/dna-chat", }, openGraph: { - title: "DNA Chat - Monadic DNA Explorer", + title: "Chat - Monadic DNA Explorer", description: "Ask private AI questions about your saved genetic results. Your DNA data never leaves your device.", type: "website", url: "https://explorer.monadicdna.com/dna-chat", @@ -17,7 +17,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "DNA Chat - Monadic DNA Explorer", + title: "Chat - Monadic DNA Explorer", description: "Ask private AI questions about your saved genetic results. Your DNA data never leaves your device.", creator: "@MonadicDNA", }, diff --git a/app/explore/layout.tsx b/app/explore/layout.tsx index 86f0f4a..114e95b 100644 --- a/app/explore/layout.tsx +++ b/app/explore/layout.tsx @@ -1,14 +1,8 @@ import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Explore GWAS Studies - Monadic DNA Explorer", - description: "Search and filter millions of genetic associations from the GWAS Catalog. Upload your DNA data to see personalized results.", - keywords: ["GWAS", "genetic studies", "DNA research", "genome-wide association", "genetic variants", "SNP analysis"], - openGraph: { - title: "Explore GWAS Studies - Monadic DNA Explorer", - description: "Search millions of genetic associations and analyze your DNA data", - type: "website", - }, + title: "Explore - Monadic DNA Explorer", + description: "Discover a random study from the GWAS Catalog.", }; export default function ExploreLayout({ diff --git a/app/explore/page.tsx b/app/explore/page.tsx index 875cd70..548a0ee 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -1,1193 +1,53 @@ "use client"; -import { useEffect, useMemo, useState, useCallback, useRef, startTransition, memo } from "react"; -import Link from "next/link"; -import { useGenotype } from "../components/UserDataUpload"; -import { useResults } from "../components/ResultsContext"; -import { useAuth } from "../components/AuthProvider"; -import { RunAllIcon } from "../components/Icons"; -import StudyResultReveal from "../components/StudyResultReveal"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import MenuBar from "../components/MenuBar"; -import VariantChips from "../components/VariantChips"; import Footer from "../components/Footer"; -import DisclaimerModal from "../components/DisclaimerModal"; -import TermsAcceptanceModal from "../components/TermsAcceptanceModal"; -import RunAllModal from "../components/RunAllModal"; -import GuidedTour, { hasCompletedTour } from "../components/GuidedTour"; -import { exploreTour } from "../components/tours/tourContent"; -import { hasMatchingSNPs } from "@/lib/snp-utils"; -import { analyzeStudyClientSide } from "@/lib/risk-calculator"; -import { isDevModeEnabled } from "@/lib/dev-mode"; -import { hasValidPromoAccess, clearPromoAccess } from "@/lib/promo-access"; -import { - trackSearch, - trackRunAllCompleted, - trackRunAllFailed, - trackRunAllStarted, - trackQueryRun, - trackExploreTabViewed, - trackSearchModeChanged, -} from "@/lib/analytics"; -// Note: Metadata must be exported from layout.tsx or a server component -// This page is a client component and cannot export metadata +export default function ExplorePage() { + const router = useRouter(); + const [total, setTotal] = useState(null); + const [navigating, setNavigating] = useState(false); -type SortOption = "relevance" | "power" | "recent" | "alphabetical"; -type SortDirection = "asc" | "desc"; -type ConfidenceBand = "high" | "medium" | "low"; - -type Filters = { - search: string; - searchMode: "similarity" | "exact"; - trait: string; - minSampleSize: string; - maxPValue: string; - excludeLowQuality: boolean; - excludeMissingGenotype: boolean; - requireUserSNPs: boolean; - sort: SortOption; - sortDirection: SortDirection; - limit: number; - confidenceBand: ConfidenceBand | null; - offset: number; -}; - -type Study = { - id: number; - study_accession: string | null; - study: string | null; - disease_trait: string | null; - mapped_trait: string | null; - mapped_trait_uri: string | null; - mapped_gene: string | null; - first_author: string | null; - date: string | null; - journal: string | null; - pubmedid: string | null; - link: string | null; - initial_sample_size: string | null; - replication_sample_size: string | null; - p_value: string | null; - pvalue_mlog: string | null; - or_or_beta: string | null; - ci_text: string | null; - risk_allele_frequency: string | null; - strongest_snp_risk_allele: string | null; - snps: string | null; - sampleSize: number | null; - sampleSizeLabel: string; - pValueNumeric: number | null; - pValueLabel: string; - logPValue: number | null; - qualityFlags: Array<{ message: string; severity: string }>; - isLowQuality: boolean; - confidenceBand: ConfidenceBand; - publicationDate: number | null; - similarity?: number; // Semantic search similarity score (0-1, higher is more similar) - isAnalyzable: boolean; - nonAnalyzableReason?: string; -}; - -type StudiesResponse = { - data: Study[]; - total: number; - limit: number; - truncated: boolean; - sourceCount: number; - error?: string; -}; - -type QualitySummary = { - high: number; - medium: number; - low: number; - flagged: number; -}; - -const defaultFilters: Filters = { - search: "sleep", - searchMode: "similarity", - trait: "", - minSampleSize: "500", - maxPValue: "5e-8", - excludeLowQuality: true, - excludeMissingGenotype: true, - requireUserSNPs: false, - sort: "relevance", - sortDirection: "desc", - limit: 1000, - confidenceBand: null, - offset: 0, -}; - - -function InfoIcon({ text }: { text: string }) { - return ( - - ⓘ - - ); -} - -function parseVariantIds(snps: string | null): string[] { - if (!snps) { - return []; - } - return snps - .split(/[;,\s]+/) - .map((id) => id.trim()) - .filter(Boolean); -} - -function getRelevanceCategory(logPValue: number | null): { label: string; className: string } { - if (logPValue === null) return { label: "", className: "" }; - if (logPValue >= 9) return { label: "strong", className: "relevance-strong" }; - if (logPValue >= 7) return { label: "moderate", className: "relevance-moderate" }; - return { label: "weak", className: "relevance-weak" }; -} - -function getPowerCategory(sampleSize: number | null): { label: string; className: string } { - if (sampleSize === null) return { label: "", className: "" }; - if (sampleSize >= 50000) return { label: "large study", className: "power-large" }; - if (sampleSize >= 5000) return { label: "medium study", className: "power-medium" }; - if (sampleSize >= 1000) return { label: "small study", className: "power-small" }; - return { label: "very small", className: "power-very-small" }; -} - -function getEffectCategory(effectStr: string | null): { label: string; className: string } { - if (!effectStr) return { label: "", className: "" }; - const effect = parseFloat(effectStr); - if (isNaN(effect)) return { label: "", className: "" }; - - // Check if this looks like an odds ratio (typically > 0.5 and < 10) - // vs a beta coefficient (can be any value, often small) - const likelyOR = effect > 0.5 && effect < 10; - - if (likelyOR) { - if (Math.abs(effect - 1.0) < 0.05) return { label: "no effect", className: "effect-none" }; - if (effect < 1.0) { - if (effect <= 0.67) return { label: "protective", className: "effect-protective" }; - return { label: "slightly protective", className: "effect-slight-protective" }; - } - if (effect >= 2.0) return { label: "large effect", className: "effect-large" }; - if (effect >= 1.5) return { label: "moderate effect", className: "effect-moderate" }; - return { label: "small effect", className: "effect-small" }; - } - - // For beta coefficients, we can't easily categorize without trait context - return { label: "", className: "" }; -} - -function buildQuery(filters: Filters): string { - const params = new URLSearchParams(); - params.set("limit", String(filters.limit)); - params.set("offset", String(filters.offset)); - params.set("sort", filters.sort); - params.set("direction", filters.sortDirection); - params.set("excludeLowQuality", String(filters.excludeLowQuality)); - params.set("excludeMissingGenotype", String(filters.excludeMissingGenotype)); - if (filters.search.trim()) { - params.set("search", filters.search.trim()); - params.set("searchMode", filters.searchMode); - } - if (filters.trait) { - params.set("trait", filters.trait); - } - if (filters.minSampleSize.trim()) { - params.set("minSampleSize", filters.minSampleSize.trim()); - } - if (filters.maxPValue.trim()) { - params.set("maxPValue", filters.maxPValue.trim()); - } - if (filters.confidenceBand) { - params.set("confidenceBand", filters.confidenceBand); - } - return params.toString(); -} - -type DebouncedTextInputProps = { - id: string; - type?: "text" | "search"; - placeholder?: string; - list?: string; - value: string; - delay?: number; - onDebouncedChange: (value: string) => void; -}; - -const DebouncedTextInput = memo(function DebouncedTextInput({ - id, - type = "text", - placeholder, - list, - value, - delay = 500, - onDebouncedChange, -}: DebouncedTextInputProps) { - const inputRef = useRef(null); - const timerRef = useRef(null); - const committedValueRef = useRef(value); - - useEffect(() => { - committedValueRef.current = value; - - if (inputRef.current && inputRef.current.value !== value) { - inputRef.current.value = value; - } - }, [value]); - - useEffect(() => { - return () => { - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - } - }; - }, []); - - const scheduleCommit = useCallback((nextValue: string) => { - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - timerRef.current = null; - if (nextValue !== committedValueRef.current) { - onDebouncedChange(nextValue); - } - }, delay); - }, [delay, onDebouncedChange]); - - const commitImmediately = useCallback(() => { - const nextValue = inputRef.current?.value ?? ""; - - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - timerRef.current = null; - } - - if (nextValue !== committedValueRef.current) { - onDebouncedChange(nextValue); - } - }, [onDebouncedChange]); - - return ( - scheduleCommit(event.target.value)} - onBlur={commitImmediately} - onKeyDown={(event) => { - if (event.key === "Enter") { - commitImmediately(); - } - }} - /> - ); -}); - -function ExplorePage() { - const { genotypeData, isUploaded, setOnDataLoadedCallback } = useGenotype(); - const { setOnResultsLoadedCallback, addResult, addResultsBatch, hasResult } = useResults(); - const resultsContext = useResults(); - const { isAuthenticated, hasActiveSubscription } = useAuth(); - - // Track client-side mounting to prevent hydration errors - const [mounted, setMounted] = useState(false); - - // Track if search change is user-initiated (for Reddit analytics) - const userInitiatedSearchRef = useRef(false); - - useEffect(() => { - setMounted(true); - }, []); - - // Track Explore page view - useEffect(() => { - if (mounted) { - trackExploreTabViewed(); - } - }, [mounted]); - - // Auto-show guided tour on first visit - useEffect(() => { - if (mounted && !hasCompletedTour(exploreTour.id)) { - setTourOpen(true); - } - }, [mounted]); - - const [filters, setFilters] = useState(defaultFilters); - const scrollPositionRef = useRef(0); - const isLoadingMoreRef = useRef(false); - const [traits, setTraits] = useState([]); - const [studies, setStudies] = useState([]); - const [meta, setMeta] = useState>({ - total: 0, - limit: defaultFilters.limit, - truncated: false, - sourceCount: 0, - }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [sectionCollapsed, setSectionCollapsed] = useState(false); - const [showTermsModal, setShowTermsModal] = useState(false); - const [isRunningAll, setIsRunningAll] = useState(false); - const [runAllProgress, setRunAllProgress] = useState({ current: 0, total: 0 }); - const [showRunAllModal, setShowRunAllModal] = useState(false); - const [showRunAllDisclaimer, setShowRunAllDisclaimer] = useState(false); - const [tourOpen, setTourOpen] = useState(false); - const [runAllStatus, setRunAllStatus] = useState<{ - phase: 'fetching' | 'downloading' | 'decompressing' | 'parsing' | 'storing' | 'analyzing' | 'embeddings' | 'complete' | 'error'; - fetchedBatches: number; - totalStudiesFetched: number; - totalInDatabase: number; - matchingStudies: number; - processedCount: number; - totalToProcess: number; - matchCount: number; - startTime?: number; - elapsedSeconds?: number; - etaSeconds?: number; - errorMessage?: string; - }>({ - phase: 'fetching', - fetchedBatches: 0, - totalStudiesFetched: 0, - totalInDatabase: 0, - matchingStudies: 0, - processedCount: 0, - totalToProcess: 0, - matchCount: 0, - }); - const [loadTime, setLoadTime] = useState(null); - - // Terms modal opens after tour (or immediately if tour already completed) - const openTermsIfNeeded = useCallback(() => { - const termsAccepted = localStorage.getItem('terms_accepted'); - if (!termsAccepted) { - setShowTermsModal(true); - } - }, []); - - useEffect(() => { - if (!mounted) return; - if (hasCompletedTour(exploreTour.id)) { - openTermsIfNeeded(); - } - // Otherwise, terms will open via the tour's onClose handler - }, [mounted, openTermsIfNeeded]); - - const updateFilter = useCallback((key: Key, value: Filters[Key]) => { - setFilters((prev) => { - const next = { ...prev, [key]: value }; - if (key !== "confidenceBand") { - next.confidenceBand = null; - } - - // Reset offset to 0 when any filter changes (except offset, sort, sortDirection, limit) - // This ensures "Load More" starts fresh when user changes search/filters - const shouldResetOffset = key !== 'offset' && key !== 'sort' && key !== 'sortDirection' && key !== 'limit'; - if (shouldResetOffset) { - next.offset = 0; - } - - // Filter tracking removed for simplified analytics - - return next; - }); - }, []); - - const handleDebouncedSearchChange = useCallback((value: string) => { - if (value !== filters.search) { - userInitiatedSearchRef.current = true; - startTransition(() => { - updateFilter("search", value); - }); - } - }, [filters.search, updateFilter]); - - const handleDebouncedTraitChange = useCallback((value: string) => { - if (value !== filters.trait) { - startTransition(() => { - updateFilter("trait", value); - }); - } - }, [filters.trait, updateFilter]); - - // Set up callback to auto-check "Only my variants" when genotype data is loaded useEffect(() => { - setOnDataLoadedCallback(() => { - updateFilter("requireUserSNPs", true); - }); - }, [setOnDataLoadedCallback, updateFilter]); - - useEffect(() => { - let active = true; - fetch("/api/traits") - .then(async (response) => { - if (!response.ok) { - throw new Error("Unable to load traits"); - } - const payload = (await response.json()) as { traits: string[]; error?: string }; - if (!active) return; - if (payload.error) { - throw new Error(payload.error); - } - setTraits(payload.traits ?? []); + fetch("/api/studies?limit=1") + .then(r => r.json()) + .then(data => { + if (data.total) setTotal(data.total); }) - .catch(() => { - if (!active) return; - setTraits([]); - }); - return () => { - active = false; - }; + .catch(() => {}); }, []); - useEffect(() => { - const controller = new AbortController(); - const query = buildQuery(filters); - const startTime = performance.now(); - setLoading(true); - setError(null); - - fetch(`/api/studies?${query}`, { signal: controller.signal }) - .then(async (response) => { - const apiDuration = performance.now() - startTime; - - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.error ?? "Failed to load studies"); - } - const payload = (await response.json()) as StudiesResponse; - if (payload.error) { - throw new Error(payload.error); - } - - let filteredData = payload.data ?? []; - - // Client-side filtering for user SNPs - if (filters.requireUserSNPs && genotypeData) { - filteredData = filteredData.filter(study => { - // STRICT MODE: Only show studies where user has the specific SNP with the specific allele - const hasUserSNPs = hasMatchingSNPs(genotypeData, study.snps, study.strongest_snp_risk_allele, true); - if (!hasUserSNPs) return false; - - // If "Require genotype" is also enabled, ensure the study has genotype data - if (filters.excludeMissingGenotype) { - const hasGenotype = study.strongest_snp_risk_allele && - study.strongest_snp_risk_allele.trim().length > 0 && - study.strongest_snp_risk_allele.trim() !== '?' && - study.strongest_snp_risk_allele.trim() !== 'NR' && - !study.strongest_snp_risk_allele.includes('?'); - return hasGenotype; - } - - return true; - }); - } - - const endTime = performance.now(); - const totalLoadTime = endTime - startTime; - setLoadTime(totalLoadTime); - - // Track search if there's a search query and it was user-initiated - if (filters.search.trim() && userInitiatedSearchRef.current) { - trackSearch(filters.search, filteredData.length, totalLoadTime); - userInitiatedSearchRef.current = false; // Reset flag after tracking - } - - // Append results if offset > 0 (Load More), otherwise replace - if (filters.offset > 0) { - setStudies(prev => [...prev, ...filteredData]); - setMeta(prev => ({ - total: prev.total + filteredData.length, - limit: payload.limit ?? filters.limit, - truncated: payload.truncated ?? false, - sourceCount: payload.sourceCount ?? 0, - })); - } else { - setStudies(filteredData); - setMeta({ - total: filteredData.length, - limit: payload.limit ?? filters.limit, - truncated: payload.truncated ?? false, - sourceCount: payload.sourceCount ?? 0, - }); - } - }) - .catch((err) => { - if (controller.signal.aborted) { - return; - } - setError(err instanceof Error ? err.message : "Failed to load studies"); - setStudies([]); - }) - .finally(() => { - if (!controller.signal.aborted) { - setLoading(false); - // Restore scroll position after loading more results - if (isLoadingMoreRef.current) { - requestAnimationFrame(() => { - window.scrollTo(0, scrollPositionRef.current); - isLoadingMoreRef.current = false; - }); - } - } - }); - - return () => controller.abort(); - }, [filters, genotypeData]); - - const qualitySummary = useMemo(() => { - return studies.reduce( - (acc, study) => { - acc[study.confidenceBand] += 1; - if (study.isLowQuality) { - acc.flagged += 1; - } - return acc; - }, - { high: 0, medium: 0, low: 0, flagged: 0 }, - ); - }, [studies]); - - const resetFilters = () => { - setFilters(defaultFilters); - userInitiatedSearchRef.current = false; - }; - - - const handleColumnSort = (sortKey: SortOption) => { - const newDirection = filters.sort === sortKey - ? (filters.sortDirection === "asc" ? "desc" : "asc") - : "desc"; - - if (filters.sort === sortKey) { - // Same column clicked, toggle direction - updateFilter("sortDirection", newDirection); - } else { - // New column clicked, set to desc (most common use case) - updateFilter("sort", sortKey); - updateFilter("sortDirection", newDirection); - } + const handleRandom = () => { + if (total === null) return; + setNavigating(true); + const id = Math.floor(Math.random() * total) + 1; + router.push(`/study/${id}`); }; - const handleStudyColumnSort = () => { - // Study column cycles between alphabetical and recent - if (filters.sort === "alphabetical") { - handleColumnSort("recent"); - } else if (filters.sort === "recent") { - // Toggle direction for recent - const newDirection = filters.sortDirection === "asc" ? "desc" : "asc"; - updateFilter("sortDirection", newDirection); - } else { - // Start with alphabetical - handleColumnSort("alphabetical"); - } - }; - - const handleRunAll = () => { - if (!genotypeData || genotypeData.size === 0) { - trackRunAllFailed("explore", "no_genotype_data"); - alert("No SNPs found in your genetic data"); - return; - } - - // Show disclaimer first - setShowRunAllDisclaimer(true); - }; - - const handleRunAllDisclaimerAccept = async () => { - setShowRunAllDisclaimer(false); - - // Check if we need to download the catalog first - const { gwasDB } = await import('@/lib/gwas-db'); - const metadata = await gwasDB.getMetadata(); - - if (!metadata) { - const confirmDownload = window.confirm( - `First-time setup: Download ~54MB GWAS Catalog data?\n\n` + - `This will be cached locally for instant future analysis.\n` + - `Estimated storage: ~500MB after decompression.\n\n` + - `Continue?` - ); - if (!confirmDownload) return; - } else { - const confirmRun = window.confirm( - `Analyze all ${metadata.totalStudies.toLocaleString()} studies where you have matching SNPs?\n\n` + - `Using cached data from ${new Date(metadata.downloadDate).toLocaleDateString()}\n\n` + - `Continue?` - ); - if (!confirmRun) return; - } - - // Initialize and show modal - setIsRunningAll(true); - setShowRunAllModal(true); - const startTime = Date.now(); - setRunAllStatus({ - phase: 'fetching', - fetchedBatches: 0, - totalStudiesFetched: 0, - totalInDatabase: 0, - matchingStudies: 0, - processedCount: 0, - totalToProcess: 0, - matchCount: 0, - startTime, - }); - setRunAllProgress({ current: 0, total: 0 }); - - // Track Run All started (with estimated study count) - trackRunAllStarted(metadata?.totalStudies || 0); - - try { - // Check if genotype data is loaded - if (!genotypeData) { - throw new Error('No genotype data loaded. Please upload your genetic data first.'); - } - - // Use IndexedDB-based implementation - const { runAllAnalysisIndexed } = await import('@/lib/run-all-indexed'); - - const results = await runAllAnalysisIndexed( - genotypeData, - (progress) => { - setRunAllStatus(prev => ({ - ...prev, - phase: progress.phase, - totalStudiesFetched: progress.loaded, - totalInDatabase: progress.total, - matchingStudies: progress.matchingStudies, - matchCount: progress.matchCount, - elapsedSeconds: progress.elapsedSeconds, - fetchedBatches: 0, - processedCount: progress.matchingStudies, - totalToProcess: progress.matchingStudies, - })); - }, - hasResult - ); - - // Add all results in one efficient batch operation - console.log(`Adding ${results.length} results to the results manager...`); - const startAdd = Date.now(); - await addResultsBatch(results); // Embeddings will be fetched on-demand during LLM analysis - const addTime = Date.now() - startAdd; - console.log(`Finished adding ${results.length} results in ${addTime}ms`); - trackRunAllCompleted(metadata?.totalStudies || 0, results.length, results.length, "explore"); - - // Notify MenuBar that cache has been updated - window.dispatchEvent(new CustomEvent('cacheUpdated')); - } catch (error) { - console.error('Run All failed:', error); - trackRunAllFailed("explore", error instanceof Error ? error.message : "run_all_failed"); - setRunAllStatus(prev => ({ - ...prev, - phase: 'error', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - })); - } finally { - setIsRunningAll(false); - } - }; - - const summaryText = useMemo(() => { - if (error) { - return error; - } - if (loading) { - return "Loading studies…"; - } - if (studies.length === 0) { - return "No studies match the current filters."; - } - const parts = [ - `${studies.length} of ${meta.total} quality-filtered studies`, - `${meta.sourceCount.toLocaleString()} matches before quality filters`, - ]; - if (loadTime !== null) { - parts.push(`loaded in ${Math.round(loadTime)}ms`); - } - const breakdown: string[] = []; - if (qualitySummary.high > 0) { - breakdown.push(`${qualitySummary.high} high`); - } - if (qualitySummary.medium > 0) { - breakdown.push(`${qualitySummary.medium} medium`); - } - if ((qualitySummary.low > 0 && !filters.excludeLowQuality) || filters.confidenceBand === "low") { - breakdown.push(`${qualitySummary.low} low`); - } - if (breakdown.length > 0) { - parts.push(`Confidence mix: ${breakdown.join(", ")}`); - } - if (meta.truncated) { - parts.push(`showing the top ${meta.limit}`); - } - if (qualitySummary.flagged > 0 && !filters.excludeLowQuality) { - parts.push(`${qualitySummary.flagged} flagged as lower confidence`); - } - return parts.join(" · "); - }, [ - studies.length, - meta, - loading, - error, - loadTime, - qualitySummary.high, - qualitySummary.medium, - qualitySummary.low, - qualitySummary.flagged, - filters.excludeLowQuality, - filters.confidenceBand, - ]); - return (
- setShowTermsModal(false)} - /> -
-
-
-
- {!sectionCollapsed && ( - <> -

Study Filters

-

Filter genetic association studies by various criteria.

- - )} - {sectionCollapsed &&

Study Filters

} -
-
- {!sectionCollapsed && ( - - )} - {!sectionCollapsed && ( - - )} - -
-
- {!sectionCollapsed && ( -
-
-
- - -
- - -
-
-
- - - - {traits.map((traitOption) => ( - -
-
- - updateFilter("minSampleSize", event.target.value)} - /> -
-
-
-
- - -
-
- - -
-
- updateFilter("excludeMissingGenotype", event.target.checked)} - /> - -
- {isUploaded && ( -
- updateFilter("requireUserSNPs", event.target.checked)} - /> - -
- )} -
-
- )} -
- -
-

{summaryText}

- -
- -
-
- - - - - {studies.some(s => s.similarity !== undefined) && ( - - )} - - - - - - - - - - - - {loading && ( - - - - )} - {!loading && studies.length === 0 && ( - - - - )} - {!loading && - studies.map((study, index) => { - const trait = study.disease_trait ?? study.mapped_trait ?? "-"; - const mappedTrait = study.mapped_trait ?? "-"; - const date = study.publicationDate - ? new Date(study.publicationDate).toLocaleDateString() - : study.date - ? new Date(study.date).toLocaleDateString() || study.date - : "-"; - const relevance = study.logPValue ? study.logPValue.toFixed(2) : "-"; - const power = study.sampleSizeLabel ?? "-"; - const effect = study.or_or_beta ?? "-"; - const relevanceCategory = getRelevanceCategory(study.logPValue); - const powerCategory = getPowerCategory(study.sampleSize); - const effectCategory = getEffectCategory(study.or_or_beta); - const gwasLink = study.study_accession - ? `https://www.ebi.ac.uk/gwas/studies/${study.study_accession}` - : null; - const studyLink = - gwasLink || study.link || (study.pubmedid ? `https://pubmed.ncbi.nlm.nih.gov/${study.pubmedid}` : null); - const variantIds = parseVariantIds(study.snps); - const variantGenotype = study.strongest_snp_risk_allele?.trim() ?? ""; - const hasGenotype = variantGenotype.length > 0; - const confidenceLabel = - study.confidenceBand === "high" - ? "High confidence" - : study.confidenceBand === "medium" - ? "Medium confidence" - : "Lower confidence"; - return ( - - - {study.similarity !== undefined && ( - - )} - - - - - - - - - - ); - })} - -
- Study {filters.sort === "recent" && "(by date)"} - - {(filters.sort === "alphabetical" || filters.sort === "recent") && ( - {filters.sortDirection === "asc" ? " ↑" : " ↓"} - )} - - Similarity - - - Trait - - Mapped Trait - - Variant - handleColumnSort("relevance")} - > - Relevance - - {filters.sort === "relevance" && ( - {filters.sortDirection === "asc" ? " ↑" : " ↓"} - )} - handleColumnSort("power")} - > - Power - - {filters.sort === "power" && ( - {filters.sortDirection === "asc" ? " ↑" : " ↓"} - )} - - Effect - - Quality - - Your Result -
s.similarity !== undefined) ? 10 : 9} className="loading-row"> - Loading… -
s.similarity !== undefined) ? 10 : 9} className="empty-row"> - No studies found. Try widening your filters. -
-
- - {study.study ?? "Untitled study"} - -
-
- {study.first_author ?? "Unknown author"} - {date} - {study.study_accession && {study.study_accession}} - {study.mapped_gene && Gene: {study.mapped_gene}} -
-
- {study.similarity.toFixed(3)} - {trait}{mappedTrait} - - - {relevance} - {relevanceCategory.label && ( - {relevanceCategory.label} - )} - {study.pValueNumeric !== null && ( - p = {study.pValueLabel} - )} - - {power} - {powerCategory.label && ( - {powerCategory.label} - )} - {study.initial_sample_size && ( - Initial: {study.initial_sample_size} - )} - {study.replication_sample_size && ( - Replication: {study.replication_sample_size} - )} - - {effect} - {effectCategory.label && ( - {effectCategory.label} - )} - {study.risk_allele_frequency && ( - RAF: {study.risk_allele_frequency} - )} - -
- {confidenceLabel} - {study.qualityFlags.length > 0 && ( -
- {study.qualityFlags.map((flag, index) => ( - - {flag.message} - - ))} -
- )} -
-
- -
-
- - {/* Load More Button */} - {!loading && studies.length > 0 && studies.length < meta.sourceCount && ( -
-

- Showing {studies.length.toLocaleString()} of {meta.sourceCount.toLocaleString()} matches +

+

Explore

+

Navigate to a random study from the GWAS Catalog.

+ {total !== null && ( +

+ {total.toLocaleString()} studies available

- -
- )} -
+ )} + +