diff --git a/.gitignore b/.gitignore index 59c8729..c62e0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* /embeddings_backup.npz /AGENTS.md /CLAUDE.md +/scripts/ga-performance.env diff --git a/app/api/pubmed-abstract/route.ts b/app/api/pubmed-abstract/route.ts new file mode 100644 index 0000000..5b83abc --- /dev/null +++ b/app/api/pubmed-abstract/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +const cache = new Map(); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export async function GET(request: NextRequest) { + const pmid = request.nextUrl.searchParams.get("pmid"); + if (!pmid || !/^\d+$/.test(pmid)) { + return NextResponse.json({ error: "Invalid PMID" }, { status: 400 }); + } + + const cached = cache.get(pmid); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return NextResponse.json({ abstract: cached.abstract, title: cached.title }); + } + + try { + const url = `https://www.ebi.ac.uk/europepmc/webservices/rest/article/MED/${pmid}?resultType=core&format=json`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + + if (!res.ok) { + return NextResponse.json({ error: `Europe PMC returned ${res.status}` }, { status: 502 }); + } + + const data = await res.json(); + const article = data?.result; + if (!article) { + return NextResponse.json({ error: "Article not found" }, { status: 404 }); + } + + const abstract: string = article.abstractText ?? ""; + const title: string = article.title ?? ""; + + cache.set(pmid, { abstract, title, fetchedAt: Date.now() }); + return NextResponse.json({ abstract, title }); + } catch (err) { + const message = err instanceof Error ? err.message : "Fetch failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/studies/route.ts b/app/api/studies/route.ts index e0a56c3..542c148 100644 --- a/app/api/studies/route.ts +++ b/app/api/studies/route.ts @@ -330,6 +330,49 @@ export async function GET(request: NextRequest) { if (originError) return originError; const searchParams = request.nextUrl.searchParams; + + // Fast single-study lookup by primary key — used by the study detail page + const idParam = searchParams.get("id"); + if (idParam !== null) { + const studyPk = parseInt(idParam); + if (isNaN(studyPk) || studyPk <= 0) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + try { + const rows = await executeQuery( + `SELECT id, study_accession, study, disease_trait, mapped_trait, + mapped_trait_uri, mapped_gene, first_author, date, journal, + pubmedid, link, initial_sample_size, replication_sample_size, + p_value, pvalue_mlog, or_or_beta, ci_text, risk_allele_frequency, + strongest_snp_risk_allele, snps + FROM gwas_catalog WHERE id = $1 LIMIT 1`, + [studyPk] + ); + if (rows.length === 0) { + return NextResponse.json({ data: [], total: 0, limit: 1 }); + } + const row = rows[0]; + const sampleSize = parseSampleSize(row.initial_sample_size) ?? parseSampleSize(row.replication_sample_size); + const pValueNumeric = parsePValue(row.p_value); + const logPValue = parseLogPValue(row.pvalue_mlog) ?? (pValueNumeric ? -Math.log10(pValueNumeric) : null); + const qualityFlags = computeQualityFlags(sampleSize, pValueNumeric, logPValue); + const isLowQuality = qualityFlags.some(f => f.severity === 'major'); + const confidenceBand = determineConfidenceBand(sampleSize, pValueNumeric, logPValue, qualityFlags); + const publicationDate = parseStudyDate(row.date); + const isAnalyzable = !!(row.snps && row.or_or_beta && row.strongest_snp_risk_allele); + const nonAnalyzableReason = !isAnalyzable + ? (!row.snps ? 'Missing SNP data' : !row.or_or_beta ? 'Missing effect size (OR/beta)' : 'Missing risk allele') + : undefined; + const study = { ...row, sampleSize, sampleSizeLabel: formatNumber(sampleSize), pValueNumeric, + pValueLabel: formatPValue(pValueNumeric), logPValue, qualityFlags, isLowQuality, confidenceBand, + publicationDate, isAnalyzable, nonAnalyzableReason }; + return NextResponse.json({ data: [study], total: 1, limit: 1 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch study"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } + const search = searchParams.get("search")?.trim(); const searchTerms = search ? getSearchTerms(search) : []; const trait = searchParams.get("trait")?.trim(); 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..8744cf7 --- /dev/null +++ b/app/browse/page.tsx @@ -0,0 +1,1229 @@ +"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 BrowseHeatmap from "../components/BrowseHeatmap"; +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: 2000, + 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); + + useEffect(() => { + if (typeof window === "undefined") return; + const q = new URLSearchParams(window.location.search).get("q"); + if (q) setFilters(prev => ({ ...prev, search: q })); + }, []); + + 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(true); + 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); + const [activeTab, setActiveTab] = useState<'table' | 'heatmap'>('table'); + + // 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]); + + // Auto-check "Only my variants" if data is already uploaded on mount + useEffect(() => { + if (isUploaded) { + updateFilter("requireUserSNPs", true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isUploaded]); + + // 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}

