From 384778924b71ca93ef3bd2c3f997956fb8ddfbdd Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 26 Jun 2026 16:30:38 -0400 Subject: [PATCH] Usability Overhaul --- .gitignore | 1 + app/api/stripe/create-checkout/route.ts | 27 +- app/components/LLMChatInline.tsx | 16 +- app/components/MenuBar.tsx | 189 ++++--- app/components/ResultsContext.tsx | 15 + app/components/StripeSubscriptionForm.tsx | 80 ++- app/components/StudyPersonalResultBanner.tsx | 61 +- app/components/StudyPersonalSection.tsx | 12 + app/components/UserDataUpload.tsx | 74 ++- app/explore/page.tsx | 160 +++++- app/globals.css | 565 ++++++++++++++++++- app/landing-client.tsx | 249 ++++++-- app/overview-report/page.tsx | 7 +- app/page.tsx | 16 +- app/raw-dna-guide/page.tsx | 77 +++ app/study/[id]/page.tsx | 4 + lib/analytics.ts | 100 +++- lib/evidence-labels.ts | 76 +++ lib/preview-insights.ts | 247 ++++++++ lib/run-all-onboarding.ts | 26 +- next-env.d.ts | 2 +- 21 files changed, 1821 insertions(+), 183 deletions(-) create mode 100644 app/raw-dna-guide/page.tsx create mode 100644 lib/evidence-labels.ts create mode 100644 lib/preview-insights.ts diff --git a/.gitignore b/.gitignore index c62e0c1..b5847ff 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ yarn-error.log* /AGENTS.md /CLAUDE.md /scripts/ga-performance.env +/.junie/ diff --git a/app/api/stripe/create-checkout/route.ts b/app/api/stripe/create-checkout/route.ts index 02655f6..a260df6 100644 --- a/app/api/stripe/create-checkout/route.ts +++ b/app/api/stripe/create-checkout/route.ts @@ -5,6 +5,24 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder' apiVersion: '2025-02-24.acacia', }); +async function getOrCreateCustomer(walletAddress: string) { + const normalizedWallet = walletAddress.toLowerCase(); + const existingCustomers = await stripe.customers.search({ + query: `metadata['walletAddress']:'${normalizedWallet}'`, + limit: 1, + }); + + if (existingCustomers.data[0]) { + return existingCustomers.data[0]; + } + + return stripe.customers.create({ + metadata: { + walletAddress: normalizedWallet, + }, + }); +} + export async function POST(request: NextRequest) { try { const { walletAddress, couponCode } = await request.json(); @@ -40,8 +58,10 @@ export async function POST(request: NextRequest) { ); } - // Get the base URL for redirect - const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + const requestOrigin = request.headers.get('origin'); + const forwardedProto = request.headers.get('x-forwarded-proto') || 'http'; + const forwardedHost = request.headers.get('x-forwarded-host') || request.headers.get('host'); + const origin = requestOrigin || process.env.NEXT_PUBLIC_APP_URL || (forwardedHost ? `${forwardedProto}://${forwardedHost}` : 'http://localhost:3001'); // Validate promotion code if provided let discounts = undefined; @@ -86,10 +106,13 @@ export async function POST(request: NextRequest) { } } + const customer = await getOrCreateCustomer(walletAddress); + // Create Stripe Checkout Session for subscription const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], mode: 'subscription', // Recurring subscription + customer: customer.id, line_items: [ { price: process.env.STRIPE_PRICE_ID, // Reference existing price from Stripe Dashboard diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index a070f55..870674f 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -6,6 +6,7 @@ import { SavedResult } from "@/lib/results-manager"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; import { useAuth } from "./AuthProvider"; +import { useGenotype } from "./UserDataUpload"; import { hasValidPromoAccess } from "@/lib/promo-access"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -57,6 +58,7 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } const router = useRouter(); const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; + const { isUploaded } = useGenotype(); const { customization, status: customizationStatus } = useCustomization(); const { isAuthenticated, hasActiveSubscription, openAuthModal } = useAuth(); const [hasPromoAccess, setHasPromoAccess] = useState(false); @@ -352,7 +354,12 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } setAttachmentError(null); // Track LLM question - trackLLMQuestionAsked({ isFollowUp: messages.length > 0 }); + trackLLMQuestionAsked({ + isFollowUp: messages.length > 0, + hasUploadedDna: isUploaded, + resultCount: resultsContext.savedResults.length, + source: 'chat', + }); // Process attachments if any let processedAttachments: Attachment[] = []; @@ -701,7 +708,12 @@ Write questions from the user's perspective — as if the user is asking you. No setError(null); setAttachmentError(null); - trackLLMQuestionAsked({ isFollowUp: messages.length > 0 }); + trackLLMQuestionAsked({ + isFollowUp: messages.length > 0, + hasUploadedDna: isUploaded, + resultCount: resultsContext.savedResults.length, + source: 'research', + }); const userMessage: Message = { role: 'user', content: query, timestamp: new Date() }; const assistantMessage: Message = { role: 'assistant', content: '', timestamp: new Date() }; diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index c754cab..e63ba5b 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, type CSSProperties } from "react"; +import { useCallback, useState, useEffect, type CSSProperties } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import UserDataUpload, { useGenotype } from "./UserDataUpload"; @@ -138,12 +138,15 @@ export default function MenuBar() { }, [theme]); useEffect(() => { - const handleOpenDNAUpload = () => { + const handleOpenDNAUpload = (event: Event) => { setShowMyDataDropdown(true); + const source = event instanceof CustomEvent && typeof event.detail?.source === "string" + ? event.detail.source + : "home_upload_raw_dna"; // Wait for the dropdown and uploader to mount, then open the file picker. setTimeout(() => { - window.dispatchEvent(new CustomEvent('openDNAUploadPicker')); + window.dispatchEvent(new CustomEvent('openDNAUploadPicker', { detail: { source } })); }, 60); }; @@ -154,6 +157,14 @@ export default function MenuBar() { }; }, []); + useEffect(() => { + const handleOpenPersonalization = () => setShowCustomizationModal(true); + window.addEventListener("openPersonalization", handleOpenPersonalization); + return () => { + window.removeEventListener("openPersonalization", handleOpenPersonalization); + }; + }, []); + const toggleTheme = () => { setTheme((prev) => (prev === "dark" ? "light" : "dark")); }; @@ -171,7 +182,7 @@ export default function MenuBar() { } }; - const handleRunAll = () => { + const handleRunAll = useCallback(() => { window.dispatchEvent(new Event("showMobileCompatibilityNotice")); if (isRunningAll) { @@ -187,7 +198,7 @@ export default function MenuBar() { } setShowRunAllDisclaimer(true); - }; + }, [genotypeData, isRunningAll]); const handleRunAllDisclaimerAccept = async () => { setShowRunAllDisclaimer(false); @@ -317,6 +328,18 @@ export default function MenuBar() { const isDNAChatActive = pathname === "/dna-chat" || pathname === "/llm-chat"; const isOverviewReportActive = pathname === "/overview-report"; + const isPublicEntryPage = pathname === "/" || pathname === "/raw-dna-guide"; + const hasUserWork = isUploaded || savedResults.length > 0; + const showAdvancedControls = !isPublicEntryPage || hasUserWork; + const showRunAllControl = !isPublicEntryPage || isUploaded || savedResults.length > 0; + + useEffect(() => { + const handleStartRunAll = () => handleRunAll(); + window.addEventListener("startRunAllAnalysis", handleStartRunAll); + return () => { + window.removeEventListener("startRunAllAnalysis", handleStartRunAll); + }; + }, [handleRunAll]); return ( <> @@ -424,13 +447,15 @@ export default function MenuBar() { > Browse - - Analyze - + {showAdvancedControls && ( + + Analyze + + )} @@ -452,75 +477,85 @@ export default function MenuBar() { )} - + {showAdvancedControls && ( + + )} - + {showRunAllControl && ( + + )} - + {showAdvancedControls && ( + + )} - + {showAdvancedControls && ( + + )} - + {showAdvancedControls && ( + + )} + trackExploreFollowupClicked("ask_dna_chat")} + > + Ask what matters first + + + trackExploreFollowupClicked("browse")} + > + Browse all studies + + + +
+ {previewInsight.standout && ( + + Standout match + {previewInsight.standout.traitName} + {getPreviewCategory(previewInsight.standout).label}, {formatPreviewEffect(previewInsight.standout)} + + )} +
+ Main themes +
+ {previewInsight.themes.map((theme) => ( + {theme.label} {theme.count} + ))} +
+
+ trackExploreFollowupClicked("preview_best_question")} + > + Best next question + Which of these findings should I read first, and what caveats matter? + +
+ + {previewStarterResults.length > 0 && ( +
+

Start with these preview matches

+
+ {previewStarterResults.map((result) => ( + + {result.traitName} + + {formatPreviewEffect(result, true)} + + + ))} +
+
+ )} + + )} + {/* Discovery card */}
@@ -311,6 +444,27 @@ export default function ExplorePage() { ) : ( <> + {privateSessionWasCleared && ( +
+
+ Private session cleared +

Your previous results were kept in memory and cleared by the page reload.

+

+ This protects your DNA data from being stored automatically. Reload a saved results file, or go back to the home page and run the quick preview again. +

+ {loadResultsError &&

{loadResultsError}

} +
+
+ + + Upload again + +
+
+ )} + {/* Empty state — how it works */}

How it works

diff --git a/app/globals.css b/app/globals.css index 4f28451..bcc696c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1888,6 +1888,23 @@ tbody tr:hover { opacity: 0.8; } +.provider-guide-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.provider-guide-row a { + border: 1px solid rgba(59, 130, 246, 0.18); + border-radius: 999px; + padding: 0.25rem 0.55rem; + background: rgba(59, 130, 246, 0.06); + color: var(--accent-blue); + font-size: 0.72rem; + font-weight: 700; + text-decoration: none; +} + .sample-data-section { display: flex; flex-direction: column; @@ -1984,18 +2001,45 @@ tbody tr:hover { .genotype-status { display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; background: rgba(16, 185, 129, 0.05); border: 1px solid rgba(16, 185, 129, 0.1); border-radius: 6px; } +.genotype-status-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.2rem; +} + .genotype-indicator { font-size: 0.85rem; color: var(--accent-green); - font-weight: 500; + font-weight: 800; +} + +.genotype-status-detail, +.genotype-status-file, +.genotype-status-privacy { + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.35; +} + +.genotype-status-file { + overflow: hidden; + max-width: 18rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.genotype-status-privacy { + color: var(--accent-green); } .genotype-clear { @@ -2458,6 +2502,236 @@ tbody tr:hover { flex-shrink: 0; } +.explore-next-steps { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 1.25rem; + margin-bottom: 1.5rem; + padding: 1.5rem; + border: 1px solid rgba(37, 99, 235, 0.22); + border-left: 4px solid var(--accent-blue); + border-radius: 12px; + background: + linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(16, 185, 129, 0.05)), + var(--surface-bg); + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06); +} + +.explore-next-steps-copy { + display: flex; + flex-direction: column; + gap: 0.7rem; + min-width: 0; +} + +.explore-next-steps-eyebrow { + width: fit-content; + border: 1px solid rgba(37, 99, 235, 0.24); + border-radius: 999px; + padding: 0.25rem 0.6rem; + background: rgba(37, 99, 235, 0.08); + color: var(--accent-blue); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.explore-next-steps h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.35rem; + line-height: 1.2; +} + +.explore-next-steps p { + max-width: 68ch; + margin: 0; + color: var(--text-secondary); + font-size: 0.95rem; + line-height: 1.65; +} + +.explore-next-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + align-self: start; +} + +.explore-next-primary, +.explore-next-secondary { + display: inline-flex; + min-height: 2.55rem; + align-items: center; + justify-content: center; + border-radius: 8px; + padding: 0.65rem 0.9rem; + font-size: 0.88rem; + font-weight: 800; + text-decoration: none; + cursor: pointer; + transition: transform 0.15s, opacity 0.15s, border-color 0.15s, background 0.15s; +} + +.explore-next-primary { + border: 1px solid transparent; + background: linear-gradient(135deg, #2563eb, #0f766e); + color: #ffffff; + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.2); +} + +.explore-next-secondary { + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(255, 255, 255, 0.66); + color: var(--text-primary); +} + +.explore-next-primary:hover, +.explore-next-secondary:hover { + transform: translateY(-1px); + opacity: 0.9; +} + +.explore-preview-guide { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + padding-top: 0.25rem; +} + +.explore-preview-guide > * { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.8rem; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.48); + text-decoration: none; +} + +.explore-preview-guide strong, +.explore-preview-results-panel h3 { + color: var(--text-primary); + font-size: 0.82rem; +} + +.explore-preview-guide span { + color: var(--text-secondary); + font-size: 0.8rem; + line-height: 1.45; +} + +.explore-preview-insight-card--link { + transition: transform 0.15s, border-color 0.15s, background 0.15s; +} + +.explore-preview-insight-card--link:hover { + transform: translateY(-1px); + border-color: rgba(37, 99, 235, 0.35); + background: rgba(255, 255, 255, 0.72); +} + +.explore-preview-insight-title { + color: var(--text-primary) !important; + font-weight: 800; +} + +.explore-preview-insight-card small { + color: var(--text-muted); + font-size: 0.76rem; + line-height: 1.35; +} + +.explore-theme-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.1rem; +} + +.explore-theme-list span { + display: inline-flex; + align-items: center; + gap: 0.25rem; + width: fit-content; + border: 1px solid rgba(37, 99, 235, 0.18); + border-radius: 999px; + padding: 0.2rem 0.5rem; + background: rgba(37, 99, 235, 0.06); + color: var(--text-primary); + font-weight: 650; +} + +.explore-theme-list b { + color: var(--accent-blue); +} + +.explore-preview-results-panel { + grid-column: 1 / -1; + display: grid; + gap: 0.7rem; + padding-top: 0.25rem; +} + +.explore-preview-results-panel h3 { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.explore-preview-result-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +:root[data-theme="dark"] .explore-next-secondary, +:root[data-theme="dark"] .explore-preview-guide > * { + background: rgba(15, 23, 42, 0.58); +} + +.explore-session-recovery { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.35fr); + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1.2rem; + border: 1px solid rgba(245, 158, 11, 0.3); + border-left: 4px solid #f59e0b; + border-radius: 10px; + background: rgba(245, 158, 11, 0.07); +} + +.explore-session-recovery h2 { + margin: 0.6rem 0 0.4rem; + color: var(--text-primary); + font-size: 1.2rem; + line-height: 1.25; +} + +.explore-session-recovery p { + max-width: 68ch; + margin: 0; + color: var(--text-secondary); + font-size: 0.92rem; + line-height: 1.55; +} + +.explore-session-recovery-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + align-self: center; +} + +.explore-session-recovery-error { + margin-top: 0.6rem !important; + color: var(--error) !important; +} + /* Discovery card */ .explore-discovery-card { background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%); @@ -3656,8 +3930,12 @@ details[open] .summary-arrow { .page-nav .nav-link { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; text-align: center; flex-shrink: 1; + white-space: nowrap; } .page-nav::-webkit-scrollbar { @@ -3742,6 +4020,24 @@ details[open] .summary-arrow { white-space: normal; } + .explore-next-steps { + grid-template-columns: 1fr; + padding: 1.1rem; + } + + .explore-session-recovery { + grid-template-columns: 1fr; + } + + .explore-next-actions { + width: 100%; + } + + .explore-preview-guide, + .explore-preview-result-list { + grid-template-columns: 1fr; + } + .status-section { flex-direction: column; gap: 0.5rem; @@ -9617,6 +9913,7 @@ details[open] .summary-arrow { flex-direction: column; justify-content: center; gap: 1.6rem; + min-width: 0; } .landing-home-copy h1 { @@ -9628,6 +9925,35 @@ details[open] .summary-arrow { color: var(--text-primary); } +.landing-home-subtitle { + max-width: 62ch; + margin: 0; + color: var(--text-secondary); + font-size: 1.08rem; + line-height: 1.7; + overflow-wrap: anywhere; +} + +.landing-home-proof { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.landing-home-proof span { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border: 1px solid rgba(37, 99, 235, 0.2); + border-radius: 999px; + background: rgba(37, 99, 235, 0.06); + color: var(--text-primary); + font-size: 0.82rem; + font-weight: 700; +} + .landing-home-explainer { display: grid; gap: 0; @@ -9669,6 +9995,7 @@ details[open] .summary-arrow { box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); position: relative; overflow: hidden; + min-width: 0; } .landing-home-start-panel::before { @@ -9688,6 +10015,12 @@ details[open] .summary-arrow { letter-spacing: 0; } +.landing-home-start-panel .landing-primary-button, +.landing-home-start-panel .landing-secondary-button { + width: 100%; + min-height: 2.75rem; +} + .landing-start-actions { display: flex; flex-direction: column; @@ -9720,6 +10053,82 @@ details[open] .summary-arrow { transform: translateY(-1px); } +.landing-start-note, +.landing-start-error, +.landing-start-help { + margin: 0; + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.55; +} + +.landing-start-error { + color: var(--error); +} + +.landing-start-help a { + color: var(--accent-blue); + font-weight: 700; + text-decoration: none; +} + +.landing-upload-success, +.landing-preview-results { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0.75rem; + border: 1px solid rgba(16, 185, 129, 0.22); + border-radius: 8px; + background: rgba(16, 185, 129, 0.06); + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.45; +} + +.landing-upload-success strong, +.landing-preview-results strong { + color: var(--text-primary); + font-size: 0.9rem; +} + +.landing-preview-results { + border-color: rgba(59, 130, 246, 0.22); + background: rgba(59, 130, 246, 0.06); +} + +.landing-preview-results span { + color: var(--text-primary); +} + +.landing-home-steps { + display: grid; + gap: 0.55rem; + margin: 0; + padding: 0.75rem 0 0 1.35rem; + border-top: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 0.9rem; + line-height: 1.45; +} + +.landing-provider-guides { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.landing-provider-guides a { + border: 1px solid rgba(59, 130, 246, 0.18); + border-radius: 999px; + padding: 0.25rem 0.55rem; + background: rgba(59, 130, 246, 0.06); + color: var(--accent-blue); + font-size: 0.72rem; + font-weight: 800; + text-decoration: none; +} + :root[data-theme="dark"] .landing-home-intro { background: rgba(17, 24, 39, 0.54); } @@ -9754,17 +10163,36 @@ details[open] .summary-arrow { } .landing-home-intro { + gap: 1.1rem; padding: 1.25rem; } + .landing-home-copy { + display: contents; + } + .landing-home-start-panel { border-radius: 8px; padding: 1.1rem; + order: 3; } .landing-home-copy h1 { max-width: none; - font-size: clamp(2.2rem, 11vw, 3.2rem); + font-size: clamp(2rem, 10vw, 2.85rem); + order: 1; + } + + .landing-home-subtitle { + order: 2; + } + + .landing-home-proof { + order: 4; + } + + .landing-home-explainer { + order: 5; } .landing-home-explainer p { @@ -11479,6 +11907,44 @@ details[open] .summary-arrow { margin: 0 0 1.25rem; } +.srb-evidence { + display: grid; + gap: 0.6rem; + margin-top: 0.5rem; + padding: 0.85rem; + border: 1px solid rgba(59, 130, 246, 0.18); + border-radius: 8px; + background: rgba(59, 130, 246, 0.05); +} + +.srb-evidence strong { + color: var(--text-primary); + font-size: 0.88rem; +} + +.srb-evidence-items { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.srb-evidence-items span { + display: inline-flex; + align-items: center; + min-height: 1.65rem; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + padding: 0.25rem 0.55rem; + background: rgba(255, 255, 255, 0.5); + color: var(--text-secondary); + font-size: 0.74rem; + font-weight: 700; +} + +:root[data-theme="dark"] .srb-evidence-items span { + background: rgba(15, 23, 42, 0.58); +} + .srb-no-match { font-size: 0.9rem; color: var(--text-secondary); @@ -12247,3 +12713,92 @@ a.heatmap-chip:hover { text-align: center; } +/* Raw DNA guide */ +.guide-page { + max-width: 980px; + overflow-x: hidden; +} + +.guide-hero { + display: grid; + gap: 1rem; + min-width: 0; + padding: 2rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--surface-bg); +} + +.guide-eyebrow { + color: var(--accent-blue); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.guide-hero h1 { + margin: 0; + color: var(--text-primary); + font-size: clamp(2.1rem, 5vw, 3.7rem); + line-height: 1; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +.guide-hero p { + max-width: 68ch; + margin: 0; + color: var(--text-secondary); + font-size: 1.05rem; + line-height: 1.7; + overflow-wrap: anywhere; +} + +.guide-hero .landing-primary-button { + width: fit-content; + max-width: 100%; + white-space: normal; + text-align: center; +} + +.guide-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.guide-grid article, +.guide-provider-section { + padding: 1.25rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--surface-bg); +} + +.guide-grid h2, +.guide-provider-section h2 { + margin: 0 0 0.6rem; + color: var(--text-primary); + font-size: 1.1rem; +} + +.guide-grid p { + margin: 0; + color: var(--text-secondary); + line-height: 1.65; +} + +@media (max-width: 768px) { + .guide-hero { + padding: 1.25rem; + } + + .guide-grid { + grid-template-columns: 1fr; + } + + .guide-hero .landing-primary-button { + width: 100%; + } +} diff --git a/app/landing-client.tsx b/app/landing-client.tsx index 3249193..cbfe39b 100644 --- a/app/landing-client.tsx +++ b/app/landing-client.tsx @@ -7,7 +7,19 @@ import { useGenotype } from "./components/UserDataUpload"; 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"; +import { runAllAnalysisOnboarding, type OnboardingRunAllProgress } from "@/lib/run-all-onboarding"; +import { selectAhaPreviewResults } from "@/lib/preview-insights"; +import { + trackFirstResultViewed, + trackGetStartedClicked, + trackProviderGuideClicked, + trackQuickPreviewCompleted, + trackQuickPreviewFailed, + trackQuickPreviewStarted, + 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"; @@ -26,6 +38,7 @@ const SAMPLE_CUSTOMIZATION: UserCustomization = { }; type SampleLoadStatus = "idle" | "downloading" | "loading" | "loaded" | "error"; +type PreviewStatus = "idle" | "running" | "complete" | "error"; function formatBytes(bytes: number): string { if (!bytes) return "0 KB"; @@ -68,13 +81,62 @@ const featureCopy = [ export default function LandingClient() { const router = useRouter(); - const { error } = useGenotype(); - const { addResultsBatch, clearResults, savedResults } = useResults(); + const { error, isUploaded, genotypeData, originalFileName, originalFileSize, detectedFormat } = useGenotype(); + const { addResultsBatch, clearResults, savedResults, hasResult } = 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 [previewStatus, setPreviewStatus] = useState("idle"); + const [previewError, setPreviewError] = useState(null); + const [previewProgress, setPreviewProgress] = useState(null); + const [previewTraitNames, setPreviewTraitNames] = useState([]); + + const openDNAUpload = () => { + trackGetStartedClicked("home_upload_raw_dna"); + window.dispatchEvent(new CustomEvent("openDNAUpload", { detail: { source: "home_upload_raw_dna" } })); + }; + + const startFullAnalysis = () => { + trackGetStartedClicked("home_analyze_uploaded_dna"); + window.dispatchEvent(new CustomEvent("startRunAllAnalysis")); + }; + + const runQuickPreview = async () => { + if (!genotypeData || previewStatus === "running") return; + + trackQuickPreviewStarted("home"); + setPreviewStatus("running"); + setPreviewError(null); + setPreviewTraitNames([]); + + try { + const results = await runAllAnalysisOnboarding( + genotypeData, + (progress) => setPreviewProgress(progress), + hasResult, + { maxResults: 1000 } + ); + + if (!results.length) { + throw new Error("No preview matches were found in the quick scan. You can still run the full catalog analysis."); + } + + const curatedResults = selectAhaPreviewResults(results, 12); + await addResultsBatch(curatedResults); + const traitNames = curatedResults.slice(0, 3).map((result) => result.traitName); + setPreviewTraitNames(traitNames); + setPreviewStatus("complete"); + trackQuickPreviewCompleted(curatedResults.length, "home"); + trackFirstResultViewed("home_preview"); + } catch (err) { + const message = err instanceof Error ? err.message : "The quick preview could not complete."; + setPreviewStatus("error"); + setPreviewError(message); + trackQuickPreviewFailed(message, "home"); + } + }; const loadSampleData = async () => { if (savedResults.length > 0) { @@ -156,50 +218,41 @@ export default function LandingClient() { ? "Parsing results…" : null; + const formatFileSize = (bytes: number | null) => { + if (!bytes) return null; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + const previewProgressText = + previewStatus === "running" && previewProgress + ? previewProgress.phase === "downloading" + ? "Preparing preview catalog..." + : previewProgress.phase === "analyzing" + ? `Scanning studies, ${previewProgress.matchCount} candidate matches found` + : previewProgress.message + : null; + return (
-
+
-

Understand your DNA without giving it away.

+

Analyze your raw DNA file privately.

-

- Upload your DNA file or try it now with sample data, free. +

+ Upload a 23andMe, AncestryDNA, MyHeritage, FTDNA, LivingDNA, CSV, or TSV file. Your raw DNA file stays in your browser while the app matches variants against GWAS Catalog research.

{error &&

{error}

} -
- - {sampleProgressText && ( -

- {sampleProgressText} -

- )} - {sampleError && ( -

{sampleError}

- )} -

- No DNA file needed. Your data never leaves your device.{' '} - trackGetStartedClicked("schedule_video_call")} - > - Need help? Book a free call. - -

+
+ Local file processing + No DNA account required + GWAS evidence, effect sizes, and p-values + Educational use, not diagnosis
-
+
{featureCopy.map((item) => (

@@ -222,6 +275,128 @@ export default function LandingClient() { ))}

+ +
+

Start with your raw DNA file

+ +
+ {isUploaded ? ( + + ) : ( + + )} + + + + {isUploaded && savedResults.length === 0 && ( + + )} + + {sampleProgressText && ( +

{sampleProgressText}

+ )} + {previewProgressText && ( +

{previewProgressText}

+ )} + {sampleError && ( +

{sampleError}

+ )} + {previewError && ( +

{previewError}

+ )} +
+ + {isUploaded && genotypeData ? ( +
+ File parsed successfully + {genotypeData.size.toLocaleString()} variants loaded{detectedFormat ? ` from ${detectedFormat}` : ""}. + {originalFileName && {originalFileName}{formatFileSize(originalFileSize) ? `, ${formatFileSize(originalFileSize)}` : ""}} + Your raw DNA file stayed in this browser. +
+ ) : ( +

+ Nothing is uploaded to us. The file picker opens locally, and analysis runs in this browser. +

+ )} + + {previewTraitNames.length > 0 && ( +
+ Preview results ready + {previewTraitNames.map((traitName) => ( + {traitName} + ))} +
+ )} + +
    +
  1. Choose your raw DNA file.
  2. +
  3. Start with a quick local preview.
  4. +
  5. Explore results and ask DNA Chat questions.
  6. +
+ +
+ {[ + ["23andMe", "23andme"], + ["AncestryDNA", "ancestry"], + ["MyHeritage", "myheritage"], + ["FTDNA", "ftdna"], + ["LivingDNA", "livingdna"], + ].map(([label, provider]) => ( + trackProviderGuideClicked(provider, "home")} + > + {label} + + ))} +
+ +

+ Need help getting your raw DNA file?{" "} + trackGetStartedClicked("schedule_video_call")} + > + Book a free call + + {" "}or use a provider guide above.{" "} + + Share the raw DNA guide + . +

+
); diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx index d11548e..337650a 100644 --- a/app/overview-report/page.tsx +++ b/app/overview-report/page.tsx @@ -40,8 +40,11 @@ export default function OverviewReportPage() { }, []); useEffect(() => { - trackOverviewReportViewed(); - }, []); + trackOverviewReportViewed({ + resultCount: savedResults.length, + hasResults: savedResults.length > 0, + }); + }, [savedResults.length]); const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; diff --git a/app/page.tsx b/app/page.tsx index bd088dd..c364bc5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,12 +5,12 @@ import Footer from "./components/Footer"; import LandingClient from "./landing-client"; export const metadata: Metadata = { - title: "Monadic DNA | Personal DNA insights with privacy, autonomy, and boundless curiosity", - description: "Private DNA insights from trusted genetic research. Learn from your DNA while your data remains private, protected, and entirely in your hands.", - keywords: ["GWAS", "genetics", "DNA analysis", "genome explorer", "genetic traits", "personal genomics", "23andMe", "AncestryDNA"], + title: "Private Raw DNA Analysis | Monadic DNA Explorer", + description: "Upload a raw DNA file from 23andMe, AncestryDNA, MyHeritage, FTDNA, LivingDNA, CSV, or TSV. Analyze genetic traits privately in your browser using GWAS Catalog research.", + keywords: ["raw DNA upload", "private DNA analysis", "secure DNA analysis", "GWAS", "genetics", "DNA analysis", "genome explorer", "genetic traits", "personal genomics", "23andMe", "AncestryDNA"], openGraph: { - title: "Monadic DNA | Personal DNA insights with privacy, autonomy, and boundless curiosity", - description: "Private DNA insights from trusted genetic research. Learn from your DNA while your data remains private, protected, and entirely in your hands.", + title: "Private Raw DNA Analysis | Monadic DNA Explorer", + description: "Upload a raw DNA file and analyze genetic traits privately in your browser using GWAS Catalog research.", type: "website", url: "https://explorer.monadicdna.com", siteName: "Monadic DNA Explorer", @@ -26,8 +26,8 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "Monadic DNA | Personal DNA insights with privacy, autonomy, and boundless curiosity", - description: "Private DNA insights from trusted genetic research. Learn from your DNA while your data remains private, protected, and entirely in your hands.", + title: "Private Raw DNA Analysis | Monadic DNA Explorer", + description: "Upload a raw DNA file and analyze genetic traits privately in your browser using GWAS Catalog research.", images: ["/og-image.png"], }, }; @@ -46,7 +46,7 @@ const websiteJsonLd = { "@type": "WebSite", "name": "Monadic DNA Explorer", "url": "https://explorer.monadicdna.com", - "description": "Private DNA insights from trusted genetic research.", + "description": "Private raw DNA analysis from trusted genetic research.", "potentialAction": { "@type": "SearchAction", "target": { diff --git a/app/raw-dna-guide/page.tsx b/app/raw-dna-guide/page.tsx new file mode 100644 index 0000000..c8c982d --- /dev/null +++ b/app/raw-dna-guide/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import MenuBar from "../components/MenuBar"; +import Footer from "../components/Footer"; + +export const metadata: Metadata = { + title: "What Raw DNA Analysis Can Tell You | Monadic DNA Explorer", + description: "A privacy-first guide to raw DNA files, GWAS associations, scientific caveats, and browser-local genetic analysis.", + keywords: ["raw DNA analysis", "private DNA analysis", "GWAS guide", "23andMe raw data", "AncestryDNA raw data"], +}; + +export default function RawDNAGuidePage() { + return ( +
+ +
+
+ Privacy-safe guide +

What raw DNA analysis can tell you

+

+ Raw DNA files from consumer tests contain genotype calls at many known variants. + Monadic DNA Explorer compares those variants with GWAS Catalog studies in your browser. +

+ + Analyze a raw DNA file + +
+ +
+
+

What you can learn

+

+ GWAS findings can show associations between variants and traits such as metabolism, + sleep, physical traits, disease susceptibility, and medication response research. +

+
+ +
+

How to read the science

+

+ Each finding should be read with its study, p-value, effect size, sample context, + population context, and replication status. Most effects are probabilistic and small. +

+
+ +
+

Privacy model

+

+ The raw DNA file is parsed locally. Results are held in browser memory unless you + choose to export them. The app should never need a DNA account to analyze your file. +

+
+ +
+

Limits

+

+ GWAS associations are educational research signals. They are not medical diagnosis, + ancestry assignment, or a complete picture of health risk. +

+
+
+ +
+

Download your raw file

+ +
+
+
+
+ ); +} diff --git a/app/study/[id]/page.tsx b/app/study/[id]/page.tsx index 637600f..0317ba5 100644 --- a/app/study/[id]/page.tsx +++ b/app/study/[id]/page.tsx @@ -136,6 +136,10 @@ export default async function StudyDetailPage({ params }: { params: Promise<{ id pubmedId={study.pubmedid} mappedGene={study.mapped_gene} reportedTrait={study.disease_trait} + pValue={study.p_value} + pValueMlog={study.pvalue_mlog} + initialSampleSize={study.initial_sample_size} + replicationSampleSize={study.replication_sample_size} /> {/* Study Details */} diff --git a/lib/analytics.ts b/lib/analytics.ts index d6a7ed8..50cd914 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -25,7 +25,7 @@ declare global { function trackEvent(eventName: string, params?: Record) { if (typeof window !== 'undefined' && window.gtag) { try { - window.gtag('event', eventName, params); + window.gtag('event', eventName, sanitizeAnalyticsParams(params)); } catch (error) { console.warn('Analytics tracking failed:', error); } @@ -128,6 +128,20 @@ function sanitizeErrorReason(reason?: string): string | undefined { return reason.slice(0, 120); } +const SENSITIVE_ANALYTICS_KEYS = new Set(); + +function sanitizeAnalyticsParams(params?: Record): Record | undefined { + if (!params) return undefined; + + const safeParams = Object.fromEntries( + Object.entries(params).filter(([key, value]) => + value !== undefined && !SENSITIVE_ANALYTICS_KEYS.has(key) + ) + ); + + return Object.keys(safeParams).length > 0 ? safeParams : undefined; +} + /** * User accepted terms and moved past initial modal */ @@ -190,7 +204,7 @@ export function trackOnboardingDismissed(step: string) { /** * User clicked one of the homepage Get Started actions */ -export function trackGetStartedClicked(action: 'onboarding_tour' | 'welcome_options' | 'try_dna_chat_directly' | 'instructional_video' | 'schedule_video_call' | 'restart_onboarding') { +export function trackGetStartedClicked(action: 'onboarding_tour' | 'welcome_options' | 'try_dna_chat_directly' | 'instructional_video' | 'schedule_video_call' | 'restart_onboarding' | 'home_upload_raw_dna' | 'home_analyze_uploaded_dna') { trackEvent('get_started_clicked', { action, }); @@ -327,11 +341,32 @@ export function trackGenotypeFileUploadStarted(source: string = 'unknown') { }); } +export function trackUploadPickerOpened(source: string = 'unknown') { + trackEvent('upload_picker_opened', { + source, + }); +} + +export function trackGenotypeParseStarted(source: string = 'unknown', fileExtension?: string) { + trackEvent('genotype_parse_started', { + source, + file_extension: fileExtension, + }); +} + +export function trackGenotypeParseSucceeded(source: string = 'unknown', detectedFormat?: string, variantCount?: number) { + trackEvent('genotype_parse_succeeded', { + source, + detected_format: detectedFormat, + variant_count: variantCount, + }); +} + export function trackGenotypeFileUploadFailed(source: string = 'unknown', reason?: string, fileExtension?: string) { trackEvent('genotype_file_upload_failed', { source, reason: sanitizeErrorReason(reason), - ...(fileExtension && { file_extension: fileExtension }), + file_extension: fileExtension, }); } @@ -340,13 +375,12 @@ export function trackGenotypeFileLoaded(fileSize: number, variantCount: number, file_size_kb: Math.round(fileSize / 1024), variant_count: variantCount, source, - ...(detectedFormat && { detected_format: detectedFormat }), - ...(fileExtension && { file_extension: fileExtension }), + detected_format: detectedFormat, + file_extension: fileExtension, }; trackEvent('genotype_file_loaded', metadata); - // Track as Lead event on Reddit (DNA upload is a lead generation action) const conversionId = `dna_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; trackRedditEvent('Lead', { conversionId, @@ -355,8 +389,7 @@ export function trackGenotypeFileLoaded(fileSize: number, variantCount: number, conversion_id: conversionId, }); - // Track as Lead event on X - trackXEvent('tw-r9lkr-rbtjs', { // Custom event - DNA File Upload + trackXEvent('tw-r9lkr-rbtjs', { conversion_id: conversionId, }); trackXConversion('Lead', { @@ -459,9 +492,45 @@ export function trackRunAllFailed(source: 'menu' | 'explore' | 'onboarding', rea }); } -export function trackLLMQuestionAsked(params?: { isFollowUp?: boolean }) { +export function trackQuickPreviewStarted(source: 'home' | 'onboarding' = 'home') { + trackEvent('quick_preview_started', { + source, + }); +} + +export function trackQuickPreviewCompleted(resultCount: number, source: 'home' | 'onboarding' = 'home') { + trackEvent('quick_preview_completed', { + source, + result_count: resultCount, + }); +} + +export function trackQuickPreviewFailed(reason?: string, source: 'home' | 'onboarding' = 'home') { + trackEvent('quick_preview_failed', { + source, + reason: sanitizeErrorReason(reason), + }); +} + +export function trackFirstResultViewed(source: 'home_preview' | 'study' | 'results' = 'results') { + trackEvent('first_result_viewed', { + source, + }); +} + +export function trackProviderGuideClicked(provider: string, source: 'home' | 'upload' | 'study' = 'home') { + trackEvent('provider_guide_clicked', { + provider, + source, + }); +} + +export function trackLLMQuestionAsked(params?: { isFollowUp?: boolean; hasUploadedDna?: boolean; resultCount?: number; source?: 'chat' | 'research' | 'onboarding' }) { trackEvent('llm_question_asked', { is_follow_up: params?.isFollowUp ?? false, + has_uploaded_dna: params?.hasUploadedDna, + result_count: params?.resultCount, + source: params?.source, }); } @@ -489,8 +558,11 @@ export function trackAIConsentModalShown() { trackEvent('ai_consent_modal_shown'); } -export function trackOverviewReportViewed() { - trackEvent('overview_report_viewed'); +export function trackOverviewReportViewed(params?: { resultCount?: number; hasResults?: boolean }) { + trackEvent('overview_report_viewed', { + result_count: params?.resultCount, + has_results: params?.hasResults, + }); } /** @@ -533,6 +605,12 @@ export function trackExplorePageViewed() { trackEvent('explore_page_viewed'); } +export function trackExploreFollowupClicked(action: 'run_full_analysis' | 'ask_dna_chat' | 'preview_best_question' | 'personalize' | 'browse') { + trackEvent('explore_followup_clicked', { + action, + }); +} + export function trackContinueInDNAChat(source: 'study_analysis') { trackEvent('continue_in_dna_chat', { source }); } diff --git a/lib/evidence-labels.ts b/lib/evidence-labels.ts new file mode 100644 index 0000000..0f51691 --- /dev/null +++ b/lib/evidence-labels.ts @@ -0,0 +1,76 @@ +import type { SavedResult } from "./results-manager"; + +function parsePValue(result: Pick): number | null { + if (result.pValue) { + const normalized = result.pValue.replace(/\s/g, "").replace(/x10\^/i, "e"); + const value = Number.parseFloat(normalized); + if (Number.isFinite(value) && value > 0) return value; + } + + if (result.pValueMlog) { + const mlog = Number.parseFloat(result.pValueMlog); + if (Number.isFinite(mlog) && mlog > 0) return Math.pow(10, -mlog); + } + + return null; +} + +function parseLargestNumber(value?: string): number | null { + if (!value) return null; + const matches = value.match(/\d[\d,]*/g); + if (!matches?.length) return null; + const values = matches + .map((match) => Number.parseInt(match.replace(/,/g, ""), 10)) + .filter((number) => Number.isFinite(number)); + if (!values.length) return null; + return Math.max(...values); +} + +export function getEvidenceBand(result: SavedResult): "strong" | "moderate" | "limited" { + const pValue = parsePValue(result); + const sampleSize = parseLargestNumber(result.sampleSize); + const hasReplication = !!result.replicationSampleSize?.trim(); + + if ((pValue !== null && pValue <= 5e-8) && (hasReplication || (sampleSize !== null && sampleSize >= 10000))) { + return "strong"; + } + + if ((pValue !== null && pValue <= 5e-8) || (sampleSize !== null && sampleSize >= 10000)) { + return "moderate"; + } + + return "limited"; +} + +export function getEvidenceLabel(result: SavedResult): string { + const band = getEvidenceBand(result); + if (band === "strong") return "Stronger GWAS evidence"; + if (band === "moderate") return "Moderate GWAS evidence"; + return "Limited context available"; +} + +export function getEvidenceDetails(result: SavedResult): string[] { + const details: string[] = []; + const pValue = parsePValue(result); + + if (pValue !== null) { + details.push(pValue <= 5e-8 ? "Genome-wide significant p-value" : "Association p-value reported"); + } + + if (result.sampleSize) { + details.push(`Initial sample: ${result.sampleSize}`); + } + + if (result.replicationSampleSize) { + details.push(`Replication sample: ${result.replicationSampleSize}`); + } + + if (result.effectType === "beta") { + details.push("Beta effect, not an odds ratio"); + } else { + details.push("Odds ratio is relative, not absolute risk"); + } + + details.push("Educational interpretation, not diagnosis"); + return details; +} diff --git a/lib/preview-insights.ts b/lib/preview-insights.ts new file mode 100644 index 0000000..f06602d --- /dev/null +++ b/lib/preview-insights.ts @@ -0,0 +1,247 @@ +import type { SavedResult } from "./results-manager"; + +type PreviewCategory = + | "body_composition" + | "cardiometabolic" + | "immune" + | "brain_behavior" + | "sleep_energy" + | "reproductive" + | "appearance" + | "medication" + | "other"; + +type CategoryDefinition = { + id: PreviewCategory; + label: string; + keywords: string[]; +}; + +export type PreviewInsight = { + themes: { label: string; count: number }[]; + standout: SavedResult | null; + protectiveCount: number; + elevatedCount: number; + headline: string; +}; + +const CATEGORY_DEFINITIONS: CategoryDefinition[] = [ + { + id: "body_composition", + label: "body composition", + keywords: ["body mass", "bmi", "weight", "obesity", "height", "adiposity", "waist", "lean mass", "fat mass"], + }, + { + id: "cardiometabolic", + label: "cardiometabolic traits", + keywords: ["cholesterol", "lipid", "triglyceride", "glucose", "insulin", "diabetes", "blood pressure", "coronary", "heart"], + }, + { + id: "immune", + label: "immune and inflammation", + keywords: ["immune", "inflammatory", "asthma", "allergy", "eczema", "psoriasis", "arthritis", "celiac", "crohn", "colitis"], + }, + { + id: "brain_behavior", + label: "brain and behavior", + keywords: ["cognitive", "intelligence", "education", "neuroticism", "depression", "anxiety", "adhd", "risk taking", "alcohol", "smoking"], + }, + { + id: "sleep_energy", + label: "sleep and energy", + keywords: ["sleep", "chronotype", "morning", "insomnia", "fatigue", "caffeine", "restless"], + }, + { + id: "reproductive", + label: "hormonal and reproductive traits", + keywords: ["endometriosis", "menopause", "menarche", "testosterone", "estrogen", "fertility", "reproductive", "puberty"], + }, + { + id: "appearance", + label: "visible traits", + keywords: ["hair", "skin", "eye color", "freckle", "baldness", "pigmentation", "facial", "tooth"], + }, + { + id: "medication", + label: "medication response", + keywords: ["drug", "medication", "statin", "warfarin", "metformin", "response", "adverse", "pharmacogen"], + }, +]; + +const GENERIC_TRAIT_WORDS = new Set([ + "stage", + "type", + "disease", + "disorder", + "condition", + "measurement", + "self", + "reported", + "adjusted", + "male", + "female", + "men", + "women", +]); + +function normalizeTraitName(traitName: string): string { + return traitName + .toLowerCase() + .replace(/\([^)]*\)/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .split(/\s+/) + .filter((word) => word.length > 2 && !GENERIC_TRAIT_WORDS.has(word)) + .join(" ") + .trim(); +} + +export function getPreviewCategory(result: SavedResult): { id: PreviewCategory; label: string } { + const text = `${result.traitName} ${result.studyTitle} ${result.mappedGene || ""}`.toLowerCase(); + const match = CATEGORY_DEFINITIONS.find((category) => + category.keywords.some((keyword) => text.includes(keyword)) + ); + + if (!match) return { id: "other", label: "other traits" }; + return { id: match.id, label: match.label }; +} + +export function formatPreviewEffect(result: SavedResult, compact = false): string { + if (result.effectType === "beta") { + return `β=${result.riskScore >= 0 ? "+" : ""}${result.riskScore.toFixed(3)}`; + } + + if (result.riskLevel === "neutral") return "baseline"; + if (compact) return `${result.riskScore.toFixed(2)}x ${result.riskLevel === "increased" ? "↑" : "↓"}`; + return `${result.riskScore.toFixed(2)}x ${result.riskLevel === "increased" ? "higher relative odds" : "lower relative odds"}`; +} + +function parseNumeric(value?: string): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value.replace(/,/g, "")); + return Number.isFinite(parsed) ? parsed : null; +} + +function effectMagnitude(result: SavedResult): number { + if (result.effectType === "beta") return Math.min(Math.abs(result.riskScore) * 4, 2.5); + if (result.riskScore <= 0 || result.riskScore > 50) return 0; + return Math.min(Math.abs(Math.log(result.riskScore)), 2.5); +} + +function evidenceScore(result: SavedResult): number { + const mlog = parseNumeric(result.pValueMlog); + const sampleText = `${result.sampleSize || ""} ${result.replicationSampleSize || ""}`; + const sampleNumbers = sampleText.match(/\d[\d,]*/g)?.map((value) => Number.parseInt(value.replace(/,/g, ""), 10)) || []; + const largestSample = sampleNumbers.length ? Math.max(...sampleNumbers) : 0; + + let score = 0; + if (mlog !== null) score += Math.min(mlog / 10, 1.5); + if (largestSample >= 100000) score += 1; + else if (largestSample >= 10000) score += 0.6; + if (result.replicationSampleSize) score += 0.4; + return score; +} + +function interestScore(result: SavedResult): number { + const category = getPreviewCategory(result).id; + const categoryBoost = + category === "other" ? 0 : + category === "appearance" ? 0.25 : + category === "cardiometabolic" || category === "immune" || category === "medication" ? 1 : + 0.75; + return effectMagnitude(result) + evidenceScore(result) + categoryBoost; +} + +function previewCategoryLimit(category: PreviewCategory): number { + if (category === "appearance") return 3; + if (category === "other") return 2; + return 3; +} + +const CATEGORY_DISPLAY_PRIORITY: Record = { + cardiometabolic: 0, + medication: 1, + immune: 2, + sleep_energy: 3, + reproductive: 4, + brain_behavior: 5, + body_composition: 6, + appearance: 7, + other: 8, +}; + +function orderPreviewResultsForDisplay(results: SavedResult[]): SavedResult[] { + return [...results].sort((a, b) => { + const categoryDiff = CATEGORY_DISPLAY_PRIORITY[getPreviewCategory(a).id] - CATEGORY_DISPLAY_PRIORITY[getPreviewCategory(b).id]; + if (categoryDiff !== 0) return categoryDiff; + return interestScore(b) - interestScore(a); + }); +} + +export function selectAhaPreviewResults(results: SavedResult[], limit = 12): SavedResult[] { + const sorted = [...results].sort((a, b) => interestScore(b) - interestScore(a)); + const selected: SavedResult[] = []; + const seenTraits = new Set(); + const categoryCounts = new Map(); + + for (const result of sorted) { + const normalized = normalizeTraitName(result.traitName); + if (normalized && seenTraits.has(normalized)) continue; + + const category = getPreviewCategory(result).id; + const categoryLimit = previewCategoryLimit(category); + if ((categoryCounts.get(category) || 0) >= categoryLimit) continue; + + selected.push(result); + if (normalized) seenTraits.add(normalized); + categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1); + if (selected.length >= limit) break; + } + + if (selected.length < limit) { + for (const result of sorted) { + if (selected.some((item) => item.studyId === result.studyId)) continue; + const normalized = normalizeTraitName(result.traitName); + if (normalized && seenTraits.has(normalized)) continue; + + const category = getPreviewCategory(result).id; + if ((categoryCounts.get(category) || 0) >= previewCategoryLimit(category)) continue; + + selected.push(result); + if (normalized) seenTraits.add(normalized); + categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1); + if (selected.length >= limit) break; + } + } + + return orderPreviewResultsForDisplay(selected); +} + +export function buildPreviewInsight(results: SavedResult[]): PreviewInsight { + const categoryCounts = new Map(); + for (const result of results) { + const category = getPreviewCategory(result).label; + categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1); + } + + const themes = [...categoryCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([label, count]) => ({ label, count })); + + const standout = [...results] + .filter((result) => result.riskLevel !== "neutral") + .sort((a, b) => interestScore(b) - interestScore(a))[0] || results[0] || null; + + const protectiveCount = results.filter((result) => result.riskLevel === "decreased").length; + const elevatedCount = results.filter((result) => result.riskLevel === "increased").length; + + return { + themes, + standout, + protectiveCount, + elevatedCount, + headline: themes.length > 0 + ? `Your first matches point to ${themes.map((theme) => theme.label).join(", ")}.` + : "Your first matches are ready to explore.", + }; +} diff --git a/lib/run-all-onboarding.ts b/lib/run-all-onboarding.ts index 9383553..59868ce 100644 --- a/lib/run-all-onboarding.ts +++ b/lib/run-all-onboarding.ts @@ -17,7 +17,8 @@ const ONBOARDING_CATALOG_URL = export async function runAllAnalysisOnboarding( genotypeData: Map, onProgress: (progress: OnboardingRunAllProgress) => void, - hasResult: (studyId: number) => boolean + hasResult: (studyId: number) => boolean, + options: { maxResults?: number } = {} ): Promise { const startTime = Date.now(); @@ -120,6 +121,10 @@ export async function runAllAnalysisOnboarding( const orBetaIdx = colMap.or_or_beta; const ciTextIdx = colMap.ci_text; const mappedGeneIdx = colMap.mapped_gene; + const pValueIdx = colMap.p_value; + const pValueMlogIdx = colMap.pvalue_mlog; + const sampleSizeIdx = colMap.initial_sample_size; + const replicationSampleSizeIdx = colMap.replication_sample_size; if ( idIdx === undefined || @@ -244,9 +249,24 @@ export async function runAllAnalysisOnboarding( riskLevel, matchedSnp: riskSnpId, analysisDate: new Date().toISOString(), + pValue: pValueIdx !== undefined ? cols[pValueIdx] || undefined : undefined, + pValueMlog: pValueMlogIdx !== undefined ? cols[pValueMlogIdx] || undefined : undefined, mappedGene: cols[mappedGeneIdx] || undefined, + sampleSize: sampleSizeIdx !== undefined ? cols[sampleSizeIdx] || undefined : undefined, + replicationSampleSize: replicationSampleSizeIdx !== undefined ? cols[replicationSampleSizeIdx] || undefined : undefined, }); matchCount++; + if (options.maxResults && results.length >= options.maxResults) { + emitProgress({ + phase: "complete", + loaded: processedStudies, + total: processedStudies, + processedStudies, + matchCount, + message: "Preview analysis complete.", + }); + return results; + } } } @@ -311,7 +331,11 @@ export async function runAllAnalysisOnboarding( riskLevel, matchedSnp: riskSnpId, analysisDate: new Date().toISOString(), + pValue: pValueIdx !== undefined ? cols[pValueIdx] || undefined : undefined, + pValueMlog: pValueMlogIdx !== undefined ? cols[pValueMlogIdx] || undefined : undefined, mappedGene: cols[mappedGeneIdx] || undefined, + sampleSize: sampleSizeIdx !== undefined ? cols[sampleSizeIdx] || undefined : undefined, + replicationSampleSize: replicationSampleSizeIdx !== undefined ? cols[replicationSampleSizeIdx] || undefined : undefined, }); matchCount++; } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.