Territory analytics (#1926)
* 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 <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
5de9d92af2
commit
73170ba8a2
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
79
components/sub-analytics-header.js
Normal file
79
components/sub-analytics-header.js
Normal file
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
@ -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',
|
||||
|
@ -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'
|
||||
@ -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'
|
||||
}),
|
||||
|
@ -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'
|
||||
@ -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'>
|
||||
|
157
pages/stackers/[sub]/[when].js
Normal file
157
pages/stackers/[sub]/[when].js
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
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 { UsageHeader } from '@/components/usage-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)
|
||||
{
|
||||
registrationGrowth(when: $when, from: $from, to: $to) {
|
||||
time
|
||||
data {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
itemGrowth(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
|
||||
}
|
||||
}
|
||||
stackerGrowth(when: $when, from: $from, to: $to) {
|
||||
time
|
||||
data {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY })
|
||||
|
||||
export default function Growth ({ ssrData }) {
|
||||
const router = useRouter()
|
||||
const { when, from, to } = router.query
|
||||
|
||||
const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to } })
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
|
||||
const { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } = data || ssrData
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<UsageHeader />
|
||||
<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>
|
||||
)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user