Skip to content

Commit

Permalink
Territory analytics (#1926)
Browse files Browse the repository at this point in the history
* add territory to analytics selectors

* implement territory analytics, revert user satistics header

* fix linting errors

* disallow some territory names

* fix linting error

* minor adjustments to header

* escape input

* 404 on non-existant sub

* exclude unused queries depending on sub select

---------

Co-authored-by: Keyan <[email protected]>
Co-authored-by: k00b <[email protected]>
  • Loading branch information
3 people authored Mar 1, 2025
1 parent 5de9d92 commit 73170ba
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 122 deletions.
33 changes: 33 additions & 0 deletions api/resolvers/growth.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,39 @@ export default {
FROM ${viewGroup(range, 'stacking_growth')}
GROUP BY time
ORDER BY time ASC`, ...range)
},
itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)

const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')

return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)),
json_build_object('name', 'comments', 'value', coalesce(sum(comments),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
},
revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)

const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')

return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)),
json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)),
json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
}
}
}
2 changes: 2 additions & 0 deletions api/typeDefs/growth.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default gql`
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
}
type TimeData {
Expand Down
2 changes: 1 addition & 1 deletion components/footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export default function Footer ({ links = true }) {
<Rewards />
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>
<Link href='/stackers/day' className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/stackers/all/day' className='nav-link p-0 p-0 d-inline-flex'>
analytics
</Link>
<span className='mx-2 text-muted'> \ </span>
Expand Down
79 changes: 79 additions & 0 deletions components/sub-analytics-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useRouter } from 'next/router'
import { Select, DatePicker } from './form'
import { useSubs } from './sub-select'
import { WHENS } from '@/lib/constants'
import { whenToFrom } from '@/lib/time'
import styles from './sub-select.module.css'
import classNames from 'classnames'

export function SubAnalyticsHeader ({ pathname = null }) {
const router = useRouter()

const path = pathname || 'stackers'

const select = async values => {
const { sub, when, ...query } = values

if (when !== 'custom') { delete query.from; delete query.to }
if (query.from && !query.to) return

await router.push({

pathname: `/${path}/${sub}/${when}`,
query
})
}

const when = router.query.when || 'day'
const sub = router.query.sub || 'all'

const subs = useSubs({ prependSubs: ['all'], sub, appendSubs: [], filterSubs: () => true })

return (
<div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
stacker analytics in
<Select
groupClassName='mb-0 mx-2'
className={classNames(styles.subSelect, styles.subSelectSmall)}
name='sub'
size='sm'
items={subs}
value={sub}
noForm
onChange={(formik, e) => {
const range = when === 'custom' ? { from: router.query.from, to: router.query.to } : {}
select({ sub: e.target.value, when, ...range })
}}
/>
for
<Select
groupClassName='mb-0 mx-2'
className='w-auto'
name='when'
size='sm'
items={WHENS}
value={when}
noForm
onChange={(formik, e) => {
const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: Date.now() } : {}
select({ sub, when: e.target.value, ...range })
}}
/>
</div>
{when === 'custom' &&
<DatePicker
noForm
fromName='from'
toName='to'
className='p-0 px-2 mb-0'
onChange={(formik, [from, to], e) => {
select({ sub, when, from: from.getTime(), to: to.getTime() })
}}
from={router.query.from}
to={router.query.to}
when={when}
/>}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Select, DatePicker } from './form'
import { WHENS } from '@/lib/constants'
import { whenToFrom } from '@/lib/time'

export function UsageHeader ({ pathname = null }) {
export function UserAnalyticsHeader ({ pathname = null }) {
const router = useRouter()

const path = pathname || 'stackers'
const path = pathname || 'satistics/graph'

const select = async values => {
const { when, ...query } = values
Expand Down
1 change: 1 addition & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// to be loaded from the server
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
export const RESERVED_SUB_NAMES = ['all', 'home']

export const PAID_ACTION_PAYMENT_METHODS = {
FEE_CREDIT: 'FEE_CREDIT',
Expand Down
5 changes: 3 additions & 2 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX,
RESERVED_SUB_NAMES
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
Expand Down Expand Up @@ -306,7 +307,7 @@ export function territorySchema (args) {
const isArchived = sub => sub.status === 'STOPPED'
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
const exists = await subExists(name, { ...args, filter })
return !exists
return !exists & !RESERVED_SUB_NAMES.includes(name)
},
message: 'taken'
}),
Expand Down
4 changes: 2 additions & 2 deletions pages/satistics/graphs/[when].js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
import PageLoading from '@/components/page-loading'
import dynamic from 'next/dynamic'
import { numWithUnits } from '@/lib/format'
import { UsageHeader } from '@/components/usage-header'
import { UserAnalyticsHeader } from '@/components/user-analytics-header'
import { SatisticsHeader } from '..'
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
Expand Down Expand Up @@ -55,7 +55,7 @@ export default function Satistics ({ ssrData }) {
<SatisticsHeader />
<div className='tab-content' id='myTabContent'>
<div className='tab-pane fade show active text-muted' id='statistics' role='tabpanel' aria-labelledby='statistics-tab'>
<UsageHeader pathname='satistics/graphs' />
<UserAnalyticsHeader pathname='satistics/graphs' />
<div className='mt-3'>
<div className='d-flex row justify-content-between'>
<div className='col-md-6 mb-2'>
Expand Down
157 changes: 157 additions & 0 deletions pages/stackers/[sub]/[when].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { gql, useQuery } from '@apollo/client'
import { getGetServerSideProps } from '@/api/ssrApollo'
import Layout from '@/components/layout'
import Col from 'react-bootstrap/Col'
import Row from 'react-bootstrap/Row'
import { SubAnalyticsHeader } from '@/components/sub-analytics-header'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import PageLoading from '@/components/page-loading'
import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons'

const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
loading: () => <WhenAreaChartSkeleton />
})
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
loading: () => <WhenLineChartSkeleton />
})
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
loading: () => <WhenComposedChartSkeleton />
})

