From 44992fd1bf9ac370eaed96875026a3a960e6c3b4 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 6 Aug 2025 18:32:37 -0500 Subject: [PATCH] add kpi script --- scripts/kpi.js | 430 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 scripts/kpi.js diff --git a/scripts/kpi.js b/scripts/kpi.js new file mode 100644 index 00000000..8ec7f83e --- /dev/null +++ b/scripts/kpi.js @@ -0,0 +1,430 @@ +const { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client') +const fetch = require('cross-fetch') + +// Configuration +const GRAPHQL_ENDPOINT = process.env.GRAPHQL_ENDPOINT || 'http://localhost:3000/api/graphql' +const PLAUSIBLE_API_KEY = process.env.PLAUSIBLE_API_KEY + +// Territory profitability threshold (in sats) +const TERRITORY_PROFIT_THRESHOLD = 50000 + +// Apollo Client setup +const client = new ApolloClient({ + link: new HttpLink({ + uri: GRAPHQL_ENDPOINT, + fetch + }), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + } + } +}) + +// GraphQL queries +const GROWTH_QUERY = gql` + query Growth($when: String!, $from: String, $to: String) { + registrationGrowth(when: $when, from: $from, to: $to) { + time + data { + name + value + } + } + spendingGrowth(when: $when, from: $from, to: $to) { + time + data { + name + value + } + } + spenderGrowth(when: $when, from: $from, to: $to) { + time + data { + name + value + } + } + stackingGrowth(when: $when, from: $from, to: $to) { + time + data { + name + value + } + } + } +` + +const TOP_SUBS_QUERY = gql` + query TopSubs($when: String, $from: String, $to: String, $by: String) { + topSubs(when: $when, from: $from, to: $to, by: $by, limit: 200) { + subs { + name + status + optional { + revenue(when: $when, from: $from, to: $to) + } + } + } + } +` + +// Utility functions +function formatNumber (num) { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'm' + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'k' + } + return num.toString() +} + +function formatSats (sats) { + return formatNumber(sats) + ' sats' +} + +function calculatePercentChange (current, previous) { + if (previous === 0) return '+100%' + const change = ((current - previous) / previous) * 100 + const sign = change >= 0 ? '+' : '' + return `${sign}${change.toFixed(1)}%` +} + +function formatMonth (monthStr) { + // monthStr format: "2025-07" or "2025-07-01" + const yearMonth = monthStr.slice(0, 7) // Get "2025-07" part + const [year, month] = yearMonth.split('-') + const monthNames = [ + 'january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december' + ] + return `${monthNames[parseInt(month) - 1]} ${year}` +} + +// Data processing functions + +function processRegistrationData (registrationData) { + const monthlyData = {} + + registrationData.forEach(entry => { + const month = new Date(entry.time).toISOString().slice(0, 7) // YYYY-MM + + // Find referrals and organic data points + const referrals = entry.data.find(d => d.name === 'referrals')?.value || 0 + const organic = entry.data.find(d => d.name === 'organic')?.value || 0 + const total = referrals + organic + + if (!monthlyData[month]) { + monthlyData[month] = 0 + } + monthlyData[month] += total + }) + + return Object.entries(monthlyData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, value]) => ({ + month, + value + })) +} + +function processSpenderData (spenderData) { + const monthlyData = {} + + spenderData.forEach(entry => { + const month = new Date(entry.time).toISOString().slice(0, 7) // YYYY-MM + const anySpenders = entry.data.find(d => d.name === 'any') + + if (anySpenders) { + // With year aggregation, each entry should be monthly aggregated data + monthlyData[month] = anySpenders.value + } + }) + + return Object.entries(monthlyData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, value]) => ({ + month, + value + })) +} + +function processAllSpending (spendingData) { + const monthlyData = {} + + spendingData.forEach(entry => { + const month = new Date(entry.time).toISOString().slice(0, 7) // YYYY-MM + + // Sum all spending categories: jobs, boost, fees, zaps, donations, territories + const total = entry.data.reduce((sum, item) => { + return sum + (item.value || 0) + }, 0) + + if (!monthlyData[month]) { + monthlyData[month] = 0 + } + monthlyData[month] += total + }) + + return Object.entries(monthlyData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, value]) => ({ + month, + value + })) +} + +// Temporarily commented out - not currently used +// function processAllStacking (stackingData) { ... } + +function processTerritoryRevenue (stackingData) { + const monthlyData = {} + + stackingData.forEach(entry => { + const month = new Date(entry.time).toISOString().slice(0, 7) // YYYY-MM + + // Only include territories revenue + const territoriesRevenue = entry.data.find(d => d.name === 'territories')?.value || 0 + + if (!monthlyData[month]) { + monthlyData[month] = 0 + } + monthlyData[month] += territoriesRevenue + }) + + return Object.entries(monthlyData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, value]) => ({ + month, + value + })) +} + +// Removed - using inline date calculations for exact month alignment + +// Removed - using inline territory queries for exact month alignment + +// Removed - using inline Plausible queries for exact month alignment + +async function generateKPISummary (monthsBack = 6) { + try { + console.log(`\n# Stacker News KPI Summary (Last ${monthsBack} months)\n`) + + const now = new Date() + console.log('Current date:', now.toISOString().slice(0, 10)) + console.log('Current month:', now.toISOString().slice(0, 7)) + + // Fetch growth data using year aggregation to get monthly values + const { data: growthData } = await client.query({ + query: GROWTH_QUERY, + variables: { + when: 'year' + } + }) + + // Process registrations using corrected function + const allRegistrations = processRegistrationData(growthData.registrationGrowth) + // Take complete months (exclude current incomplete month) + 1 extra for comparison + const registrations = allRegistrations.slice(-(monthsBack + 2), -1) + + // Process other metrics + const allSpenders = processSpenderData(growthData.spenderGrowth) + const spenders = allSpenders.slice(-(monthsBack + 2), -1) + + console.log('All available months:', allRegistrations.map(r => r.month)) + console.log('Selected months for display:', registrations.map(r => r.month)) + + // Sum all spending categories for total bitcoin volume + const allSpending = processAllSpending(growthData.spendingGrowth) + const spending = allSpending.slice(-(monthsBack + 2), -1) + + // Get territory revenue from spending data (what users paid for territories) + const allRevenue = processTerritoryRevenue(growthData.spendingGrowth) + const revenue = allRevenue.slice(-(monthsBack + 2), -1) + + // Get territory profits data for the exact same months as registrations + const territoryProfits = [] + + for (const regData of registrations) { + // For each month in registrations, get the corresponding territory profits + const month = regData.month + const [year, monthNum] = month.split('-').map(Number) + + try { + // Query this specific month + const startDate = new Date(year, monthNum - 1, 1) + const endDate = new Date(year, monthNum, 0, 23, 59, 59, 999) + const from = startDate.getTime().toString() + const to = endDate.getTime().toString() + + console.log(`Querying territory profits for ${month}: ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}`) + + const { data } = await client.query({ + query: TOP_SUBS_QUERY, + variables: { + when: 'custom', + from, + to, + by: 'revenue' + } + }) + + const profitableTerritories = data.topSubs.subs.filter(sub => { + if (sub.status === 'STOPPED') return false + + const revenue = sub.optional?.revenue || 0 + + return revenue > TERRITORY_PROFIT_THRESHOLD + }) + + territoryProfits.push({ + month, + value: profitableTerritories.length + }) + } catch (error) { + console.warn(`Failed to fetch territory profits for ${month}:`, error.message) + territoryProfits.push({ month, value: 'N/A' }) + } + } + + // Get Plausible data for the exact same months as registrations + const pageViews = [] + const visitors = [] + + for (const regData of registrations) { + // For each month in registrations, get the corresponding Plausible data + const month = regData.month + const [year, monthNum] = month.split('-').map(Number) + + try { + // Query this specific month + const startDate = new Date(year, monthNum - 1, 1) + const endDate = new Date(year, monthNum, 0) + const dateRange = `${startDate.toISOString().slice(0, 10)},${endDate.toISOString().slice(0, 10)}` + + console.log(`Querying Plausible for ${month}: ${dateRange}`) + + // Get pageviews + const pvResponse = await fetch( + `https://plausible.io/api/v1/stats/timeseries?site_id=stacker.news&period=custom&date=${dateRange}&interval=month&metrics=pageviews`, + { + headers: { Authorization: `Bearer ${PLAUSIBLE_API_KEY}` } + } + ) + const pvData = await pvResponse.json() + const pageviewValue = pvData.results?.[0]?.pageviews || 'N/A' + pageViews.push({ month, value: pageviewValue }) + + // Get visitors + const vResponse = await fetch( + `https://plausible.io/api/v1/stats/timeseries?site_id=stacker.news&period=custom&date=${dateRange}&interval=month&metrics=visitors`, + { + headers: { Authorization: `Bearer ${PLAUSIBLE_API_KEY}` } + } + ) + const vData = await vResponse.json() + const visitorValue = vData.results?.[0]?.visitors || 'N/A' + visitors.push({ month, value: visitorValue }) + } catch (error) { + console.warn(`Failed to fetch Plausible data for ${month}:`, error.message) + pageViews.push({ month, value: 'N/A' }) + visitors.push({ month, value: 'N/A' }) + } + } + + console.log('Registration months:', registrations.map(r => r.month)) + console.log('PageViews months:', pageViews.map(p => p.month)) + console.log('Visitors months:', visitors.map(v => v.month)) + console.log('Territory Profits months:', territoryProfits.map(t => t.month)) + + // Display results + console.log('## Registrations:') + // Start from index 1 if we have extra data for comparison, otherwise start from 0 + const startIndex = registrations.length > monthsBack ? 1 : 0 + for (let i = startIndex; i < registrations.length; i++) { + const current = registrations[i] + const previous = registrations[i - 1] + const change = previous ? ` (${calculatePercentChange(current.value, previous.value)})` : '' + console.log(`${formatMonth(current.month + '-01')}: ${current.value}${change}`) + } + + console.log('\n## Unique Visitors:') + for (let i = startIndex; i < visitors.length; i++) { + const current = visitors[i] + const previous = visitors[i - 1] + const change = previous && current.value !== 'N/A' && previous.value !== 'N/A' + ? ` (${calculatePercentChange(current.value, previous.value)})` + : '' + const formattedValue = current.value === 'N/A' ? 'N/A' : formatNumber(current.value) + console.log(`${formatMonth(current.month + '-01')}: ${formattedValue}${change}`) + } + + console.log('\n## Monthly Spenders:') + for (let i = startIndex; i < spenders.length; i++) { + const current = spenders[i] + const previous = spenders[i - 1] + const change = previous ? ` (${calculatePercentChange(current.value, previous.value)})` : '' + console.log(`${formatMonth(current.month + '-01')}: ${current.value}${change}`) + } + + console.log('\n## Page Views:') + for (let i = startIndex; i < pageViews.length; i++) { + const current = pageViews[i] + const previous = pageViews[i - 1] + const change = previous && current.value !== 'N/A' && previous.value !== 'N/A' + ? ` (${calculatePercentChange(current.value, previous.value)})` + : '' + const formattedValue = current.value === 'N/A' ? 'N/A' : formatNumber(current.value) + console.log(`${formatMonth(current.month + '-01')}: ${formattedValue}${change}`) + } + + console.log('\n## Total Bitcoin Volume (Spending):') + for (let i = startIndex; i < spending.length; i++) { + const current = spending[i] + const previous = spending[i - 1] + const change = previous ? ` (${calculatePercentChange(current.value, previous.value)})` : '' + console.log(`${formatMonth(current.month + '-01')}: ${formatSats(current.value)}${change}`) + } + + console.log('\n## Total Bitcoin Revenue (Territory Billing):') + for (let i = startIndex; i < revenue.length; i++) { + const current = revenue[i] + const previous = revenue[i - 1] + const change = previous ? ` (${calculatePercentChange(current.value, previous.value)})` : '' + console.log(`${formatMonth(current.month + '-01')}: ${formatSats(current.value)}${change}`) + } + + console.log('\n## Territories in Profit:') + for (let i = startIndex; i < territoryProfits.length; i++) { + const current = territoryProfits[i] + const previous = territoryProfits[i - 1] + const change = previous && current.value !== 'N/A' && previous.value !== 'N/A' + ? ` (${calculatePercentChange(current.value, previous.value)})` + : '' + console.log(`${formatMonth(current.month + '-01')}: ${current.value}${change}`) + } + + console.log('\n---') + if (!PLAUSIBLE_API_KEY) { + console.log('Note: Set PLAUSIBLE_API_KEY environment variable to fetch page views and visitor data.') + } + console.log('Usage: PLAUSIBLE_API_KEY=your_key node scripts/kpi.js [months]\n') + } catch (error) { + console.error('Error generating KPI summary:', error) + process.exit(1) + } +} + +// CLI interface +const args = process.argv.slice(2) +const monthsBack = args[0] ? parseInt(args[0]) : 6 + +if (isNaN(monthsBack) || monthsBack < 1) { + console.error('Usage: node scripts/kpi.js [months_back]') + console.error('Example: node scripts/kpi.js 12 # for last 12 months') + console.error('Example: PLAUSIBLE_API_KEY=key node scripts/kpi.js 6') + process.exit(1) +} + +generateKPISummary(monthsBack).catch(console.error)