From 3a7e393f0966752bd47af347a38bc3ffbbd7077b Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 18 Jun 2026 10:59:52 -0400 Subject: [PATCH 1/4] Clearer messaging about subscription benefits. --- app/components/LLMChatInline.tsx | 8 +-- app/overview-report/page.tsx | 1 - app/subscribe/page.tsx | 100 +++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 453200b..a070f55 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -98,7 +98,6 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } const requirePremium = (): boolean => { if (!hasPremiumAccess && !hasValidPromoAccess()) { - if (!isAuthenticated) { openAuthModal(); return false; } router.push('/subscribe'); return false; } @@ -692,9 +691,10 @@ Write questions from the user's perspective — as if the user is asking you. No }; const handleResearch = async () => { - const query = inputValue.trim(); - if (!query || isLoading) return; + if (isLoading) return; if (!requirePremium()) return; + const query = inputValue.trim(); + if (!query) return; setInputValue(''); setIsLoading(true); @@ -1236,7 +1236,7 @@ Write questions from the user's perspective — as if the user is asking you. No - + ) : !isAuthenticated ? (

Sign in to subscribe

- Sign in with your wallet first so your subscription can be tied - to your Monadic DNA account. + Sign in to continue. We do not store your genetic data, tie your DNA to your identity, or share your information with third parties.

+ + ); +} diff --git a/app/components/StudyPersonalSection.tsx b/app/components/StudyPersonalSection.tsx new file mode 100644 index 0000000..caf6aac --- /dev/null +++ b/app/components/StudyPersonalSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useResults } from "./ResultsContext"; +import StudyPersonalResultBanner from "./StudyPersonalResultBanner"; +import StudyInlineAnalysis from "./StudyInlineAnalysis"; + +type Props = { + studyId: number; + studyAccession: string | null; + snps: string | null; + traitName: string; + studyTitle: string; + riskAllele?: string | null; + orOrBeta?: string | null; + ciText?: string | null; + isAnalyzable: boolean; + nonAnalyzableReason?: string; + pubmedId?: string | null; + mappedGene?: string | null; + reportedTrait?: string | null; +}; + +export default function StudyPersonalSection({ + studyId, + studyAccession, + snps, + traitName, + studyTitle, + riskAllele, + orOrBeta, + ciText, + isAnalyzable, + nonAnalyzableReason, + pubmedId, + mappedGene, + reportedTrait, +}: Props) { + const { hasResult, getResult } = useResults(); + const result = getResult(studyId); + + return ( + <> + + {hasResult(studyId) && result && ( + + )} + + ); +} diff --git a/app/study/[id]/page.tsx b/app/study/[id]/page.tsx index d9264df..637600f 100644 --- a/app/study/[id]/page.tsx +++ b/app/study/[id]/page.tsx @@ -1,154 +1,53 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; +import { notFound } from "next/navigation"; 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; - 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: "high" | "medium" | "low"; - publicationDate: number | null; - isAnalyzable: boolean; - nonAnalyzableReason?: string; -}; - -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}`); - } - }; +import StudyNavButtons from "../../components/StudyNavButtons"; +import StudyPersonalSection from "../../components/StudyPersonalSection"; +import { fetchStudyById } from "@/lib/study-service"; + +function interpretSampleSize(n: number | null): string { + if (n === null) return ""; + if (n >= 100000) return "Very large study"; + if (n >= 10000) return "Large study"; + if (n >= 1000) return "Mid-size study"; + return "Smaller study"; +} - useEffect(() => { - const fetchStudy = async () => { - try { - setLoading(true); - setError(null); +function interpretPValue(logP: number | null): string { + if (logP === null) return ""; + if (logP >= 10) return "Exceptionally strong evidence"; + if (logP >= 7.3) return "Very strong evidence"; + if (logP >= 5) return "Strong evidence"; + if (logP >= 3) return "Moderate evidence"; + return "Suggestive evidence"; +} - // Fetch study by ID from the API - const response = await fetch(`/api/studies?id=${parseInt(studyId)}`); +function interpretEffectSize(orBeta: string | null): string { + if (!orBeta) return ""; + const val = parseFloat(orBeta); + if (isNaN(val)) return ""; + if (val >= 2 || val <= 0.5) return "Large effect"; + if (val >= 1.3 || val <= 0.77) return "Moderate effect"; + if (val >= 1.1 || val <= 0.91) return "Subtle effect"; + return "Very subtle effect"; +} - if (!response.ok) { - throw new Error('Failed to fetch study details'); - } +function formatDisplayDate(dateStr: string): string { + const ts = Date.parse(dateStr); + if (Number.isNaN(ts)) return dateStr; + return new Date(ts).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); +} - const data = await response.json(); +export default async function StudyDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const studyPk = parseInt(id); - if (data.data && data.data.length > 0) { - setStudy(data.data[0]); - } else { - setError('Study not found'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } finally { - setLoading(false); - } - }; + if (isNaN(studyPk) || studyPk <= 0) notFound(); - if (studyId) { - fetchStudy(); - } - }, [studyId]); - - if (loading) { - return ( - <> -
- -
-
-

Loading study details...

-
-
-
-
- - ); - } - - if (error || !study) { - return ( - <> -
- -
-
-

Study Not Found

-

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

- - ← Back to Browse - -
-
-
-
- - ); - } + const study = await fetchStudyById(studyPk); + if (!study) notFound(); const reportedTrait = study.disease_trait?.trim() || null; const mappedTrait = study.mapped_trait?.trim() || null; @@ -159,235 +58,181 @@ export default function StudyDetailPage() { const pubmedLink = study.pubmedid ? `https://pubmed.ncbi.nlm.nih.gov/${study.pubmedid}` : null; - const studyLink = gwasLink || study.link || pubmedLink; - - const interpretSampleSize = (n: number | null): string => { - if (n === null) return ""; - if (n >= 100000) return "Very large study"; - if (n >= 10000) return "Large study"; - if (n >= 1000) return "Mid-size study"; - return "Smaller study"; - }; - - const interpretPValue = (logP: number | null): string => { - if (logP === null) return ""; - if (logP >= 10) return "Exceptionally strong evidence"; - if (logP >= 7.3) return "Very strong evidence"; - if (logP >= 5) return "Strong evidence"; - if (logP >= 3) return "Moderate evidence"; - return "Suggestive evidence"; - }; - - const interpretEffectSize = (orBeta: string | null): string => { - if (!orBeta) return ""; - const val = parseFloat(orBeta); - if (isNaN(val)) return ""; - // OR interpretation - if (val >= 2 || val <= 0.5) return "Large effect"; - if (val >= 1.3 || val <= 0.77) return "Moderate effect"; - if (val >= 1.1 || val <= 0.91) return "Subtle effect"; - return "Very subtle effect"; - }; - - const navButtons = ( -
- - ← Back to Browse - - -
- ); return ( - <> -
- -
- {/* Breadcrumb + top nav */} -
- - Home - {" > "} - Browse - {" > "} - Study {study.id} +
+ +
+ {/* Breadcrumb + top nav */} +
+ + Home + {" > "} + Browse + {" > "} + Study {study.id} + + +
+ + {/* Study Header */} +
+
+ + {trait} - {navButtons} +
+

{study.study || "Untitled Study"}

+ +
+ {reportedTrait && mappedTrait && mappedTrait !== reportedTrait && ( + Reported trait: {reportedTrait} + )} + {study.first_author && ( + Author: {study.first_author} + )} + {study.date && ( + Date: {formatDisplayDate(study.date)} + )} + {study.journal && ( + Journal: {study.journal} + )} + {study.study_accession && ( + Accession: {study.study_accession} + )} + {study.mapped_gene && ( + Gene: {study.mapped_gene} + )}
- {/* Study Header */} -
-
+ + {/* Personal result banner + inline analysis (client-rendered, reads from browser) */} + + + {/* Study Details */} +
+
+ {study.date && ( +
+ Published + {new Date(study.date).getFullYear()} + {study.journal || "Peer-reviewed"} +
+ )} + {study.sampleSize !== null && ( +
+ Participants + {study.sampleSizeLabel} + {interpretSampleSize(study.sampleSize)} +
+ )} + {study.pValueNumeric !== null && ( +
+ P-value + {study.pValueLabel} + {interpretPValue(study.logPValue)} +
+ )} + {study.or_or_beta && ( +
+ Effect size + + {study.or_or_beta} + {study.ci_text ? {study.ci_text} : null} + + {interpretEffectSize(study.or_or_beta)} +
+ )} + {study.risk_allele_frequency && ( +
+ Variant frequency + {study.risk_allele_frequency} + In population +
+ )} +
+ Confidence + + + {study.confidenceBand === "high" ? "High" : study.confidenceBand === "medium" ? "Medium" : "Lower"} + + + + {study.confidenceBand === "high" ? "Well-replicated" : study.confidenceBand === "medium" ? "Some caveats" : "Interpret carefully"}
-

{study.study || "Untitled Study"}

+
-
- {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}} - {study.study_accession && Accession: {study.study_accession}} - {study.mapped_gene && Gene: {study.mapped_gene}} + {study.snps && ( +
+ Variants tested +
+ )} -
- {gwasLink && ( - - Source data → - Full dataset on GWAS Catalog - + {(study.initial_sample_size || study.replication_sample_size) && ( +
+ {study.initial_sample_size && ( +

+ Initial sample + {study.initial_sample_size} +

)} - {pubmedLink && ( - - Research paper → - Published article on PubMed - + {study.replication_sample_size && ( +

+ Replication + {study.replication_sample_size} +

)}
-
- - {/* Personal Result Banner */} - - - {/* Inline LLM analysis when a saved result exists for this study */} - {hasResult(study.id) && getResult(study.id) && ( - )} - {/* Study Details */} -
- {/* Stat grid */} -
- {study.date && ( -
- Published - {new Date(study.date).getFullYear()} - {study.journal || "Peer-reviewed"} -
- )} - {study.sampleSize !== null && ( -
- Participants - {study.sampleSizeLabel} - {interpretSampleSize(study.sampleSize)} -
- )} - {study.pValueNumeric !== null && ( -
- P-value - {study.pValueLabel} - {interpretPValue(study.logPValue)} -
- )} - {study.or_or_beta && ( -
- Effect size - - {study.or_or_beta} - {study.ci_text ? {study.ci_text} : null} - - {interpretEffectSize(study.or_or_beta)} -
- )} - {study.risk_allele_frequency && ( -
- Variant frequency - {study.risk_allele_frequency} - In population + {study.qualityFlags.length > 0 && ( +
+ {study.qualityFlags.map((flag, index) => ( +
+ {flag.message}
- )} -
- Confidence - - - {study.confidenceBand === "high" ? "High" : study.confidenceBand === "medium" ? "Medium" : "Lower"} - - - - {study.confidenceBand === "high" ? "Well-replicated" : study.confidenceBand === "medium" ? "Some caveats" : "Interpret carefully"} - -
+ ))}
+ )} +
- {/* Genetic variants */} - {study.snps && ( -
- Variants tested - -
- )} - - {/* Sample breakdown */} - {(study.initial_sample_size || study.replication_sample_size) && ( -
- {study.initial_sample_size && ( -

- Initial sample - {study.initial_sample_size} -

- )} - {study.replication_sample_size && ( -

- Replication - {study.replication_sample_size} -

- )} -
- )} - - {/* Quality flags */} - {study.qualityFlags.length > 0 && ( -
- {study.qualityFlags.map((flag, index) => ( -
- {flag.message} -
- ))} -
- )} -
- - {/* Bottom nav */} -
- {navButtons} -
-
-
-
- + {/* Bottom nav */} +
+ +
+
+
+
); } diff --git a/lib/study-service.ts b/lib/study-service.ts new file mode 100644 index 0000000..c3de9cf --- /dev/null +++ b/lib/study-service.ts @@ -0,0 +1,138 @@ +import { executeQuery } from "@/lib/db"; +import { + computeQualityFlags, + formatNumber, + formatPValue, + parseLogPValue, + parsePValue, + parseSampleSize, +} from "@/lib/parsing"; + +type ConfidenceBand = "high" | "medium" | "low"; + +export type StudyData = { + 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; + isAnalyzable: boolean; + nonAnalyzableReason?: string; +}; + +type RawRow = { + 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; +}; + +function determineConfidenceBand( + sampleSize: number | null, + pValue: number | null, + logPValue: number | null, + qualityFlags: Array<{ severity: string }>, +): ConfidenceBand { + const hasMajorFlags = qualityFlags.some(f => f.severity === "major"); + if (hasMajorFlags) return "low"; + const meetsHigh = + sampleSize !== null && sampleSize >= 5000 && + logPValue !== null && logPValue >= 9 && + (pValue === null || pValue <= 5e-9); + if (meetsHigh) return "high"; + const meetsMedium = + ((sampleSize ?? 0) >= 2000 || (logPValue ?? 0) >= 7) && + (pValue === null || pValue <= 1e-6); + if (meetsMedium) return "medium"; + return "low"; +} + +function parsePublicationDate(value: string | null): number | null { + if (!value) return null; + const ts = Date.parse(value.trim()); + return Number.isNaN(ts) ? null : ts; +} + +export async function fetchStudyById(studyId: number): Promise { + 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`, + [studyId], + ); + + if (rows.length === 0) return null; + 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 = parsePublicationDate(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; + + return { + ...row, + sampleSize, + sampleSizeLabel: formatNumber(sampleSize), + pValueNumeric, + pValueLabel: formatPValue(pValueNumeric), + logPValue, + qualityFlags, + isLowQuality, + confidenceBand, + publicationDate, + isAnalyzable, + nonAnalyzableReason, + }; +} 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. diff --git a/next.config.mjs b/next.config.mjs index d45615c..c19c39f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,8 @@ const nextConfig = { // Mark server-only packages as external (prevents bundling for browser) // Nillion packages removed - need webpack bundling to apply libsodium alias serverExternalPackages: [ + 'pg', + 'pg-native', 'onnxruntime-node', 'sharp', 'alchemy-sdk', @@ -85,6 +87,9 @@ const nextConfig = { util: false, assert: false, 'pino-pretty': false, + dns: false, + net: false, + tls: false, }; } From 44cfc1b0236a17e3bdfb549f80d158202b4fd32e Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 18 Jun 2026 11:41:07 -0400 Subject: [PATCH 3/4] Added a Stripe analysis script. --- scripts/stripe-subscribers.mjs | 166 +++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 scripts/stripe-subscribers.mjs diff --git a/scripts/stripe-subscribers.mjs b/scripts/stripe-subscribers.mjs new file mode 100644 index 0000000..6968b6c --- /dev/null +++ b/scripts/stripe-subscribers.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Fetches subscriber data from Stripe: active and cancelled subscriptions, + * tenure, and total spend per customer. + * + * Setup: + * Add STRIPE_SECRET_KEY=sk_live_... to scripts/ga-performance.env + * node scripts/stripe-subscribers.mjs + * + * Output: summary printed to stdout, full data written to /tmp/stripe-subscribers-.json + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadEnv(envPath) { + const env = {}; + for (const raw of readFileSync(envPath, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + env[key] = val; + } + return env; +} + +async function stripeGet(path, secretKey, params = {}, expand = []) { + const url = new URL(`https://api.stripe.com/v1/${path}`); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + for (const e of expand) url.searchParams.append('expand[]', e); + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${secretKey}` }, + }); + if (!res.ok) throw new Error(`Stripe ${path} failed (${res.status}): ${await res.text()}`); + return res.json(); +} + +async function paginate(path, secretKey, params = {}, expand = []) { + const items = []; + let startingAfter = null; + while (true) { + const p = { limit: '100', ...params }; + if (startingAfter) p.starting_after = startingAfter; + const page = await stripeGet(path, secretKey, p, expand); + items.push(...page.data); + if (!page.has_more) break; + startingAfter = page.data[page.data.length - 1].id; + } + return items; +} + +function formatDate(ts) { + if (!ts) return 'n/a'; + return new Date(ts * 1000).toISOString().slice(0, 10); +} + +function tenureDays(createdTs, endedTs) { + const end = endedTs ? endedTs * 1000 : Date.now(); + return Math.round((end - createdTs * 1000) / (1000 * 60 * 60 * 24)); +} + +async function main() { + const envPath = join(__dirname, 'ga-performance.env'); + const env = loadEnv(envPath); + + const secretKey = env.STRIPE_SECRET_KEY; + if (!secretKey || secretKey.startsWith('sk_test_placeholder')) { + throw new Error('Add STRIPE_SECRET_KEY=sk_live_... to scripts/ga-performance.env'); + } + + console.log('Fetching subscriptions...'); + const [activeSubs, cancelledSubs] = await Promise.all([ + paginate('subscriptions', secretKey, { status: 'active' }, ['data.customer']), + paginate('subscriptions', secretKey, { status: 'canceled' }, ['data.customer']), + ]); + + console.log(` Active: ${activeSubs.length}, Cancelled: ${cancelledSubs.length}`); + console.log('Fetching invoices...'); + + // Collect all customer IDs + const allSubs = [...activeSubs, ...cancelledSubs]; + const customerIds = [...new Set(allSubs.map(s => + typeof s.customer === 'string' ? s.customer : s.customer?.id + ).filter(Boolean))]; + + // Fetch paid invoices per customer + console.log(` Fetching invoices for ${customerIds.length} customers...`); + const spendByCustomer = {}; + for (const customerId of customerIds) { + const invoices = await paginate('invoices', secretKey, { + customer: customerId, + status: 'paid', + }); + spendByCustomer[customerId] = invoices.reduce((sum, inv) => sum + inv.amount_paid, 0); + } + + // Build subscriber records + const records = allSubs.map(sub => { + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + const customerEmail = typeof sub.customer === 'object' ? sub.customer?.email : null; + const tenure = tenureDays(sub.created, sub.ended_at); + const totalSpendCents = spendByCustomer[customerId] || 0; + + return { + subscriptionId: sub.id, + customerId, + customerEmail, + status: sub.status, + created: formatDate(sub.created), + cancelledAt: formatDate(sub.canceled_at), + endedAt: formatDate(sub.ended_at), + tenureDays: tenure, + tenureMonths: (tenure / 30).toFixed(1), + totalSpendUsd: (totalSpendCents / 100).toFixed(2), + }; + }); + + records.sort((a, b) => new Date(b.created) - new Date(a.created)); + + // Summary stats + const active = records.filter(r => r.status === 'active'); + const cancelled = records.filter(r => r.status !== 'active'); + const allSpend = records.map(r => parseFloat(r.totalSpendUsd)); + const cancelledTenures = cancelled.map(r => r.tenureDays); + + const avg = arr => arr.length ? (arr.reduce((a, b) => a + b, 0) / arr.length) : 0; + + console.log('\n--- Summary ---'); + console.log(`Active subscribers: ${active.length}`); + console.log(`Cancelled subscribers: ${cancelled.length}`); + console.log(`Total revenue: $${allSpend.reduce((a, b) => a + b, 0).toFixed(2)}`); + console.log(`Avg spend per customer: $${avg(allSpend).toFixed(2)}`); + if (cancelledTenures.length) { + console.log(`Avg tenure (cancelled): ${avg(cancelledTenures).toFixed(0)} days (${(avg(cancelledTenures) / 30).toFixed(1)} months)`); + } + if (active.length) { + const activeTenures = active.map(r => r.tenureDays); + console.log(`Avg tenure (active): ${avg(activeTenures).toFixed(0)} days (${(avg(activeTenures) / 30).toFixed(1)} months)`); + } + + console.log('\n--- Subscribers ---'); + for (const r of records) { + const label = r.status === 'active' ? '[active] ' : '[cancelled]'; + console.log(`${label} ${r.created} tenure: ${r.tenureMonths}mo spend: $${r.totalSpendUsd} ${r.customerEmail || r.customerId}`); + } + + // Write JSON output + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const outPath = `/tmp/stripe-subscribers-${ts}.json`; + writeFileSync(outPath, JSON.stringify({ summary: { active: active.length, cancelled: cancelled.length }, records }, null, 2)); + console.log(`\nFull data written to: ${outPath}`); +} + +main().catch(err => { + console.error(`\nError: ${err.message}`); + process.exit(1); +}); From a5985b7c0e13c79f01e513b134a62defc8d41572 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 18 Jun 2026 15:31:29 -0400 Subject: [PATCH 4/4] Fixed some copy. --- app/subscribe/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/subscribe/page.tsx b/app/subscribe/page.tsx index a592693..22cc0f7 100644 --- a/app/subscribe/page.tsx +++ b/app/subscribe/page.tsx @@ -53,7 +53,7 @@ function SubscribeContent() {
Premium -

Subscribe to Monadic DNA Premium

+

Subscribe to Monadic DNA Explorer Premium

$4.99/month. Cancel any time.