const GROWTH_QUERY = gql`
query Growth($when: String!, $from: String, $to: String, $sub: String, $subSelect: Boolean = false)
{
registrationGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
itemGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
spendingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
spenderGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
stackingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
stackerGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
time
data {
name
value
}
}
itemGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
time
data {
name
value
}
}
revenueGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
time
data {
name
value
}
}
}`

const variablesFunc = vars => ({ ...vars, subSelect: vars.sub !== 'all' })
export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY, variables: variablesFunc })

export default function Growth ({ ssrData }) {
const router = useRouter()
const { when, from, to, sub } = router.query

const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to, sub, subSelect: sub !== 'all' } })
if (!data && !ssrData) return <PageLoading />

const {
registrationGrowth,
itemGrowth,
spendingGrowth,
spenderGrowth,
stackingGrowth,
stackerGrowth,
itemGrowthSubs,
revenueGrowthSubs
} = data || ssrData

if (sub === 'all') {
return (
<Layout>
<SubAnalyticsHeader />
<Row>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>stackers</div>
<WhenLineChart data={stackerGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>stacking</div>
<WhenAreaChart data={stackingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>spenders</div>
<WhenLineChart data={spenderGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>spending</div>
<WhenAreaChart data={spendingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>registrations</div>
<WhenAreaChart data={registrationGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>items</div>
<WhenComposedChart data={itemGrowth} areaNames={['posts', 'comments', 'jobs']} areaAxis='left' lineNames={['comments/posts', 'territories']} lineAxis='right' barNames={['zaps']} />
</Col>
</Row>
</Layout>
)
} else {
return (
<Layout>
<SubAnalyticsHeader />
<Row>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>items</div>
<WhenLineChart data={itemGrowthSubs} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted fw-bold'>sats</div>
<WhenLineChart data={revenueGrowthSubs} />
</Col>
</Row>
</Layout>
)
}
}
Loading

0 comments on commit 73170ba

Please sign in to comment.