+
+ + +
+
+ + {activeTab === 'heatmap' ? ( +
+ +
+ ) : ( +
+
+ + + + + {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/BrowseHeatmap.tsx b/app/components/BrowseHeatmap.tsx new file mode 100644 index 0000000..06ee02d --- /dev/null +++ b/app/components/BrowseHeatmap.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; +import { useResults } from "./ResultsContext"; +import type { SavedResult } from "@/lib/results-manager"; + +type HeatmapStudy = { + id: number; + mapped_trait: string | null; + disease_trait: string | null; + study: string | null; +}; + +type Props = { + studies: HeatmapStudy[]; + totalCount: number; +}; + +function effectMagnitude(r: SavedResult): number { + if (r.effectType === 'beta') return Math.abs(r.riskScore); + if (r.riskScore <= 0) return 0; + return Math.abs(Math.log(r.riskScore)); +} + +function chipBg(r: SavedResult): string { + const mag = effectMagnitude(r); + const a = Math.min(0.95, 0.35 + mag * 0.6).toFixed(2); + if (r.riskLevel === 'increased') return `rgba(220,38,38,${a})`; + if (r.riskLevel === 'decreased') return `rgba(22,163,74,${a})`; + return 'rgba(148,163,184,0.5)'; +} + +function effectLabel(r: SavedResult): string { + if (r.effectType === 'beta') return `β=${r.riskScore >= 0 ? '+' : ''}${r.riskScore.toFixed(3)}`; + return `OR ${r.riskScore.toFixed(2)}x`; +} + +export default function BrowseHeatmap({ studies, totalCount }: Props) { + const { getResult, hasResult } = useResults(); + + const groups = useMemo(() => { + const map = new Map(); + for (const s of studies) { + const trait = s.mapped_trait?.trim() || s.disease_trait?.trim() || "Unknown"; + const existing = map.get(trait) || []; + existing.push(s); + map.set(trait, existing); + } + + return Array.from(map.entries()) + .map(([trait, items]) => { + const withResults = items + .map(s => ({ study: s, result: hasResult(s.id) ? getResult(s.id) : null })) + .sort((a, b) => { + if (!a.result && !b.result) return 0; + if (!a.result) return 1; + if (!b.result) return -1; + return effectMagnitude(b.result) - effectMagnitude(a.result); + }); + + const increased = withResults.filter(x => x.result?.riskLevel === 'increased').length; + const decreased = withResults.filter(x => x.result?.riskLevel === 'decreased').length; + const analyzed = withResults.filter(x => x.result).length; + const dominant = increased > decreased ? 'increased' : decreased > increased ? 'decreased' : increased > 0 ? 'mixed' : 'none'; + + return { trait, items: withResults, increased, decreased, analyzed, total: items.length, dominant }; + }) + // Sort: most analyzed first, break ties by dominant signal magnitude + .sort((a, b) => { + if (b.analyzed !== a.analyzed) return b.analyzed - a.analyzed; + return b.total - a.total; + }); + }, [studies, hasResult, getResult]); + + return ( +
+
+ + ↑ 4 + Elevated risk studies + + + ↓ 2 + Reduced risk studies + + + + Not analyzed — darker chip = stronger effect + +
+ +
+ {groups.map(({ trait, items, increased, decreased, total, dominant }) => ( +
+ {trait} +
+ {increased > 0 && ( + ↑ {increased} + )} + {decreased > 0 && ( + ↓ {decreased} + )} + {increased === 0 && decreased === 0 && ( + {total} + )} +
+
+ {items.map(({ study: s, result }) => + result ? ( + + ) : ( + + ) + )} +
+
+ ))} +
+ + {studies.length < totalCount && ( +

+ Showing {studies.length.toLocaleString()} of {totalCount.toLocaleString()} studies. Switch to Table view and use Load More to expand. +

+ )} +
+ ); +} diff --git a/app/components/HealthReportModal.tsx b/app/components/HealthReportModal.tsx new file mode 100644 index 0000000..11ff9fb --- /dev/null +++ b/app/components/HealthReportModal.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useResults } from "./ResultsContext"; +import { useCustomization } from "./CustomizationContext"; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { SelectedResult } from "@/lib/health-report-service"; +import { trackHealthReportGenerated, trackReportOpenedInChat } from "@/lib/analytics"; + +type Phase = 'idle' | 'generating' | 'complete' | 'error'; + +interface HealthReportModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function HealthReportModal({ isOpen, onClose }: HealthReportModalProps) { + const router = useRouter(); + const { savedResults } = useResults(); + const { customization } = useCustomization(); + + const [phase, setPhase] = useState('idle'); + const [message, setMessage] = useState(''); + const [progress, setProgress] = useState(0); + const [report, setReport] = useState(null); + const [selectedTraits, setSelectedTraits] = useState([]); + const [questions, setQuestions] = useState([]); + const [traitsExpanded, setTraitsExpanded] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + + // Animate progress bar while generating + const progressTimerRef = useRef | null>(null); + + useEffect(() => { + if (phase === 'generating') { + progressTimerRef.current = setInterval(() => { + setProgress(p => p < 88 ? p + 1 : p); + }, 400); + } else { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + } + return () => { if (progressTimerRef.current) clearInterval(progressTimerRef.current); }; + }, [phase]); + + const handleGenerate = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setPhase('generating'); + setMessage('Selecting relevant results…'); + setProgress(10); + setError(null); + setReport(null); + + try { + const { generateHealthReport } = await import('@/lib/health-report-service'); + const { report: result, selected, questions: qs } = await generateHealthReport(savedResults, customization, (update) => { + setMessage(update.message); + setProgress(update.progress); + }); + setReport(result); + setSelectedTraits(selected); + setQuestions(qs); + setProgress(100); + setPhase('complete'); + const conditionCount = (customization?.personalConditions?.length ?? 0) + (customization?.familyConditions?.length ?? 0); + trackHealthReportGenerated(selected.length, conditionCount); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Generation failed.'; + setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); + setPhase('error'); + } finally { + inFlightRef.current = false; + } + }; + + const handleCopy = async () => { + if (!report) return; + try { + await navigator.clipboard.writeText(report); + alert('Report copied to clipboard!'); + } catch { + alert('Failed to copy report to clipboard'); + } + }; + + const handlePrint = () => { + if (!report) return; + const win = window.open('', '_blank'); + if (!win) return; + win.document.write(`Health Insights Report +

Health Insights Report

+

Generated ${new Date().toLocaleString()}

+
${report.replace(/\n/g, '
')}
+
Generated by Monadic DNA Explorer • For educational purposes only
+ `); + win.document.close(); + win.print(); + }; + + const handleOpenInChat = (question?: string) => { + if (!report) return; + trackReportOpenedInChat('health_insights', !!question); + localStorage.setItem('health_report_context', report); + router.push(question ? `/dna-chat?q=${encodeURIComponent(question)}` : '/dna-chat'); + }; + + const handleClose = () => { + if (phase === 'generating') return; + setPhase('idle'); + setReport(null); + setSelectedTraits([]); + setQuestions([]); + setTraitsExpanded(false); + setError(null); + setProgress(0); + onClose(); + }; + + const hasConditions = + (customization?.personalConditions?.length ?? 0) > 0 || + (customization?.familyConditions?.length ?? 0) > 0; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + + +
+ + {/* ── Idle ── */} + {phase === 'idle' && ( +
+

Health Insights Report

+

+ This report is anchored to your health history. It selects the genetic associations most relevant to your personal and family conditions, then identifies the underlying biological mechanisms that may be at play. +

+ {!hasConditions && ( +
+ No health history found in your personalization settings. The report will still run using your strongest genetic signals, but adding personal and family conditions will make it more targeted. +
+ )} + {hasConditions && ( +
+ Personal conditions: + {(customization?.personalConditions ?? []).join(', ') || 'none'} +
+ Family history: + {(customization?.familyConditions ?? []).join(', ') || 'none'} +
+ )} +
+ +
+

+ {savedResults.length.toLocaleString()} saved results available · Single AI call · ~30 seconds +

+
+ )} + + {/* ── Generating ── */} + {phase === 'generating' && ( +
+
+
+

+ Generating Health Insights Report +

+

{message}

+
+
+
+
+

+ This may take 20–40 seconds depending on the provider. +

+
+ )} + + {/* ── Error ── */} + {phase === 'error' && ( +
+

Generation failed: {error}

+ +
+ )} + + {/* ── Complete ── */} + {phase === 'complete' && report && ( +
+
+ + + + +
+ +
+ Generated: {new Date().toLocaleString()}
+ Based on: {selectedTraits.length} associations selected from {savedResults.length.toLocaleString()} saved results + {hasConditions && ( + <> · anchored to { + (customization?.personalConditions?.length ?? 0) + + (customization?.familyConditions?.length ?? 0) + } health history conditions + )} +
+ +
+ These are population-level associations from GWAS studies, not individual predictions. Effect sizes reflect averages across large cohorts. Do not use this report for medical decisions. +
+ +
+ {report} +
+ + {/* Suggested questions */} + {questions.length > 0 && ( +
+

+ Ask in DNA Chat: +

+
+ {questions.map((q, i) => ( + + ))} +
+
+ )} + + {/* Traits used */} +
+ + {traitsExpanded && ( +
+ {selectedTraits.map((r, i) => { + const dir = r.riskLevel === 'increased' ? '↑' : r.riskLevel === 'decreased' ? '↓' : '→'; + const effect = r.effectType === 'OR' + ? `OR ${r.riskScore.toFixed(2)}x` + : `β=${r.riskScore >= 0 ? '+' : ''}${r.riskScore.toFixed(3)}`; + return ( +
+ {dir} + {r.traitName} + {effect} + {r.mappedGene && {r.mappedGene}} + {r.matchedCondition && [{r.matchedCondition}]} +
+ ); + })} +
+ )} +
+ +
+ Generated by Monadic DNA Explorer · For educational purposes only +
+
+ )} +
+
+
+ ); +} diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx new file mode 100644 index 0000000..6c0a10d --- /dev/null +++ b/app/components/HealthspanReportModal.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useResults } from "./ResultsContext"; +import { useCustomization } from "./CustomizationContext"; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { DOMAIN_LABELS } from "@/lib/healthspan-report-service"; +import type { HealthspanReportResult } from "@/lib/healthspan-report-service"; +import { trackHealthspanReportGenerated, trackReportOpenedInChat } from "@/lib/analytics"; + +type Phase = 'idle' | 'generating' | 'complete' | 'error'; + +interface HealthspanReportModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function HealthspanReportModal({ isOpen, onClose }: HealthspanReportModalProps) { + const router = useRouter(); + const { savedResults } = useResults(); + const { customization } = useCustomization(); + + const [phase, setPhase] = useState('idle'); + const [message, setMessage] = useState(''); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + const [questions, setQuestions] = useState([]); + const [traitsExpanded, setTraitsExpanded] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + const progressTimerRef = useRef | null>(null); + + useEffect(() => { + if (phase === 'generating') { + progressTimerRef.current = setInterval(() => { + setProgress(p => p < 88 ? p + 1 : p); + }, 500); + } else { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + } + return () => { if (progressTimerRef.current) clearInterval(progressTimerRef.current); }; + }, [phase]); + + const handleGenerate = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setPhase('generating'); + setMessage('Organizing results by healthspan domain…'); + setProgress(10); + setError(null); + setResult(null); + + try { + const { generateHealthspanReport } = await import('@/lib/healthspan-report-service'); + const res = await generateHealthspanReport(savedResults, customization, (update) => { + setMessage(update.message); + setProgress(update.progress); + }); + setResult(res); + setQuestions(res.questions ?? []); + setProgress(100); + setPhase('complete'); + const domainCount = Object.values(res.domainCounts).filter(c => c.elevated + c.protective >= 2).length; + trackHealthspanReportGenerated(res.selected.length, domainCount); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Generation failed.'; + setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); + setPhase('error'); + } finally { + inFlightRef.current = false; + } + }; + + const handleCopy = async () => { + if (!result?.report) return; + try { + await navigator.clipboard.writeText(result.report); + alert('Report copied to clipboard!'); + } catch { + alert('Failed to copy report to clipboard'); + } + }; + + const handlePrint = () => { + if (!result?.report) return; + const win = window.open('', '_blank'); + if (!win) return; + win.document.write(`Healthspan Report +

Healthspan Report

+

Generated ${new Date().toLocaleString()}

+
${result.report.replace(/\n/g, '
')}
+
Generated by Monadic DNA Explorer • For educational purposes only
+ `); + win.document.close(); + win.print(); + }; + + const handleOpenInChat = (question?: string) => { + if (!result?.report) return; + trackReportOpenedInChat('healthspan', !!question); + localStorage.setItem('health_report_context', result.report); + router.push(question ? `/dna-chat?q=${encodeURIComponent(question)}` : '/dna-chat'); + }; + + const handleClose = () => { + if (phase === 'generating') return; + setPhase('idle'); + setResult(null); + setQuestions([]); + setTraitsExpanded(false); + setError(null); + setProgress(0); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + + +
+ + {/* Idle */} + {phase === 'idle' && ( +
+

Healthspan Report

+

+ Organizes your genetic associations across 6 healthspan domains, independent of your personal health history. Surfaces the highest-signal traits in each domain and synthesizes patterns within and across domains. +

+
+ {Object.values(DOMAIN_LABELS).map(label => ( +
+ {label} +
+ ))} +
+
+ +
+

+ {savedResults.length.toLocaleString()} saved results available · Single AI call · ~40 seconds +

+
+ )} + + {/* Generating */} + {phase === 'generating' && ( +
+
+
+

+ Generating Healthspan Report +

+

{message}

+
+
+
+
+

+ This may take 30-50 seconds depending on the provider. +

+
+ )} + + {/* Error */} + {phase === 'error' && ( +
+

Generation failed: {error}

+ +
+ )} + + {/* Complete */} + {phase === 'complete' && result && ( +
+
+ + + + +
+ +
+ Generated: {new Date().toLocaleString()}
+ {Object.entries(result.domainCounts) + .filter(([, c]) => c.elevated + c.protective > 0) + .map(([id, c]) => ( + + {DOMAIN_LABELS[id]}:{' '} + {c.elevated > 0 && ↑{c.elevated}} + {c.elevated > 0 && c.protective > 0 && ' '} + {c.protective > 0 && ↓{c.protective}} + + ))} +
+ +
+ These are population-level associations from GWAS studies, not individual predictions. Effect sizes reflect averages across large cohorts. Do not use this report for medical decisions. +
+ +
+ {result.report} +
+ + {/* Suggested questions */} + {questions.length > 0 && ( +
+

+ Ask in DNA Chat: +

+
+ {questions.map((q, i) => ( + + ))} +
+
+ )} + +
+ + {traitsExpanded && ( +
+ {result.selected.map((r, i) => { + const dir = r.riskLevel === 'increased' ? '↑' : r.riskLevel === 'decreased' ? '↓' : '→'; + return ( +
+ {dir} + {r.traitName} + OR {r.riskScore.toFixed(2)}x + {r.mappedGene && {r.mappedGene}} + {DOMAIN_LABELS[r.domain]} +
+ ); + })} +
+ )} +
+ +
+ Generated by Monadic DNA Explorer · For educational purposes only +
+
+ )} +
+
+
+ ); +} diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 9e53e67..ee4aea1 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -8,7 +8,7 @@ import { useCustomization } from "./CustomizationContext"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { callLLM, callLLMStream, getLLMDescription, MessageContentPart } from "@/lib/llm-client"; -import { trackLLMQuestionAsked, trackAIConsentGiven, trackAIConsentDeclined, trackAIConsentModalShown, trackExampleQuestionClicked } from "@/lib/analytics"; +import { trackLLMQuestionAsked, trackAIConsentGiven, trackAIConsentDeclined, trackAIConsentModalShown, trackExampleQuestionClicked, trackFollowupQuestionClicked } from "@/lib/analytics"; type AttachmentType = 'text' | 'pdf' | 'csv' | 'tsv' | 'image'; @@ -28,6 +28,7 @@ type Message = { timestamp: Date; studiesUsed?: SavedResult[]; attachments?: Attachment[]; + followupQuestions?: string[]; }; const CONSENT_STORAGE_KEY = "nilai_llm_chat_consent_accepted"; @@ -48,21 +49,22 @@ const EXAMPLE_QUESTIONS = [ "What can you guess about my appearance?" ]; -const FOLLOWUP_SUGGESTIONS = [ - "Give me film, TV and music recommendations based on these results!", - "Is there anything fun in the results?", - "Tell me more about the science of my results.", - "Any supplements or vitamins I should consider?", - "How should I adjust my diet and lifestyle?" -]; -export default function AIChatInline() { +export default function AIChatInline({ initialInput }: { initialInput?: string } = {}) { const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; const { customization, status: customizationStatus } = useCustomization(); const [mounted, setMounted] = useState(false); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (!initialInput) return; + setInputValue(initialInput); + const t = setTimeout(() => handleSendMessage(false, initialInput), 0); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialInput]); const [isLoading, setIsLoading] = useState(false); const [loadingStatus, setLoadingStatus] = useState(""); const [error, setError] = useState(null); @@ -122,6 +124,11 @@ export default function AIChatInline() { trackExampleQuestionClicked(); }; + const handleFollowupClick = (question: string) => { + trackFollowupQuestionClicked(); + void handleSendMessage(false, question); + }; + const handleCopyMessage = async (content: string) => { try { await navigator.clipboard.writeText(content); @@ -300,8 +307,8 @@ export default function AIChatInline() { return parts; }; - const handleSendMessage = async (skipConsentCheck = false) => { - const query = inputValue.trim(); + const handleSendMessage = async (skipConsentCheck = false, queryOverride?: string) => { + const query = (queryOverride ?? inputValue).trim(); if (!query) return; // Check consent before sending first message @@ -437,7 +444,16 @@ RESPONSE STYLE: - Maintain the same helpful, educational tone as before - NO need for comprehensive action plans or structured sections unless specifically asked -Remember: This is educational, not medical advice. The detailed disclaimers were already provided in your initial response.`; +Remember: This is educational, not medical advice. The detailed disclaimers were already provided in your initial response. + +After the response, append exactly this block (required): + +FOLLOWUP: +- [a question that ties what you just said directly to this user's personal situation — their age, ancestry, conditions, medications, or lifestyle] +- [a question that digs deeper into the most interesting or surprising point in your response] +- [a question that connects this topic to something else in their profile or health history] + +Write questions from the user's perspective — as if the user is asking you. Not "do you notice X?" but "Why does ANK3 affect my RLS symptoms?" Reference the user's actual details where relevant.`; const systemPrompt = `You are an expert providing personalized, holistic insights about GWAS results. ${llmDescription} @@ -532,7 +548,16 @@ RESPONSE REQUIREMENTS: - This is educational, NOT medical advice - COMPLETE your full response - never stop abruptly -Remember: You have plenty of space. Use ALL of it to provide a complete, thorough, personalized analysis. Do not rush. Do not truncate.`; +Remember: You have plenty of space. Use ALL of it to provide a complete, thorough, personalized analysis. Do not rush. Do not truncate. + +After the response, append exactly this block (required): + +FOLLOWUP: +- [a question that connects one of the findings directly to this user's personal background, age, conditions, or lifestyle — make it feel like it was written for them specifically] +- [a question that goes deeper on the most surprising or high-impact finding from this response] +- [a question connecting two findings or asking how a specific gene or pathway interacts with something in their health history or life situation] + +Write questions from the user's perspective — as if the user is asking you. Not "would you like to know X?" but "How does my APOE status interact with my family history of dementia?" Reference the user's actual details where relevant.`; console.log('=== LLM CHAT PROMPT ==='); console.log('System Prompt:', systemPrompt); @@ -631,6 +656,23 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug throw new Error("No response generated from LLM"); } + // Parse and strip the FOLLOWUP block from the response + const followupMatch = accumulatedContent.split(/\n+FOLLOWUP:\n/); + const displayContent = followupMatch[0].trim(); + const followupQuestions = followupMatch[1] + ? followupMatch[1].split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2).trim()).filter(Boolean) + : []; + + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { + ...updated[updated.length - 1], + content: displayContent, + followupQuestions, + }; + return updated; + }); + // Clear attachments after successful send setAttachedFiles([]); @@ -944,15 +986,16 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug )}
)} - {message.role === 'assistant' && isLastAssistantMessage && !isLoading && ( + {message.role === 'assistant' && isLastAssistantMessage && !isLoading && (message.followupQuestions?.length ?? 0) > 0 && (
Try asking:
- {FOLLOWUP_SUGGESTIONS.map((suggestion, sidx) => ( + {message.followupQuestions!.map((suggestion, sidx) => ( diff --git a/app/components/LLMCommentaryModal.tsx b/app/components/LLMCommentaryModal.tsx index 76e7a0c..c57d183 100644 --- a/app/components/LLMCommentaryModal.tsx +++ b/app/components/LLMCommentaryModal.tsx @@ -16,6 +16,7 @@ type LLMCommentaryModalProps = { currentResult: SavedResult; allResults: SavedResult[]; // Deprecated - will use SQL query instead skipPersonalizationPrompt?: boolean; + skipConsent?: boolean; }; const CONSENT_STORAGE_KEY = "nilai_llm_consent_accepted"; @@ -35,6 +36,7 @@ export default function LLMCommentaryModal({ currentResult, allResults, // Deprecated parameter skipPersonalizationPrompt = false, + skipConsent = false, }: LLMCommentaryModalProps) { const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; @@ -63,27 +65,28 @@ export default function LLMCommentaryModal({ }, []); useEffect(() => { - console.log('[LLMCommentaryModal] isOpen changed:', isOpen, 'hasConsent:', hasConsent); if (isOpen) { + if (skipConsent) { + setShowPersonalizationPrompt(false); + setShowConsentModal(false); + fetchCommentary(); + return; + } + if (skipPersonalizationPrompt) { - console.log('[LLMCommentaryModal] Skipping personalization prompt'); setShowPersonalizationPrompt(false); setShowConsentModal(true); return; } - // Check if personalization is not set or locked if (customizationStatus === 'not-set' || customizationStatus === 'locked') { - console.log('[LLMCommentaryModal] Showing personalization prompt'); setShowPersonalizationPrompt(true); } else { - // Always show consent modal first, even if consent was previously given - // This ensures user explicitly triggers the analysis each time - console.log('[LLMCommentaryModal] Showing consent modal'); setShowConsentModal(true); } } - }, [isOpen, customizationStatus, skipPersonalizationPrompt]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, customizationStatus, skipPersonalizationPrompt, skipConsent]); const handleConsentAccept = () => { if (typeof window !== "undefined") { @@ -117,34 +120,26 @@ export default function LLMCommentaryModal({ setLoadingPhase('query'); setDelegationStatus(`Querying ${totalResults.toLocaleString()} results...`); - // Yield to UI to show loading state await new Promise(resolve => setTimeout(resolve, 50)); console.log(`[fetchCommentary] Fetching top relevant results from ${totalResults} total using semantic search (5000 studies from embeddings)...`); const startFilter = Date.now(); - // Use semantic search to find results most relevant to this trait/study - // Query 5000 studies from PostgreSQL to cast a wider net, then filter to top 499 matches - // Use only the trait name (condition) for semantic search, not the full study title setDelegationStatus(`Analyzing semantic relevance (may generate embeddings on first use)...`); const queryText = currentResult.traitName; let topResults = await getTopResultsByRelevance(queryText, 5000, currentResult.gwasId); console.log(`[fetchCommentary] Got ${topResults.length} results from semantic search (queried 5000 studies)`); - // Take only top 499 matches for the LLM prompt topResults = topResults.slice(0, 499); console.log(`[fetchCommentary] Using top ${topResults.length} results for LLM prompt`); - // If we have fewer than 499 results, fill remaining slots with highest risk score results if (topResults.length < 499) { const remaining = 499 - topResults.length; console.log(`[fetchCommentary] Only ${topResults.length} semantically relevant results. Filling ${remaining} slots with high-risk results...`); console.log(`[fetchCommentary] Total savedResults available: ${resultsContext.savedResults.length}`); - // Track existing study IDs to avoid duplicates const existingStudyIds = new Set(topResults.map(r => r.studyId)); - // Debug: Check a sample result structure const sampleResult = resultsContext.savedResults[0]; console.log(`[fetchCommentary] Sample result structure:`, { hasGwasId: !!sampleResult?.gwasId, @@ -153,44 +148,29 @@ export default function LLMCommentaryModal({ keys: sampleResult ? Object.keys(sampleResult) : [] }); - // Step 1: Filter by gwasId const withGwasId = resultsContext.savedResults.filter(r => r.gwasId !== currentResult.gwasId); console.log(`[fetchCommentary] After excluding current gwasId: ${withGwasId.length}`); - // Step 2: Filter by existing study IDs const noDuplicates = withGwasId.filter(r => !existingStudyIds.has(r.studyId)); console.log(`[fetchCommentary] After excluding duplicates: ${noDuplicates.length}`); - // Step 3: Apply quality filters (only if fields exist) const qualityFiltered = noDuplicates.filter(r => { - // If pValue exists, apply threshold (genome-wide significance: < 5e-8) if (r.pValue) { const pValue = parseFloat(r.pValue); - if (!isNaN(pValue) && pValue >= 5e-8) { - return false; - } + if (!isNaN(pValue) && pValue >= 5e-8) return false; } - - // If sampleSize exists, apply threshold (>= 500 participants) if (r.sampleSize) { - // Parse sample size from text like "15,000 European ancestry individuals" const sampleSizeMatch = r.sampleSize.match(/[\d,]+/); if (sampleSizeMatch) { const sampleSize = parseInt(sampleSizeMatch[0].replace(/,/g, '')); - if (!isNaN(sampleSize) && sampleSize < 500) { - return false; - } + if (!isNaN(sampleSize) && sampleSize < 500) return false; } } - - // If neither field exists (old results), include all results return true; }); console.log(`[fetchCommentary] After quality filters: ${qualityFiltered.length}`); - // Step 4: Random sampling to avoid bias toward high-risk results - // Shuffle using Fisher-Yates algorithm const shuffled = [...qualityFiltered]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -204,13 +184,11 @@ export default function LLMCommentaryModal({ console.log(`[fetchCommentary] Added ${randomSample.length} results. Final total: ${topResults.length}`); } - // Add current result at the top const resultsForContext = [currentResult, ...topResults]; const filterTime = Date.now() - startFilter; console.log(`Fetched top 5000 results in ${filterTime}ms using semantic similarity search`); - // Store analysis metadata setAnalysisResultsCount(resultsForContext.length); setAnalysisResults(resultsForContext); setHasCustomization(!!(customization && ( @@ -222,13 +200,8 @@ export default function LLMCommentaryModal({ (customization.familyConditions && customization.familyConditions.length > 0) ))); - // Check console logs to detect if semantic search was actually used - // If we see the fallback warning, semantic search failed - setUsedSemanticSearch(topResults.length > 0); - setDelegationStatus(`✓ Selected ${resultsForContext.length} most relevant results (${filterTime}ms)`); - // Yield to UI after query await new Promise(resolve => setTimeout(resolve, 100)); // Phase 2: Fetch study metadata for quality indicators @@ -837,191 +810,189 @@ Keep your response concise (400-600 words), educational, and reassuring where ap className="modal-dialog commentary-modal" onClick={(e) => e.stopPropagation()} > -
-

🤖 AI Commentary on Your Result

+
+

🤖 AI Commentary on Your Result

-
-

- {getLLMDescription()} -

-
+
+

+ {getLLMDescription()} +

+
-
-

{currentResult.traitName}

-

{currentResult.studyTitle}

-
- - Your genotype: {currentResult.userGenotype} - - - Risk score: {formatRiskScore(currentResult.riskScore, currentResult.riskLevel, currentResult.effectType)} ({currentResult.riskLevel}) - -
+
+

{currentResult.traitName}

+

{currentResult.studyTitle}

+
+ + Your genotype: {currentResult.userGenotype} + + + Risk score: {formatRiskScore(currentResult.riskScore, currentResult.riskLevel, currentResult.effectType)} ({currentResult.riskLevel}) +
+
-
- {isLoading && ( -
-
-

Generating personalized commentary with private AI...

- - {/* Progress indicator */} -
-
-
- {loadingPhase !== 'query' ? '✓' : '○'} - Query Results -
-
- {['token', 'llm', 'done'].includes(loadingPhase) ? '✓' : '○'} - Study Metadata -
-
- {['llm', 'done'].includes(loadingPhase) ? '✓' : '○'} - Secure Token -
-
- {loadingPhase === 'done' ? '✓' : '○'} - LLM Analysis -
+
+ {isLoading && ( +
+
+

Generating personalized commentary with private AI...

+ + {/* Progress indicator */} +
+
+
+ {loadingPhase !== 'query' ? '✓' : '○'} + Query Results
-
- - {delegationStatus && ( -

- {delegationStatus} - {resultsCount > 0 && loadingPhase === 'query' && ( - ({resultsCount.toLocaleString()} total results) - )} -

- )} - {!delegationStatus && ( -

- Your data is processed securely in a Trusted Execution Environment -

- )} -
- )} - - {error && ( -
-

❌ {error}

- -
- )} - - {!isLoading && !error && commentary && ( -
- {studyMetadata && ( - - )} - - {/* Analysis Metadata */} -
-
- 📊 - Results analyzed: - {analysisResultsCount.toLocaleString()} +
+ {['token', 'llm', 'done'].includes(loadingPhase) ? '✓' : '○'} + Study Metadata
-
- 👤 - Personalization: - {hasCustomization ? 'Enabled' : 'Not configured'} +
+ {['llm', 'done'].includes(loadingPhase) ? '✓' : '○'} + Secure Token
-
- 🔍 - - Results selected using semantic relevance matching (check browser console for details) - +
+ {loadingPhase === 'done' ? '✓' : '○'} + LLM Analysis
+
-
-
- 🤖 -

LLM-Generated Interpretation

-
-
+ {delegationStatus && ( +

+ {delegationStatus} + {resultsCount > 0 && loadingPhase === 'query' && ( + ({resultsCount.toLocaleString()} total results) + )} +

+ )} + {!delegationStatus && ( +

+ Your data is processed securely in a Trusted Execution Environment +

+ )} +
+ )} + + {error && ( +
+

❌ {error}

+ +
+ )} + + {!isLoading && !error && commentary && ( +
+ {studyMetadata && ( + + )} + +
+
+ 📊 + Results analyzed: + {analysisResultsCount.toLocaleString()}
+
+ 👤 + Personalization: + {hasCustomization ? 'Enabled' : 'Not configured'} +
+
+ 🔍 + + Results selected using semantic relevance matching (check browser console for details) + +
+
- {/* Collapsible list of studies used in analysis */} - {analysisResults.length > 0 && ( -
- - 📚 - - View all {analysisResults.length} studies used in this analysis - - - -
- {analysisResults.map((result, index) => ( -
-
- {index + 1}. - {result.traitName} - {index === 0 && ( - Current - )} +
+
+ 🤖 +

LLM-Generated Interpretation

+
+
+
+ + {analysisResults.length > 0 && ( +
+ + 📚 + + View all {analysisResults.length} studies used in this analysis + + + +
+ {analysisResults.map((result, index) => ( +
+
+ {index + 1}. + {result.traitName} + {index === 0 && ( + Current + )} +
+
+
+ Study: + {result.studyTitle} +
+
+ Your genotype: + {result.userGenotype}
-
-
- Study: - {result.studyTitle} -
-
- Your genotype: - {result.userGenotype} -
-
- Risk score: - - {formatRiskScore(result.riskScore, result.riskLevel, result.effectType)} ({result.riskLevel}) - -
-
- SNP: - {result.matchedSnp} -
+
+ Risk score: + + {formatRiskScore(result.riskScore, result.riskLevel, result.effectType)} ({result.riskLevel}) + +
+
+ SNP: + {result.matchedSnp}
- ))} -
-
- )} - -
-
⚠️
-
- LLM-Generated Content Limitations -

- This commentary is generated by an LLM model and may not fully account for study - limitations, your specific ancestry, the latest research, or individual medical factors. - It should be used for educational purposes only. Always consult a healthcare professional - or genetic counselor for personalized medical interpretation and advice. -

+
+ ))}
+
+ )} + +
+
⚠️
+
+ LLM-Generated Content Limitations +

+ This commentary is generated by an LLM model and may not fully account for study + limitations, your specific ancestry, the latest research, or individual medical factors. + It should be used for educational purposes only. Always consult a healthcare professional + or genetic counselor for personalized medical interpretation and advice. +

- )} -
+
+ )}
+
-
- {!isLoading && !error && commentary && ( - - )} - -
+ )} + +
); diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index 7e20df0..7ed9f79 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -415,7 +415,14 @@ export default function MenuBar() { className={isDNAChatActive ? "nav-link active" : "nav-link"} style={getNavLinkStyle(isDNAChatActive)} > - DNA Chat + Chat + + + Browse - Overview Report + Analyze Premium diff --git a/app/components/StudyInlineAnalysis.tsx b/app/components/StudyInlineAnalysis.tsx new file mode 100644 index 0000000..a3f377b --- /dev/null +++ b/app/components/StudyInlineAnalysis.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { SavedResult } from "@/lib/results-manager"; +import { useCustomization } from "./CustomizationContext"; +import { callLLM } from "@/lib/llm-client"; +import { trackContinueInDNAChat } from "@/lib/analytics"; + +type Props = { + result: SavedResult; + pubmedId?: string | null; + mappedGene?: string | null; + reportedTrait?: string | null; +}; + +type Suggestions = { + chat: string[]; + browse: string[]; +}; + +function formatRiskScore(score: number, level: string, effectType?: 'OR' | 'beta'): string { + if (level === 'neutral') return effectType === 'beta' ? 'baseline' : '1.0x'; + if (effectType === 'beta') return `β=${score >= 0 ? '+' : ''}${score.toFixed(3)} units`; + return `${score.toFixed(2)}x`; +} + +function markdownToHtml(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^[\*\-] (.+)$/gm, '
  • $1
  • ') + .replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    ') + .split('\n\n') + .map(p => p.trim()) + .filter(p => p.length > 0) + .map(p => (p.startsWith('${p}

    `) + .join(''); +} + +function describeEffect(effectSize: string, effectType?: 'OR' | 'beta', riskLevel?: string): string { + const val = parseFloat(effectSize); + if (isNaN(val)) return ''; + + if (effectType === 'beta') { + const sign = val >= 0 ? '+' : ''; + return `Each risk allele is associated with a ${sign}${val.toFixed(3)} unit change in the trait value (beta coefficient from linear regression).`; + } + + // OR interpretation + if (val === 1) return 'This variant has no effect on odds (OR = 1.0).'; + if (val > 1) { + const pct = ((val - 1) * 100).toFixed(0); + return `Each copy of the risk allele raises the odds by ${pct}% relative to non-carriers (OR = ${val.toFixed(2)}).`; + } + // val < 1 — protective + const pct = ((1 - val) * 100).toFixed(0); + return `Each copy of this allele lowers the odds by ${pct}% relative to non-carriers (OR = ${val.toFixed(2)}).`; +} + +function parseSuggestions(raw: string): { commentary: string; suggestions: Suggestions } { + const idx = raw.indexOf('SUGGESTIONS:'); + if (idx === -1) return { commentary: raw, suggestions: { chat: [], browse: [] } }; + + const commentaryPart = raw.slice(0, idx).trimEnd(); + const jsonPart = raw.slice(idx + 'SUGGESTIONS:'.length).trim(); + + try { + const parsed = JSON.parse(jsonPart); + return { + commentary: commentaryPart, + suggestions: { + chat: Array.isArray(parsed.chat) ? parsed.chat.slice(0, 2) : [], + browse: Array.isArray(parsed.browse) ? parsed.browse.slice(0, 3) : [], + }, + }; + } catch { + return { commentary: raw, suggestions: { chat: [], browse: [] } }; + } +} + +export default function StudyInlineAnalysis({ result, pubmedId, mappedGene, reportedTrait }: Props) { + const { customization } = useCustomization(); + const [commentary, setCommentary] = useState(""); + const [suggestions, setSuggestions] = useState({ chat: [], browse: [] }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [pubmedWarning, setPubmedWarning] = useState(null); + + const analyze = async () => { + setIsLoading(true); + setError(null); + setCommentary(""); + setSuggestions({ chat: [], browse: [] }); + setPubmedWarning(null); + + try { + // Fetch PubMed abstract for richer context + let pubmedContext = ''; + if (pubmedId) { + try { + const pmRes = await fetch(`/api/pubmed-abstract?pmid=${pubmedId}`); + if (pmRes.ok) { + const { abstract } = await pmRes.json(); + if (abstract) { + pubmedContext = `\n\nPUBMED ABSTRACT:\n${abstract}`; + } + } else { + setPubmedWarning("PubMed data unavailable; interpretation based on GWAS metadata only."); + } + } catch { + setPubmedWarning("PubMed data unavailable; interpretation based on GWAS metadata only."); + } + } + + // Fetch study metadata for quality context + let studyQualityContext = ''; + const metaRes = await fetch(`/api/study-metadata?studyId=${result.studyId}`); + if (metaRes.ok) { + const { metadata } = await metaRes.json(); + if (metadata) { + const parseSampleSize = (str: string | null) => { + if (!str) return 0; + const m = str.match(/[\d,]+/); + return m ? parseInt(m[0].replace(/,/g, '')) : 0; + }; + const initialSize = parseSampleSize(metadata.initial_sample_size); + const replicationSize = parseSampleSize(metadata.replication_sample_size); + studyQualityContext = ` + +STUDY QUALITY: +- Sample size: ${initialSize.toLocaleString()} participants${initialSize < 5000 ? ' (small — interpret with caution)' : initialSize < 50000 ? ' (medium)' : ' (large, well-powered)'} +- Ancestry: ${metadata.initial_sample_size || 'not specified'} +- Replication: ${replicationSize > 0 ? `yes (${replicationSize.toLocaleString()} participants)` : 'none reported'} +- P-value: ${metadata.p_value || 'not reported'}`; + } + } + + // Build user background context from customization + let userContext = ''; + if (customization) { + const parts: string[] = []; + if (customization.ethnicities.length > 0) parts.push(`Ethnicities: ${customization.ethnicities.join(', ')}`); + if (customization.countriesOfOrigin.length > 0) parts.push(`Countries of origin: ${customization.countriesOfOrigin.join(', ')}`); + if (customization.genderAtBirth) parts.push(`Gender at birth: ${customization.genderAtBirth}`); + if (customization.age) parts.push(`Age: ${customization.age}`); + if (customization.personalConditions?.length) parts.push(`Personal conditions: ${customization.personalConditions.join(', ')}`); + if (customization.familyConditions?.length) parts.push(`Family conditions: ${customization.familyConditions.join(', ')}`); + if (parts.length > 0) { + userContext = ` + +USER BACKGROUND: +${parts.join('\n')}`; + } + } + + const prompt = `You are a genetic counselor writing a brief interpretation of a GWAS result shown on a study page.${pubmedContext}${studyQualityContext}${userContext} + +RESULT: +Trait (mapped): ${result.traitName}${reportedTrait && reportedTrait !== result.traitName ? `\nTrait (as measured in study): ${reportedTrait}` : ''} +Mapped gene: ${mappedGene || 'not specified'} +Genotype: ${result.userGenotype} +Risk allele: ${result.riskAllele} +Effect size: ${result.effectSize} (${result.effectType === 'beta' ? 'beta coefficient' : 'odds ratio'}) +Effect in plain terms: ${describeEffect(result.effectSize, result.effectType, result.riskLevel)} +Risk score: ${formatRiskScore(result.riskScore, result.riskLevel, result.effectType)} (${result.riskLevel}) +Matched SNP: ${result.matchedSnp} + +Write a plain-language interpretation covering: +1. The trait itself — what is being measured, what it represents biologically, and why scientists study it. Lead with the trait, not the gene. If the measured trait (see "Trait as measured") is a complex ratio, imaging metric, or mass spec measurement, explain what that measurement captures and what it means in the body. Only bring in the mapped gene as supporting context for how it influences this specific trait — do not describe the gene's general fame or its associations with unrelated conditions unless those associations directly explain the trait being studied. +2. How well-established this association is, considering the sample size, replication, and p-value. Note any ancestry or population limitations relevant to the user. +3. What this specific genotype means for the user — at least two sentences. Explain how many copies of the risk allele they carry and calculate the cumulative effect (e.g. 2 copies × per-allele effect). For beta coefficients, use your knowledge of how this trait is measured to express the effect in concrete terms. For odds ratios, use your knowledge of baseline prevalence to give approximate absolute risk figures. Always signal when you are inferring rather than reading from the data. +4. The "so what" — what does this result actually imply for the user's biology, health, or function? Commit to what IS known about this trait, even if incomplete. For example, if higher white matter connectivity is generally associated with better cognitive performance in the literature, say that. If a gene variant's known biology has implications for how the brain or body ages, say that specifically. Do not retreat into pure neutrality ("associated with variations in how the brain processes information" is not useful). Be calibrated — these are common population variants, not diagnoses — but calibrated does not mean saying nothing. State what is currently understood as confidently as the evidence allows, and flag uncertainty clearly where it exists. + +Do not repeat the genotype, effect size, or SNP — the user can already see those on the page. Do not include disclaimers. 250-350 words, direct and informative. + +After the interpretation, on a new line, output follow-up suggestions in this exact format (valid JSON, no markdown): +SUGGESTIONS:{"chat":["question 1 for DNA Chat","question 2 for DNA Chat"],"browse":["related trait 1","related trait 2","related trait 3"]} + +The chat questions should be specific, conversational questions the user might want to ask about their own DNA data. The browse traits should be short trait names (2-4 words) they might want to explore next.`; + + const response = await callLLM([ + { + role: "system", + content: "You are a knowledgeable genetic counselor. Explain GWAS results clearly and concisely. No disclaimers, no repetition of information the user can already see on the page. Avoid vague hedges — say something specific and useful about health implications. Do not use LaTeX or math notation; write numbers in plain text (e.g. 9×10⁻¹⁰ or p=9e-10). Always end your response with the SUGGESTIONS line as instructed.", + }, + { role: "user", content: prompt }, + ], { maxTokens: 1100, temperature: 0.7, reasoningEffort: 'low' }); + + if (!response.content) throw new Error("No commentary generated"); + const { commentary: parsed, suggestions: parsedSuggestions } = parseSuggestions(response.content); + setCommentary(markdownToHtml(parsed)); + setSuggestions(parsedSuggestions); + } catch (err) { + setError(err instanceof Error ? err.message : "Analysis failed"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + analyze(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const hasSuggestions = suggestions.chat.length > 0 || suggestions.browse.length > 0; + + const dnaChatContinueUrl = (() => { + const riskAllele = result.riskAllele.split('-').pop() || result.riskAllele; + const q = `I've just read the AI interpretation of my result for "${result.traitName}" (study: "${result.studyTitle}"). I carry the ${result.userGenotype} genotype at ${result.matchedSnp} — the risk allele is ${riskAllele} and my effect is ${result.riskLevel}. I'd like to discuss this result and ask follow-up questions.`; + return `/dna-chat?q=${encodeURIComponent(q)}`; + })(); + + return ( +
    +
    + 🤖 +

    AI Interpretation

    + Private AI + {!isLoading && (commentary || error) && ( + + )} +
    + + {isLoading && ( +
    +
    +

    Generating interpretation...

    +
    + )} + + {error && ( +
    +

    ❌ {error}

    + +
    + )} + + {pubmedWarning && ( +

    {pubmedWarning}

    + )} + + {!isLoading && !error && commentary && ( +
    + )} + + {!isLoading && hasSuggestions && ( +
    + {suggestions.chat.length > 0 && ( +
    +

    Ask in DNA Chat

    +
    + {suggestions.chat.map((q, i) => ( + + {q} + + ))} +
    +
    + )} + {suggestions.browse.length > 0 && ( +
    +

    Explore related traits

    +
    + {suggestions.browse.map((t, i) => ( + + {t} + + ))} +
    +
    + )} +
    + )} + + {!isLoading && commentary && ( +
    + trackContinueInDNAChat('study_analysis')}> + Continue this conversation in DNA Chat → + +
    + )} +
    + ); +} diff --git a/app/components/StudyPersonalResultBanner.tsx b/app/components/StudyPersonalResultBanner.tsx index 8c1bb11..b9f57df 100644 --- a/app/components/StudyPersonalResultBanner.tsx +++ b/app/components/StudyPersonalResultBanner.tsx @@ -1,12 +1,11 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useLayoutEffect, useMemo } from "react"; import { useGenotype } from "./UserDataUpload"; import { useResults } from "./ResultsContext"; import { hasMatchingSNPs } from "@/lib/snp-utils"; import { analyzeStudyClientSide, UserStudyResult, determineEffectTypeAndSize } from "@/lib/risk-calculator"; import DisclaimerModal from "./DisclaimerModal"; -import LLMCommentaryModal from "./LLMCommentaryModal"; import { SavedResult } from "@/lib/results-manager"; import { trackStudyResultReveal } from "@/lib/analytics"; @@ -102,13 +101,12 @@ export default function StudyPersonalResultBanner({ const [isRevealed, setIsRevealed] = useState(false); const [error, setError] = useState(null); const [showDisclaimer, setShowDisclaimer] = useState(false); - const [showCommentary, setShowCommentary] = useState(false); const savedResult = useMemo(() => { return hasResult(studyId) ? getResult(studyId) : undefined; }, [studyId, resultsVersion, hasResult, getResult]); - useEffect(() => { + useLayoutEffect(() => { if (savedResult) { setResult({ hasMatch: true, @@ -127,24 +125,6 @@ export default function StudyPersonalResultBanner({ } }, [savedResult]); - const currentResultForModal = useMemo(() => { - if (!result?.hasMatch) return null; - return { - studyId, - gwasId: studyAccession || "", - traitName, - studyTitle, - userGenotype: result.userGenotype!, - riskAllele: result.riskAllele!, - effectSize: result.effectSize!, - effectType: result.effectType, - riskScore: result.riskScore!, - riskLevel: result.riskLevel!, - matchedSnp: result.matchedSnp!, - analysisDate: new Date().toISOString(), - }; - }, [result, studyId, studyAccession, traitName, studyTitle]); - const analyzeStudy = async () => { if (!genotypeData || !snps || !riskAllele) return; setIsLoading(true); @@ -202,41 +182,28 @@ export default function StudyPersonalResultBanner({ } return ( - <> - {showCommentary && currentResultForModal && ( - setShowCommentary(false)} - currentResult={currentResultForModal} - allResults={[]} - /> - )} -
    -
    - 🧬 -

    Your Personal Result

    +
    +
    + 🧬 +

    Your Personal Result

    +
    +
    +
    + Your genotype + {result.userGenotype}
    -
    -
    - Your genotype - {result.userGenotype} -
    -
    - Risk score - - {formatRiskScore(result.riskScore!, result.riskLevel!, result.effectType)} - - {result.riskLevel === "increased" ? " ↑" : result.riskLevel === "decreased" ? " ↓" : " →"} - +
    + Risk score + + {formatRiskScore(result.riskScore!, result.riskLevel!, result.effectType)} + + {result.riskLevel === "increased" ? " ↑" : result.riskLevel === "decreased" ? " ↓" : " →"} -
    +
    -

    {generateTooltip(result)}

    -
    - +

    {generateTooltip(result)}

    +
    ); } diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx new file mode 100644 index 0000000..1629ab8 --- /dev/null +++ b/app/components/TopTraitsReportModal.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useResults } from "./ResultsContext"; +import { useCustomization } from "./CustomizationContext"; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { TopTraitsReportResult } from "@/lib/top-traits-report-service"; +import { trackTopTraitsReportGenerated, trackReportOpenedInChat } from "@/lib/analytics"; + +type Phase = 'idle' | 'generating' | 'complete' | 'error'; + +interface TopTraitsReportModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function TopTraitsReportModal({ isOpen, onClose }: TopTraitsReportModalProps) { + const router = useRouter(); + const { savedResults } = useResults(); + const { customization } = useCustomization(); + + const [phase, setPhase] = useState('idle'); + const [message, setMessage] = useState(''); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + const [questions, setQuestions] = useState([]); + const [traitsExpanded, setTraitsExpanded] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + const progressTimerRef = useRef | null>(null); + + useEffect(() => { + if (phase === 'generating') { + progressTimerRef.current = setInterval(() => { + setProgress(p => p < 88 ? p + 1 : p); + }, 450); + } else { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + } + return () => { if (progressTimerRef.current) clearInterval(progressTimerRef.current); }; + }, [phase]); + + const handleGenerate = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setPhase('generating'); + setMessage('Selecting top 50 signals by effect size…'); + setProgress(10); + setError(null); + setResult(null); + + try { + const { generateTopTraitsReport } = await import('@/lib/top-traits-report-service'); + const res = await generateTopTraitsReport(savedResults, customization, (update) => { + setMessage(update.message); + setProgress(update.progress); + }); + setResult(res); + setQuestions(res.questions ?? []); + setProgress(100); + setPhase('complete'); + trackTopTraitsReportGenerated(res.selected.length); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Generation failed.'; + setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); + setPhase('error'); + } finally { + inFlightRef.current = false; + } + }; + + const handleCopy = async () => { + if (!result?.report) return; + try { + await navigator.clipboard.writeText(result.report); + alert('Report copied to clipboard!'); + } catch { + alert('Failed to copy report to clipboard'); + } + }; + + const handlePrint = () => { + if (!result?.report) return; + const win = window.open('', '_blank'); + if (!win) return; + win.document.write(`Top Traits Report +

    Top Traits Report

    +

    Generated ${new Date().toLocaleString()}

    +
    ${result.report.replace(/\n/g, '
    ')}
    +
    Generated by Monadic DNA Explorer • For educational purposes only
    + `); + win.document.close(); + win.print(); + }; + + const handleOpenInChat = (question?: string) => { + if (!result?.report) return; + trackReportOpenedInChat('top_traits', !!question); + localStorage.setItem('health_report_context', result.report); + router.push(question ? `/dna-chat?q=${encodeURIComponent(question)}` : '/dna-chat'); + }; + + const handleClose = () => { + if (phase === 'generating') return; + setPhase('idle'); + setResult(null); + setQuestions([]); + setTraitsExpanded(false); + setError(null); + setProgress(0); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
    +
    e.stopPropagation()}> + + +
    + + {/* Idle */} + {phase === 'idle' && ( +
    +

    Top Traits Report

    +

    + Takes the 100 strongest genetic associations by effect size from your results and synthesizes what they reveal about your biology. No domain filtering or health history anchoring; just your highest-signal variants. +

    +
    + +
    +

    + {savedResults.length.toLocaleString()} saved results available · Top 100 by effect size · ~30 seconds +

    +
    + )} + + {/* Generating */} + {phase === 'generating' && ( +
    +
    +
    +

    + Generating Top Traits Report +

    +

    {message}

    +
    +
    +
    +
    +

    + This may take 20–40 seconds depending on the provider. +

    +
    + )} + + {/* Error */} + {phase === 'error' && ( +
    +

    Generation failed: {error}

    + +
    + )} + + {/* Complete */} + {phase === 'complete' && result && ( +
    +
    + + + + +
    + +
    + Generated: {new Date().toLocaleString()}
    + Based on: top {result.selected.length} associations by effect size from {savedResults.length.toLocaleString()} saved results +
    + +
    + These are population-level associations from GWAS studies, not individual predictions. Effect sizes reflect averages across large cohorts. Do not use this report for medical decisions. +
    + +
    + {result.report} +
    + + {questions.length > 0 && ( +
    +

    + Ask in DNA Chat: +

    +
    + {questions.map((q, i) => ( + + ))} +
    +
    + )} + +
    + + {traitsExpanded && ( +
    + {result.selected.map((r, i) => { + const dir = r.riskLevel === 'increased' ? '↑' : r.riskLevel === 'decreased' ? '↓' : '→'; + const effect = r.effectType === 'OR' + ? `OR ${r.riskScore.toFixed(2)}x` + : `β=${r.riskScore >= 0 ? '+' : ''}${r.riskScore.toFixed(3)}`; + return ( +
    + {dir} + {r.traitName} + {effect} + {r.mappedGene && {r.mappedGene}} +
    + ); + })} +
    + )} +
    + +
    + Generated by Monadic DNA Explorer · For educational purposes only +
    +
    + )} +
    +
    +
    + ); +} 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/dna-chat/page.tsx b/app/dna-chat/page.tsx index ca0c80f..d7d7485 100644 --- a/app/dna-chat/page.tsx +++ b/app/dna-chat/page.tsx @@ -39,11 +39,30 @@ export default function DNAChatPage() { const [tourOpen, setTourOpen] = useState(false); const [sampleLoad, setSampleLoad] = useState(initialSampleLoadState); const sampleLoadStartedRef = useRef(false); + const [initialChatInput, setInitialChatInput] = useState(); useEffect(() => { trackDNAChatViewed(); }, []); + useEffect(() => { + if (typeof window === "undefined") return; + const q = new URLSearchParams(window.location.search).get("q"); + const storedReport = localStorage.getItem('health_report_context'); + if (storedReport) localStorage.removeItem('health_report_context'); + if (q && storedReport) { + setInitialChatInput( + `I just generated a report from my genetic data. Here it is:\n\n${storedReport}\n\n${decodeURIComponent(q)}` + ); + } else if (q) { + setInitialChatInput(decodeURIComponent(q)); + } else if (storedReport) { + setInitialChatInput( + `I just generated a Health Insights Report from my genetic data. Here it is:\n\n${storedReport}\n\nCan you help me understand the key mechanisms and what I should read more about?` + ); + } + }, []); + useEffect(() => { if (typeof window === "undefined" || sampleLoadStartedRef.current) return; @@ -211,7 +230,7 @@ export default function DNAChatPage() { > Show me how to use this - +
    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..3f6ca03 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -1,1193 +1,362 @@ "use client"; -import { useEffect, useMemo, useState, useCallback, useRef, startTransition, memo } from "react"; +import { useState, useMemo, useEffect } from "react"; +import { useRouter } from "next/navigation"; 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: "" }; +import { useResults } from "../components/ResultsContext"; +import { useGenotype } from "../components/UserDataUpload"; +import { useCustomization } from "../components/CustomizationContext"; +import { trackExplorePageViewed } from "@/lib/analytics"; + +const STOP_WORDS = new Set([ + 'and', 'or', 'the', 'of', 'with', 'in', 'to', 'a', 'an', 'for', 'by', 'at', 'on', + 'is', 'it', 'its', 'as', 'my', 'our', 'their', 'has', 'have', 'had', + // Generic medical terms that match too broadly + 'disease', 'disorder', 'syndrome', 'condition', 'related', 'associated', + 'chronic', 'acute', 'primary', 'secondary', 'other', 'type', 'stage', +]); + +function extractKeywords(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 2 && !STOP_WORDS.has(w)); } -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(); +function scoreMatch(keywords: string[], traitName: string): number { + const trait = traitName.toLowerCase(); + return keywords.reduce((sum, kw) => sum + (trait.includes(kw) ? 1 : 0), 0); } -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; +export default function ExplorePage() { + const router = useRouter(); + const { savedResults } = useResults(); + const { isUploaded } = useGenotype(); + const { customization } = useCustomization(); + const [navigating, setNavigating] = useState(false); + const [shownIncreased, setShownIncreased] = useState(5); + + useEffect(() => { trackExplorePageViewed(); }, []); + const [shownDecreased, setShownDecreased] = useState(5); + const [healthCardPages, setHealthCardPages] = useState>({}); + + const INITIAL_SHOW = 5; + const PAGE_SIZE = 5; + const hasResults = savedResults.length > 0; + + const stats = useMemo(() => { + if (!hasResults) return null; + const increased = savedResults.filter(r => r.riskLevel === "increased").length; + const decreased = savedResults.filter(r => r.riskLevel === "decreased").length; + const neutral = savedResults.filter(r => r.riskLevel === "neutral").length; + + // Sort by absolute log(OR) — effect magnitude on a symmetric scale. + // Requires OR > 1.15 or < 0.87 to filter out near-neutral results. + const isValidOR = (r: typeof savedResults[0]) => + r.effectType === "OR" && r.riskScore > 0.05 && r.riskScore < 50; + + const seen = new Set(); + const unique = savedResults.filter(r => { + if (seen.has(r.studyId)) return false; + seen.add(r.studyId); + return true; }); - }, []); - - 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); + const topIncreased = unique + .filter(r => r.riskLevel === "increased" && isValidOR(r) && r.riskScore >= 1.15) + .sort((a, b) => Math.log(b.riskScore) - Math.log(a.riskScore)); - fetch(`/api/studies?${query}`, { signal: controller.signal }) - .then(async (response) => { - const apiDuration = performance.now() - startTime; + const topDecreased = unique + .filter(r => r.riskLevel === "decreased" && isValidOR(r) && r.riskScore <= 0.87) + .sort((a, b) => Math.log(a.riskScore) - Math.log(b.riskScore)); - 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); - } + return { increased, decreased, neutral, topIncreased, topDecreased, unique }; + }, [savedResults, hasResults]); - 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([]); + const healthMatches = useMemo(() => { + if (!hasResults || !customization || !stats) return []; + const conditions = [ + ...(customization.personalConditions ?? []).map(c => ({ label: c, type: 'personal' as const })), + ...(customization.familyConditions ?? []).map(c => ({ label: c, type: 'family' as const })), + ]; + if (conditions.length === 0) return []; + + return conditions + .map(({ label, type }) => { + const keywords = extractKeywords(label); + if (keywords.length === 0) return null; + const matches = stats.unique + .map(r => ({ result: r, score: scoreMatch(keywords, r.traitName) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(({ result }) => result); + if (matches.length === 0) return null; + return { label, type, matches }; }) - .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; + .filter((x): x is NonNullable => x !== null); + }, [hasResults, customization, stats]); + + const handleRandom = () => { + if (!hasResults) return; + setNavigating(true); + const result = savedResults[Math.floor(Math.random() * savedResults.length)]; + router.push(`/study/${result.studyId}`); }; - - 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 && ( - - )} - -
    + + {/* Page header */} +
    +

    Explore Your DNA

    +

    + Discover what your genome says about thousands of traits and conditions — all privately, in your browser. +

    - {!sectionCollapsed && ( -
    -
    -
    - - -
    - - -
    -
    -
    - - - - {traits.map((traitOption) => ( - -
    -
    - - updateFilter("minSampleSize", event.target.value)} - /> + + {hasResults ? ( + <> + {/* Stats bar */} +
    +
    + {savedResults.length.toLocaleString()} + results analyzed
    -
    -
    -
    - - +
    +
    + {stats!.increased} + elevated risk
    -
    - - +
    +
    + {stats!.decreased} + reduced risk
    -
    - updateFilter("excludeMissingGenotype", event.target.checked)} - /> - +
    +
    + {stats!.neutral} + neutral
    - {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 && ( - + ))} + + {shownDecreased < stats!.topDecreased.length && ( + )} - - - - - - - - - - ); - })} - -
    - 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")} + {/* Discovery card */} +
    +
    +
    🎲
    +
    +

    Discover a random result

    +

    + Jump to a study from your analyzed results. Each click surfaces a different genetic association — a great way to stumble on something surprising. +

    +
    +
    + +
    + + {/* Highlights */} + {(stats!.topIncreased.length > 0 || stats!.topDecreased.length > 0) && ( +
    + {stats!.topIncreased.length > 0 && ( +
    +

    Largest elevated effect sizes in your results

    +
    + {stats!.topIncreased.slice(0, shownIncreased).map(r => ( + + {r.traitName} + {r.riskScore.toFixed(2)}x ↑ + + ))} +
    + {shownIncreased < stats!.topIncreased.length && ( + + )} +
    )} -
    - 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"} + {stats!.topDecreased.length > 0 && ( +
    +

    Largest protective effect sizes in your results

    +
    + {stats!.topDecreased.slice(0, shownDecreased).map(r => ( + + {r.traitName} + {r.riskScore.toFixed(2)}x ↓ -
    -
    - {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} +
    + )} +
    + )} + + {/* Health history matches */} + {healthMatches.length > 0 && ( +
    +

    Based on your health history

    +
    + {healthMatches.map(({ label, type, matches }) => { + const page = healthCardPages[label] ?? 0; + const pageSize = 5; + const pageMatches = matches.slice(page * pageSize, (page + 1) * pageSize); + const hasPrev = page > 0; + const hasNext = (page + 1) * pageSize < matches.length; + return ( +
    +
    + {label} + + {type === 'personal' ? 'Your history' : 'Family history'} + +
    +
    + {pageMatches.map(r => ( + + {r.traitName} + + {r.riskLevel === 'increased' ? `${r.riskScore.toFixed(2)}x ↑` + : r.riskLevel === 'decreased' ? `${r.riskScore.toFixed(2)}x ↓` + : '→'} - ))} + + ))} +
    + {(hasPrev || hasNext) && ( +
    + + + {page * pageSize + 1}–{Math.min((page + 1) * pageSize, matches.length)} of {matches.length} + +
    )}
    -
    - -
    -
    + ); + })} +
    +
    + )} - {/* Load More Button */} - {!loading && studies.length > 0 && studies.length < meta.sourceCount && ( -
    -

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

    - -
    + {/* Browse CTA */} +
    +

    Want to search by trait, filter by significance, or run new analyses?

    + Go to Browse → +
    + + ) : ( + <> + {/* Empty state — how it works */} +
    +

    How it works

    +
    +
    +
    {isUploaded ? "✓" : "1"}
    +
    +

    Load your DNA data

    +

    + Upload your raw DNA file from 23andMe, AncestryDNA, or another provider using the My Data button at the top of the page. Your data never leaves your device. +

    + {!isUploaded && ( +

    + Need to download your raw data?{" "} + 23andMe guide + {" · "} + AncestryDNA guide +

    + )} +
    +
    + +
    +
    2
    +
    +

    Run All in Browse

    +

    + Head to Browse and click Run All. The app will match your DNA against thousands of GWAS studies — finding every genetic association in the catalog that overlaps with your variants. +

    +

    This takes about 30–60 seconds depending on your device.

    +
    +
    + +
    +
    3
    +
    +

    Explore your results

    +

    + Come back here to browse your personal results, discover random associations, and get AI-powered interpretations of what each finding means for you specifically. +

    +
    +
    +
    +
    + + {/* What you'll discover */} +
    +

    What you'll discover

    +
    +
    + 🧬 +

    Trait associations

    +

    See which genetic variants you carry and what traits they're linked to — from height and cholesterol to sleep patterns and immune response.

    +
    +
    + 🔬 +

    Study-level detail

    +

    Each result links to the full published study — with sample sizes, p-values, effect sizes, and links to PubMed and the GWAS Catalog.

    +
    +
    + 🤖 +

    Private AI interpretation

    +

    Get a plain-language explanation of what each result means for your biology, with follow-up suggestions tailored to your background.

    +
    +
    +
    + + {/* CTA */} +
    + + Go to Browse and Run All → + +
    + )} -
    - setShowRunAllDisclaimer(false)} - type="initial" - onAccept={handleRunAllDisclaimerAccept} - /> - setShowRunAllModal(false)} - status={runAllStatus} - /> - { setTourOpen(false); openTermsIfNeeded(); }} />
    ); } - -export default function ExplorePageWrapper() { - return ; -} diff --git a/app/globals.css b/app/globals.css index 953d1b2..c267fd1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2162,6 +2162,682 @@ tbody tr:hover { height: 88vh; } +.study-inline-analysis { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.95) 100%); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.sia-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.sia-icon { + font-size: 1.1rem; +} + +.sia-title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: #1f2937; + flex: 1; +} + +.sia-powered-by { + font-size: 0.75rem; + color: #6b7280; + background: rgba(59, 130, 246, 0.07); + border: 1px solid rgba(59, 130, 246, 0.15); + border-radius: 4px; + padding: 0.15rem 0.5rem; +} + +.sia-loading { + display: flex; + align-items: center; + gap: 0.75rem; + color: #6b7280; + font-size: 0.9rem; + padding: 0.5rem 0; +} + +.sia-error { + color: #dc2626; + font-size: 0.9rem; +} + +.sia-body { + font-size: 0.95rem; + line-height: 1.7; + color: #374151; +} + +.sia-body p { + margin: 0.75rem 0; +} + +.sia-body p:first-child { + margin-top: 0; +} + +.sia-body ul { + margin: 0.75rem 0; + padding-left: 1.5rem; +} + +.sia-body li { + margin: 0.4rem 0; +} + +.sia-body strong { + color: #1f2937; + font-weight: 600; +} + +.sia-body h3, .sia-body h4 { + color: #1f2937; + margin: 1rem 0 0.5rem; +} + +.sia-rerun-button { + background: none; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + padding: 0.15rem 0.45rem; + font-size: 0.85rem; + color: #6b7280; + cursor: pointer; + line-height: 1; + margin-left: auto; +} + +.sia-rerun-button:hover { + background: rgba(0, 0, 0, 0.05); + color: #374151; +} + +.sia-warning { + font-size: 0.8rem; + color: #92400e; + background: rgba(251, 191, 36, 0.12); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 6px; + padding: 0.4rem 0.75rem; + margin: 0 0 0.75rem; +} + +.sia-suggestions { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +.sia-suggestion-group { + margin-bottom: 0.75rem; +} + +.sia-suggestion-group:last-child { + margin-bottom: 0; +} + +.sia-suggestion-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9ca3af; + margin: 0 0 0.4rem; +} + +.sia-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.sia-chip { + display: inline-block; + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + text-decoration: none; + line-height: 1.4; + transition: background 0.15s, border-color 0.15s; +} + +.sia-chip--chat { + background: rgba(59, 130, 246, 0.08); + color: #1d4ed8; + border: 1px solid rgba(59, 130, 246, 0.25); +} + +.sia-chip--chat:hover { + background: rgba(59, 130, 246, 0.18); + border-color: rgba(59, 130, 246, 0.4); +} + +.sia-chip--browse { + background: rgba(102, 126, 234, 0.08); + color: #4338ca; + border: 1px solid rgba(102, 126, 234, 0.25); +} + +.sia-chip--browse:hover { + background: rgba(102, 126, 234, 0.18); + border-color: rgba(102, 126, 234, 0.4); +} + +.sia-continue-row { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +.sia-continue-button { + display: inline-block; + padding: 0.5rem 1.1rem; + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + transition: opacity 0.15s; +} + +.sia-continue-button:hover { + opacity: 0.88; +} + +/* ── Explore page ──────────────────────────────────────────── */ + +.explore-page-header { + padding: 2.5rem 0 1.5rem; + border-bottom: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.explore-page-title { + font-size: clamp(1.8rem, 3vw, 2.5rem); + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.03em; + margin: 0 0 0.5rem; +} + +.explore-page-subtitle { + font-size: 1.05rem; + color: #6b7280; + margin: 0; + max-width: 560px; + line-height: 1.6; +} + +/* Stats bar */ +.explore-stats-bar { + display: flex; + align-items: center; + gap: 0; + background: var(--surface-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.25rem 1.75rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.explore-stat { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + min-width: 80px; +} + +.explore-stat-value { + font-size: 1.6rem; + font-weight: 800; + color: var(--text-primary); + line-height: 1; +} + +.explore-stat-value--increased { color: #dc2626; } +.explore-stat-value--decreased { color: #16a34a; } + +.explore-stat-label { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 500; +} + +.explore-stat-divider { + width: 1px; + height: 2rem; + background: var(--border-color); + flex-shrink: 0; +} + +/* Discovery card */ +.explore-discovery-card { + background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%); + border: 1px solid rgba(102,126,234,0.25); + border-radius: 14px; + padding: 1.75rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + flex-wrap: wrap; +} + +.explore-discovery-content { + display: flex; + align-items: flex-start; + gap: 1rem; + flex: 1; +} + +.explore-discovery-icon { + font-size: 2rem; + flex-shrink: 0; + line-height: 1; +} + +.explore-discovery-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 0.35rem; +} + +.explore-discovery-body { + font-size: 0.9rem; + color: #6b7280; + margin: 0; + line-height: 1.5; +} + +.explore-discovery-button { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + border-radius: 10px; + padding: 0.65rem 1.4rem; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(102,126,234,0.35); + transition: opacity 0.15s; + flex-shrink: 0; +} + +.explore-discovery-button:hover:not(:disabled) { opacity: 0.88; } +.explore-discovery-button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Highlights */ +.explore-highlights { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.25rem; + margin-bottom: 1.5rem; +} + +.explore-highlight-group { + background: var(--surface-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.25rem; +} + +.explore-highlight-heading { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ca3af; + margin: 0 0 0.75rem; +} + +.explore-highlight-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.explore-highlight-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + border-radius: 8px; + text-decoration: none; + transition: background 0.15s; + gap: 0.5rem; +} + +.explore-highlight-item--increased { + background: rgba(220,38,38,0.05); + border: 1px solid rgba(220,38,38,0.12); +} + +.explore-highlight-item--increased:hover { background: rgba(220,38,38,0.1); } + +.explore-highlight-item--decreased { + background: rgba(22,163,74,0.05); + border: 1px solid rgba(22,163,74,0.12); +} + +.explore-highlight-item--decreased:hover { background: rgba(22,163,74,0.1); } + +.explore-highlight-trait { + font-size: 0.85rem; + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explore-highlight-score { + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; + flex-shrink: 0; +} + +.explore-highlight-item--increased .explore-highlight-score { color: #dc2626; } +.explore-highlight-item--decreased .explore-highlight-score { color: #16a34a; } + +.explore-highlight-item--neutral { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.explore-highlight-item--neutral:hover { background: rgba(0, 0, 0, 0.06); } +.explore-highlight-item--neutral .explore-highlight-score { color: #6b7280; } + +/* Health history section */ +.explore-health-section { + margin-bottom: 1.5rem; +} + +.explore-health-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.explore-health-card { + background: var(--surface-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.25rem; +} + +.explore-health-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.explore-health-condition { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); +} + +.explore-health-tag { + font-size: 0.68rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; +} + +.explore-health-tag--personal { + background: rgba(102, 126, 234, 0.1); + color: #4338ca; + border: 1px solid rgba(102, 126, 234, 0.25); +} + +.explore-health-tag--family { + background: rgba(245, 158, 11, 0.1); + color: #92400e; + border: 1px solid rgba(245, 158, 11, 0.25); +} + +.explore-health-study-count { + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.08); + border-radius: 10px; + font-size: 0.65rem; + font-weight: 600; + line-height: 1; + min-width: 1.25rem; + padding: 0.15rem 0.35rem; + margin-left: 0.35rem; + vertical-align: middle; +} + +.explore-health-card-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.5rem 0 0.25rem; + border-top: 1px solid rgba(0, 0, 0, 0.06); + margin-top: 0.25rem; +} + +.explore-health-card-nav-btn { + background: none; + border: 1px solid rgba(102, 126, 234, 0.35); + border-radius: 4px; + color: #667eea; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; + padding: 0.25rem 0.6rem; + transition: background 0.15s, opacity 0.15s; +} + +.explore-health-card-nav-btn:hover:not(:disabled) { + background: rgba(102, 126, 234, 0.08); +} + +.explore-health-card-nav-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.explore-health-card-nav-count { + font-size: 0.75rem; + color: #888; + min-width: 6rem; + text-align: center; +} + +.explore-show-more { + background: none; + border: none; + color: #667eea; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 0 0; + display: block; +} + +.explore-show-more:hover { text-decoration: underline; } + +/* Browse CTA */ +.explore-browse-cta { + text-align: center; + padding: 1.25rem; + color: #6b7280; + font-size: 0.9rem; +} + +.explore-browse-link { + color: #667eea; + font-weight: 600; + text-decoration: none; + margin-left: 0.4rem; +} + +.explore-browse-link:hover { text-decoration: underline; } + +/* Empty state */ +.explore-section-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 1.25rem; +} + +.explore-how-it-works { + background: var(--surface-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 1.75rem; + margin-bottom: 1.5rem; +} + +.explore-steps { + display: flex; + flex-direction: column; + gap: 0; +} + +.explore-step { + display: flex; + gap: 1.25rem; + padding: 1rem 0; + border-bottom: 1px solid var(--border-color); +} + +.explore-step:last-child { border-bottom: none; padding-bottom: 0; } +.explore-step:first-child { padding-top: 0; } + +.explore-step-number { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; + margin-top: 0.1rem; +} + +.explore-step--active .explore-step-number { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + box-shadow: 0 2px 6px rgba(102,126,234,0.4); +} + +.explore-step--done .explore-step-number { + background: rgba(22,163,74,0.12); + color: #16a34a; + border: 1.5px solid rgba(22,163,74,0.3); +} + +.explore-step--waiting .explore-step-number { + background: rgba(0,0,0,0.04); + color: #9ca3af; + border: 1.5px solid var(--border-color); +} + +.explore-step-content h3 { + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 0.35rem; +} + +.explore-step-content p { + font-size: 0.88rem; + color: #6b7280; + margin: 0 0 0.35rem; + line-height: 1.55; +} + +.explore-step-content p:last-child { margin-bottom: 0; } + +.explore-step-note { + font-size: 0.8rem !important; + color: #9ca3af !important; +} + +.explore-step-note a { + color: #667eea; + text-decoration: none; +} + +.explore-step-note a:hover { text-decoration: underline; } + +/* Preview cards */ +.explore-preview-cards { + margin-bottom: 2rem; +} + +.explore-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.explore-preview-card { + background: var(--surface-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.25rem; +} + +.explore-preview-icon { + font-size: 1.5rem; + display: block; + margin-bottom: 0.6rem; +} + +.explore-preview-card h3 { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 0.4rem; +} + +.explore-preview-card p { + font-size: 0.83rem; + color: #6b7280; + margin: 0; + line-height: 1.5; +} + +.explore-empty-cta { + text-align: center; + padding: 0.5rem 0 2rem; +} + .commentary-powered-by-header { padding: 0 1.5rem; margin-bottom: 1rem; @@ -11234,3 +11910,187 @@ details[open] .summary-arrow { border-color: rgba(15, 23, 42, 0.35); } +/* Browse page tab toggle */ +.browse-tab-toggle { + display: flex; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.browse-tab-btn { + background: none; + border: 1px solid #d1d5db; + border-radius: 6px; + color: #6b7280; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + padding: 0.3rem 0.9rem; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.browse-tab-btn:hover { + background: #f3f4f6; + color: #374151; +} + +.browse-tab-btn--active { + background: #667eea; + border-color: #667eea; + color: #fff; +} + +.browse-tab-btn--active:hover { + background: #5a6fd6; +} + +/* Browse heatmap */ +.browse-heatmap-section { + padding: 1rem 0 2rem; +} + +.browse-heatmap { + font-size: 0.82rem; +} + +.browse-heatmap-legend { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.25rem; + padding: 0.6rem 0.75rem; + background: #f8f9fa; + border-radius: 6px; + flex-wrap: wrap; +} + +.heatmap-legend-item { + display: flex; + align-items: center; + gap: 0.4rem; + color: #6b7280; + font-size: 0.75rem; +} + +.browse-heatmap-rows { + display: flex; + flex-direction: column; + gap: 2px; +} + +.browse-heatmap-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.5rem; + border-radius: 4px; + transition: background 0.1s; +} + +.browse-heatmap-row:hover { + background: rgba(0, 0, 0, 0.03); +} + +.browse-heatmap-row--increased { + border-left: 3px solid rgba(220, 38, 38, 0.4); +} + +.browse-heatmap-row--decreased { + border-left: 3px solid rgba(22, 163, 74, 0.4); +} + +.browse-heatmap-row--mixed { + border-left: 3px solid rgba(234, 179, 8, 0.5); +} + +.browse-heatmap-row--none { + border-left: 3px solid rgba(209, 213, 219, 0.5); +} + +.browse-heatmap-trait { + flex: 0 0 190px; + color: #1f2937; + font-size: 0.78rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.browse-heatmap-summary { + display: flex; + gap: 4px; + flex: 0 0 auto; + min-width: 70px; +} + +.heatmap-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; + padding: 0.15rem 0.45rem; + border-radius: 10px; + white-space: nowrap; + letter-spacing: 0.01em; +} + +.heatmap-badge--increased { + background: #fef2f2; + color: #b91c1c; + border: 1px solid rgba(220, 38, 38, 0.25); +} + +.heatmap-badge--decreased { + background: #f0fdf4; + color: #15803d; + border: 1px solid rgba(22, 163, 74, 0.25); +} + +.heatmap-badge--none { + background: #f3f4f6; + color: #9ca3af; + border: 1px solid rgba(156, 163, 175, 0.3); +} + +.browse-heatmap-chips { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; + flex: 1; +} + +.heatmap-chip { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 3px; + flex-shrink: 0; + transition: transform 0.1s, box-shadow 0.1s; +} + +a.heatmap-chip:hover { + transform: scale(1.4); + box-shadow: 0 1px 4px rgba(0,0,0,0.2); + z-index: 1; + position: relative; +} + +.heatmap-chip--neutral { + background: rgba(148, 163, 184, 0.5); +} + +.heatmap-chip--none { + background: rgba(229, 231, 235, 0.8); + border: 1px solid rgba(209, 213, 219, 0.6); +} + +.browse-heatmap-note { + margin-top: 1.5rem; + font-size: 0.78rem; + color: #9ca3af; + text-align: center; +} + diff --git a/app/landing-client.tsx b/app/landing-client.tsx index 44f01f1..ba67fb6 100644 --- a/app/landing-client.tsx +++ b/app/landing-client.tsx @@ -1,192 +1,221 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; import { useGenotype } from "./components/UserDataUpload"; -import { trackGetStartedClicked, trackIntroModalShown } from "@/lib/analytics"; - +import { useResults } from "./components/ResultsContext"; +import { useCustomization, type UserCustomization } from "./components/CustomizationContext"; +import { ResultsManager } from "@/lib/results-manager"; +import { trackGetStartedClicked, trackSampleDataStarted, trackSampleDataLoaded, trackSampleDataFailed } from "@/lib/analytics"; + +const SAMPLE_RESULTS_FILE_NAME = "monadic_dna_explorer_results_2026-05-19.tsv"; +const SAMPLE_CUSTOMIZATION_PASSWORD = "sample-data"; + +const SAMPLE_CUSTOMIZATION: UserCustomization = { + ethnicities: ["European"], + countriesOfOrigin: [], + genderAtBirth: "male", + age: 44, + personalConditions: ["Type 2 diabetes", "Hypertension"], + familyConditions: ["Coronary artery disease", "Alzheimer's disease"], + smokingHistory: "past-smoker", + alcoholUse: "mild", + medications: [], + diet: "mediterranean", +}; + +type SampleLoadStatus = "idle" | "downloading" | "loading" | "loaded" | "error"; + +function formatBytes(bytes: number): string { + if (!bytes) return "0 KB"; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} -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 = [ +const featureCopy = [ { - label: "DNA insights", - text: "Monadic DNA Explorer lets you unlock the potential of DNA data to inform diet, lifestyle, and health.", + label: "Explore", + href: "/explore", + text: "Explore your results trait by trait. Browse elevated and protective associations ranked by effect size, and see which traits connect to conditions in your personal and family health history.", }, { - label: "GWAS Catalog", - text: "We use over one million scientifically vetted traits from the GWAS Catalog to help you understand your DNA.", + label: "DNA Chat", + href: "/dna-chat", + text: "Ask questions about your genetic results in plain English. Get explanations of specific traits, genes, and what population studies say about your variants.", }, { - label: "Privacy first", - text: "Your DNA is the most sensitive data you own, so we ensure your data stays private and secure. We do not store, snoop on, or sell your data.", + label: "Browse", + href: "/browse", + text: "Browse studies from the GWAS Catalog with advanced filtering by trait, sample size, and significance. View a heatmap of your SNP matches across selected studies.", + }, + { + label: "Analyze", + href: "/overview-report", + text: "Generate AI-written reports that synthesize patterns across your results, surface hypotheses about your biology, and connect findings to your health history. Premium feature.", }, { - label: "Secure AI", - text: "Using local processing in your browser and AI running in Trusted Execution Environments, we maximize your anonymity and privacy.", + label: "Privacy first", + href: null, + text: "Your DNA stays in your browser. We do not store, transmit, or sell your raw genetic data. AI runs in Trusted Execution Environments for maximum anonymity.", }, ]; -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(); + const { addResultsBatch, clearResults, savedResults } = useResults(); + const { saveCustomization, status: customizationStatus } = useCustomization(); + const [sampleStatus, setSampleStatus] = useState("idle"); + const [sampleError, setSampleError] = useState(null); + const [sampleBytes, setSampleBytes] = useState(0); + const [sampleTotalBytes, setSampleTotalBytes] = useState(0); + + const loadSampleData = async () => { + trackSampleDataStarted('home'); + + if (savedResults.length > 0) { + router.push("/explore"); + return; } - if (forceOpen) { - const url = new URL(window.location.href); - url.searchParams.delete("onboarding"); - window.history.replaceState({}, "", url.toString()); + try { + setSampleStatus("downloading"); + setSampleError(null); + setSampleBytes(0); + setSampleTotalBytes(0); + + const response = await fetch("/api/sample-results", { method: "GET" }); + if (!response.ok) throw new Error(`Download failed (${response.status})`); + + const total = Number(response.headers.get("content-length") || "0"); + setSampleTotalBytes(total); + + const decoder = new TextDecoder(); + let content = ""; + let downloaded = 0; + + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + downloaded += value.byteLength; + content += decoder.decode(value, { stream: true }); + setSampleBytes(downloaded); + } + } + content += decoder.decode(); + } else { + content = await response.text(); + downloaded = new Blob([content]).size; + setSampleBytes(downloaded); + } + + setSampleStatus("loading"); + + const session = ResultsManager.parseResultsFile(content, SAMPLE_RESULTS_FILE_NAME); + if (!session.results.length) throw new Error("Sample file contained no usable results."); + + await clearResults(); + await addResultsBatch(session.results); + + if (customizationStatus === "not-set") { + await saveCustomization(SAMPLE_CUSTOMIZATION, SAMPLE_CUSTOMIZATION_PASSWORD); + } + + trackSampleDataLoaded('home', downloaded, session.results.length); + setSampleStatus("loaded"); + router.push("/explore"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Could not load sample data."; + trackSampleDataFailed('home', msg); + setSampleStatus("error"); + setSampleError(msg); } - }, [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); - }; - }, []); + }; + + const sampleLabel = + sampleStatus === "downloading" ? "Downloading…" : + sampleStatus === "loading" ? "Parsing…" : + sampleStatus === "loaded" ? "Loaded" : + "Try with sample data"; + + const sampleProgressText = + sampleStatus === "downloading" && sampleBytes > 0 + ? sampleTotalBytes > 0 + ? `${formatBytes(sampleBytes)} / ${formatBytes(sampleTotalBytes)}` + : `${formatBytes(sampleBytes)} downloaded` + : sampleStatus === "loading" + ? "Parsing results…" + : null; return ( - <> - { - completeWelcomeChoice(); - trackGetStartedClicked("try_dna_chat_directly"); - router.push("/dna-chat?sample=1"); - }} - /> - -
    -
    -
    -

    Understand your DNA without giving it away.

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

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

    - ))} -
    - - {error &&

    {error}

    } +
    +
    +
    +

    Understand your DNA without giving it away.

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

    + + {item.href ? ( + + {item.label} + + ) : ( + item.label + )} + + {item.text} +

    + ))}
    - -
    -
    - + {sampleProgressText && ( +

    + {sampleProgressText} +

    + )} + {sampleError && ( +

    {sampleError}

    + )} +
    + +

    + New to the app?{' '} + trackGetStartedClicked("schedule_video_call")} + > + Book a free help call. + +

    +
    + + ); } diff --git a/app/overview-report/layout.tsx b/app/overview-report/layout.tsx index 3bad550..8eb35f7 100644 --- a/app/overview-report/layout.tsx +++ b/app/overview-report/layout.tsx @@ -1,14 +1,14 @@ import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Overview Report - Monadic DNA Explorer", + title: "Analyze - Monadic DNA Explorer", description: "Generate an AI-powered report synthesizing your saved genetic results into patterns, themes, and suggested next steps.", keywords: ["DNA overview report", "genetic analysis report", "AI genetics", "personal genomics report"], alternates: { canonical: "https://explorer.monadicdna.com/overview-report", }, openGraph: { - title: "Overview Report - Monadic DNA Explorer", + title: "Analyze - Monadic DNA Explorer", description: "Generate an AI-powered report synthesizing your saved genetic results into patterns, themes, and suggested next steps.", type: "website", url: "https://explorer.monadicdna.com/overview-report", @@ -17,7 +17,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "Overview Report - Monadic DNA Explorer", + title: "Analyze - Monadic DNA Explorer", description: "Generate an AI-powered report synthesizing your saved genetic results into patterns, themes, and suggested next steps.", creator: "@MonadicDNA", }, diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx index 5f28e97..0523073 100644 --- a/app/overview-report/page.tsx +++ b/app/overview-report/page.tsx @@ -7,6 +7,9 @@ import Footer from "../components/Footer"; import PremiumFeatureHeader from "../components/PremiumFeatureHeader"; import { PremiumPaywall } from "../components/PremiumPaywall"; import OverviewReportModal from "../components/OverviewReportModal"; +import HealthReportModal from "../components/HealthReportModal"; +import HealthspanReportModal from "../components/HealthspanReportModal"; +import TopTraitsReportModal from "../components/TopTraitsReportModal"; import { OverviewReportIcon } from "../components/Icons"; import { useAuth } from "../components/AuthProvider"; import { useResults } from "../components/ResultsContext"; @@ -20,6 +23,9 @@ export default function OverviewReportPage() { const { savedResults } = useResults(); const { isAuthenticated, hasActiveSubscription, openAuthModal } = useAuth(); const [showOverviewReportModal, setShowOverviewReportModal] = useState(false); + const [showHealthReportModal, setShowHealthReportModal] = useState(false); + const [showHealthspanReportModal, setShowHealthspanReportModal] = useState(false); + const [showTopTraitsReportModal, setShowTopTraitsReportModal] = useState(false); const [hasPromoAccess, setHasPromoAccess] = useState(false); const [tourOpen, setTourOpen] = useState(false); @@ -46,24 +52,35 @@ export default function OverviewReportPage() { const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; const hasResults = savedResults.length > 0; - const handleGenerateReport = () => { - if (!hasResults) { - return; - } - + const requirePremium = () => { if (!hasPremiumAccess && !hasValidPromoAccess()) { - if (!isAuthenticated) { - openAuthModal(); - return; - } - + if (!isAuthenticated) { openAuthModal(); return false; } router.push('/subscribe'); - return; + return false; } + return true; + }; + const handleGenerateReport = () => { + if (!hasResults || !requirePremium()) return; setShowOverviewReportModal(true); }; + const handleGenerateHealthReport = () => { + if (!hasResults || !requirePremium()) return; + setShowHealthReportModal(true); + }; + + const handleGenerateHealthspanReport = () => { + if (!hasResults || !requirePremium()) return; + setShowHealthspanReportModal(true); + }; + + const handleGenerateTopTraitsReport = () => { + if (!hasResults || !requirePremium()) return; + setShowTopTraitsReportModal(true); + }; + return (
    @@ -85,39 +102,109 @@ export default function OverviewReportPage() {

    Overview Report

    Premium - Experimental

    Turn your saved analysis results into a concise AI-generated report covering patterns, themes, and suggested next steps.

    -

    - This feature is experimental and is currently under development. -

    + {/* Health Insights Report */}
    +
    + 🧬 +
    +
    +

    + Health Insights Report + New +

    +

    + Anchors to your personal and family health history. Selects the most relevant genetic associations and identifies the biological mechanisms that may be affecting your health. Add conditions in Personalization for best results. +

    +
    +
    + +
    +
    + + {/* Healthspan Report */} +
    +
    + 📊 +
    +
    +

    + Healthspan Report + New +

    +

    + Organizes your associations by healthspan domain: cardiovascular, metabolic, neurological, immune, musculoskeletal, and cancer susceptibility. Synthesizes patterns within and across domains. +

    +
    +
    + +
    +
    + + {/* Top Traits Report */} +
    +
    + 🏆 +
    +
    +

    + Top Traits Report + New +

    +

    + Takes your 100 strongest genetic associations by effect size and synthesizes what they reveal about your biology. Good starting point if you have not added health history yet. +

    +
    +
    + +
    +
    + + {/* Comprehensive Overview Report (experimental, at bottom) */} +
    -

    Generate your report

    +

    + Comprehensive Overview Report + Experimental +

    - The report works best after you have run broad analysis. Use - Run All from the Menu Bar or load a saved results file first. + Analyzes all your saved genetic results across categories: health, lifestyle, appearance, personality, and more. Works best after running broad analysis. Currently under development.

    -
    - {savedResults.length.toLocaleString()} saved results - {hasPremiumAccess ? "Premium access active" : "Premium required"} -
    @@ -130,6 +217,18 @@ export default function OverviewReportPage() { isOpen={showOverviewReportModal} onClose={() => setShowOverviewReportModal(false)} /> + setShowHealthReportModal(false)} + /> + setShowHealthspanReportModal(false)} + /> + setShowTopTraitsReportModal(false)} + /> setTourOpen(false)} />
    ); diff --git a/app/page.tsx b/app/page.tsx index 9f3ebc2..bd088dd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -51,7 +51,7 @@ const websiteJsonLd = { "@type": "SearchAction", "target": { "@type": "EntryPoint", - "urlTemplate": "https://explorer.monadicdna.com/explore?q={search_term_string}", + "urlTemplate": "https://explorer.monadicdna.com/browse?q={search_term_string}", }, "query-input": "required name=search_term_string", }, diff --git a/app/sitemap.ts b/app/sitemap.ts index 71abda4..44dd6ee 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -5,7 +5,8 @@ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://explorer.monadicdn export default function sitemap(): MetadataRoute.Sitemap { return [ { url: SITE_URL, lastModified: new Date(), changeFrequency: 'weekly', priority: 1.0 }, - { url: `${SITE_URL}/explore`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 }, + { url: `${SITE_URL}/browse`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 }, + { url: `${SITE_URL}/explore`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.7 }, { url: `${SITE_URL}/dna-chat`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 }, { url: `${SITE_URL}/overview-report`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 }, { url: `${SITE_URL}/subscribe`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.7 }, diff --git a/app/study/[id]/page.tsx b/app/study/[id]/page.tsx index 9878869..d9264df 100644 --- a/app/study/[id]/page.tsx +++ b/app/study/[id]/page.tsx @@ -1,12 +1,14 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import MenuBar from "../../components/MenuBar"; import Footer from "../../components/Footer"; import VariantChips from "../../components/VariantChips"; import StudyPersonalResultBanner from "../../components/StudyPersonalResultBanner"; +import { useResults } from "../../components/ResultsContext"; +import StudyInlineAnalysis from "../../components/StudyInlineAnalysis"; type Study = { id: number; @@ -45,10 +47,32 @@ type Study = { export default function StudyDetailPage() { const params = useParams(); + const router = useRouter(); const studyId = params.id as string; const [study, setStudy] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { savedResults, hasResult, getResult } = useResults(); + const [totalStudies, setTotalStudies] = useState(null); + const [navigating, setNavigating] = useState(false); + + useEffect(() => { + if (savedResults.length > 0) return; + fetch("/api/studies?limit=1") + .then(r => r.json()) + .then(data => { if (data.total) setTotalStudies(data.total); }) + .catch(() => {}); + }, [savedResults.length]); + + const handleNextRandom = () => { + setNavigating(true); + if (savedResults.length > 0) { + const result = savedResults[Math.floor(Math.random() * savedResults.length)]; + router.push(`/study/${result.studyId}`); + } else if (totalStudies !== null) { + router.push(`/study/${Math.floor(Math.random() * totalStudies) + 1}`); + } + }; useEffect(() => { const fetchStudy = async () => { @@ -57,7 +81,7 @@ export default function StudyDetailPage() { setError(null); // Fetch study by ID from the API - const response = await fetch(`/api/studies?limit=1&offset=${parseInt(studyId) - 1}`); + const response = await fetch(`/api/studies?id=${parseInt(studyId)}`); if (!response.ok) { throw new Error('Failed to fetch study details'); @@ -107,7 +131,7 @@ export default function StudyDetailPage() {

    Study Not Found

    {error || 'The requested study could not be found.'}

    - - ← Back to Explore + ← Back to Browse
    @@ -165,28 +189,58 @@ export default function StudyDetailPage() { return "Very subtle effect"; }; + const navButtons = ( +
    + + ← Back to Browse + + +
    + ); + return ( <>
    - {/* Breadcrumb */} -
    - Home - {" > "} - Explore - {" > "} - Study {study.id} + {/* Breadcrumb + top nav */} +
    + + Home + {" > "} + Browse + {" > "} + Study {study.id} + + {navButtons}
    {/* Study Header */}
    +
    + + {trait} + +

    {study.study || "Untitled Study"}

    - {reportedTrait && Reported trait: {reportedTrait}} - {mappedTrait && mappedTrait !== reportedTrait && Mapped trait: {mappedTrait}} - {!reportedTrait && !mappedTrait && Trait: Unknown trait} + {reportedTrait && mappedTrait && mappedTrait !== reportedTrait && Reported trait: {reportedTrait}} {study.first_author && Author: {study.first_author}} {study.date && Date: {new Date(study.date).toLocaleDateString()}} {study.journal && Journal: {study.journal}} @@ -224,6 +278,16 @@ export default function StudyDetailPage() { nonAnalyzableReason={study.nonAnalyzableReason} /> + {/* Inline LLM analysis when a saved result exists for this study */} + {hasResult(study.id) && getResult(study.id) && ( + + )} + {/* Study Details */}
    {/* Stat grid */} @@ -317,18 +381,9 @@ export default function StudyDetailPage() { )}
    - {/* Back Button */} + {/* Bottom nav */}
    - - ← Back to Explore - + {navButtons}