add kpi script
This commit is contained in:
parent
e0ddba09a8
commit
44992fd1bf
430
scripts/kpi.js
Normal file
430
scripts/kpi.js
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user