Skip to content

Commit 73170ba

Browse files
ed-kunghuumn
andauthored
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 <[email protected]> Co-authored-by: k00b <[email protected]>
1 parent 5de9d92 commit 73170ba

File tree

10 files changed

+280
-122
lines changed

10 files changed

+280
-122
lines changed

api/resolvers/growth.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,39 @@ export default {
121121
FROM ${viewGroup(range, 'stacking_growth')}
122122
GROUP BY time
123123
ORDER BY time ASC`, ...range)
124+
},
125+
itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
126+
const range = whenRange(when, from, to)
127+
128+
const subExists = await models.sub.findUnique({ where: { name: sub } })
129+
if (!subExists) throw new Error('Sub not found')
130+
131+
return await models.$queryRawUnsafe(`
132+
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
133+
json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)),
134+
json_build_object('name', 'comments', 'value', coalesce(sum(comments),0))
135+
) AS data
136+
FROM ${viewGroup(range, 'sub_stats')}
137+
WHERE sub_name = $3
138+
GROUP BY time
139+
ORDER BY time ASC`, ...range, sub)
140+
},
141+
revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
142+
const range = whenRange(when, from, to)
143+
144+
const subExists = await models.sub.findUnique({ where: { name: sub } })
145+
if (!subExists) throw new Error('Sub not found')
146+
147+
return await models.$queryRawUnsafe(`
148+
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
149+
json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)),
150+
json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)),
151+
json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0))
152+
) AS data
153+
FROM ${viewGroup(range, 'sub_stats')}
154+
WHERE sub_name = $3
155+
GROUP BY time
156+
ORDER BY time ASC`, ...range, sub)
124157
}
125158
}
126159
}

api/typeDefs/growth.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export default gql`
1313
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
1414
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
1515
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
16+
itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
17+
revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
1618
}
1719
1820
type TimeData {

components/footer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export default function Footer ({ links = true }) {
173173
<Rewards />
174174
</div>
175175
<div className='mb-0' style={{ fontWeight: 500 }}>
176-
<Link href='/stackers/day' className='nav-link p-0 p-0 d-inline-flex'>
176+
<Link href='/stackers/all/day' className='nav-link p-0 p-0 d-inline-flex'>
177177
analytics
178178
</Link>
179179
<span className='mx-2 text-muted'> \ </span>

components/sub-analytics-header.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useRouter } from 'next/router'
2+
import { Select, DatePicker } from './form'
3+
import { useSubs } from './sub-select'
4+
import { WHENS } from '@/lib/constants'
5+
import { whenToFrom } from '@/lib/time'
6+
import styles from './sub-select.module.css'
7+
import classNames from 'classnames'
8+
9+
export function SubAnalyticsHeader ({ pathname = null }) {
10+
const router = useRouter()
11+
12+
const path = pathname || 'stackers'
13+
14+
const select = async values => {
15+
const { sub, when, ...query } = values
16+
17+
if (when !== 'custom') { delete query.from; delete query.to }
18+
if (query.from && !query.to) return
19+
20+
await router.push({
21+
22+
pathname: `/${path}/${sub}/${when}`,
23+
query
24+
})
25+
}
26+
27+
const when = router.query.when || 'day'
28+
const sub = router.query.sub || 'all'
29+
30+
const subs = useSubs({ prependSubs: ['all'], sub, appendSubs: [], filterSubs: () => true })
31+
32+
return (
33+
<div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
34+
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
35+
stacker analytics in
36+
<Select
37+
groupClassName='mb-0 mx-2'
38+
className={classNames(styles.subSelect, styles.subSelectSmall)}
39+
name='sub'
40+
size='sm'
41+
items={subs}
42+
value={sub}
43+
noForm
44+
onChange={(formik, e) => {
45+
const range = when === 'custom' ? { from: router.query.from, to: router.query.to } : {}
46+
select({ sub: e.target.value, when, ...range })
47+
}}
48+
/>
49+
for
50+
<Select
51+
groupClassName='mb-0 mx-2'
52+
className='w-auto'
53+
name='when'
54+
size='sm'
55+
items={WHENS}
56+
value={when}
57+
noForm
58+
onChange={(formik, e) => {
59+
const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: Date.now() } : {}
60+
select({ sub, when: e.target.value, ...range })
61+
}}
62+
/>
63+
</div>
64+
{when === 'custom' &&
65+
<DatePicker
66+
noForm
67+
fromName='from'
68+
toName='to'
69+
className='p-0 px-2 mb-0'
70+
onChange={(formik, [from, to], e) => {
71+
select({ sub, when, from: from.getTime(), to: to.getTime() })
72+
}}
73+
from={router.query.from}
74+
to={router.query.to}
75+
when={when}
76+
/>}
77+
</div>
78+
)
79+
}

components/usage-header.js renamed to components/user-analytics-header.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { Select, DatePicker } from './form'
33
import { WHENS } from '@/lib/constants'
44
import { whenToFrom } from '@/lib/time'
55

6-
export function UsageHeader ({ pathname = null }) {
6+
export function UserAnalyticsHeader ({ pathname = null }) {
77
const router = useRouter()
88

9-
const path = pathname || 'stackers'
9+
const path = pathname || 'satistics/graph'
1010

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

lib/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// to be loaded from the server
33
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
44
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
5+
export const RESERVED_SUB_NAMES = ['all', 'home']
56

67
export const PAID_ACTION_PAYMENT_METHODS = {
78
FEE_CREDIT: 'FEE_CREDIT',

lib/validate.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
22
import {
33
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
44
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
5-
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
5+
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX,
6+
RESERVED_SUB_NAMES
67
} from './constants'
78
import { SUPPORTED_CURRENCIES } from './currency'
89
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
@@ -306,7 +307,7 @@ export function territorySchema (args) {
306307
const isArchived = sub => sub.status === 'STOPPED'
307308
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
308309
const exists = await subExists(name, { ...args, filter })
309-
return !exists
310+
return !exists & !RESERVED_SUB_NAMES.includes(name)
310311
},
311312
message: 'taken'
312313
}),

pages/satistics/graphs/[when].js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
66
import PageLoading from '@/components/page-loading'
77
import dynamic from 'next/dynamic'
88
import { numWithUnits } from '@/lib/format'
9-
import { UsageHeader } from '@/components/usage-header'
9+
import { UserAnalyticsHeader } from '@/components/user-analytics-header'
1010
import { SatisticsHeader } from '..'
1111
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
1212
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
@@ -55,7 +55,7 @@ export default function Satistics ({ ssrData }) {
5555
<SatisticsHeader />
5656
<div className='tab-content' id='myTabContent'>
5757
<div className='tab-pane fade show active text-muted' id='statistics' role='tabpanel' aria-labelledby='statistics-tab'>
58-
<UsageHeader pathname='satistics/graphs' />
58+
<UserAnalyticsHeader pathname='satistics/graphs' />
5959
<div className='mt-3'>
6060
<div className='d-flex row justify-content-between'>
6161
<div className='col-md-6 mb-2'>

pages/stackers/[sub]/[when].js

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { gql, useQuery } from '@apollo/client'
2+
import { getGetServerSideProps } from '@/api/ssrApollo'
3+
import Layout from '@/components/layout'
4+
import Col from 'react-bootstrap/Col'
5+
import Row from 'react-bootstrap/Row'
6+
import { SubAnalyticsHeader } from '@/components/sub-analytics-header'
7+
import { useRouter } from 'next/router'
8+
import dynamic from 'next/dynamic'
9+
import PageLoading from '@/components/page-loading'
10+
import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons'
11+
12+
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
13+
loading: () => <WhenAreaChartSkeleton />
14+
})
15+
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
16+
loading: () => <WhenLineChartSkeleton />
17+
})
18+
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
19+
loading: () => <WhenComposedChartSkeleton />
20+
})
21+
22+
const GROWTH_QUERY = gql`
23+
query Growth($when: String!, $from: String, $to: String, $sub: String, $subSelect: Boolean = false)
24+
{
25+
registrationGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
26+
time
27+
data {
28+
name
29+
value
30+
}
31+
}
32+
itemGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
33+
time
34+
data {
35+
name
36+
value
37+
}
38+
}
39+
spendingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
40+
time
41+
data {
42+
name
43+
value
44+
}
45+
}
46+
spenderGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
47+
time
48+
data {
49+
name
50+
value
51+
}
52+
}
53+
stackingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
54+
time
55+
data {
56+
name
57+
value
58+
}
59+
}
60+
stackerGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
61+
time
62+
data {
63+
name
64+
value
65+
}
66+
}
67+
itemGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
68+
time
69+
data {
70+
name
71+
value
72+
}
73+
}
74+
revenueGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
75+
time
76+
data {
77+
name
78+
value
79+
}
80+
}
81+
}`
82+
83+
const variablesFunc = vars => ({ ...vars, subSelect: vars.sub !== 'all' })
84+
export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY, variables: variablesFunc })
85+
86+
export default function Growth ({ ssrData }) {
87+
const router = useRouter()
88+
const { when, from, to, sub } = router.query
89+
90+
const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to, sub, subSelect: sub !== 'all' } })
91+
if (!data && !ssrData) return <PageLoading />
92+
93+
const {
94+
registrationGrowth,
95+
itemGrowth,
96+
spendingGrowth,
97+
spenderGrowth,
98+
stackingGrowth,
99+
stackerGrowth,
100+
itemGrowthSubs,
101+
revenueGrowthSubs
102+
} = data || ssrData
103+
104+
if (sub === 'all') {
105+
return (
106+
<Layout>
107+
<SubAnalyticsHeader />
108+
<Row>
109+
<Col className='mt-3'>
110+
<div className='text-center text-muted fw-bold'>stackers</div>
111+
<WhenLineChart data={stackerGrowth} />
112+
</Col>
113+
<Col className='mt-3'>
114+
<div className='text-center text-muted fw-bold'>stacking</div>
115+
<WhenAreaChart data={stackingGrowth} />
116+
</Col>
117+
</Row>
118+
<Row>
119+
<Col className='mt-3'>
120+
<div className='text-center text-muted fw-bold'>spenders</div>
121+
<WhenLineChart data={spenderGrowth} />
122+
</Col>
123+
<Col className='mt-3'>
124+
<div className='text-center text-muted fw-bold'>spending</div>
125+
<WhenAreaChart data={spendingGrowth} />
126+
</Col>
127+
</Row>
128+
<Row>
129+
<Col className='mt-3'>
130+
<div className='text-center text-muted fw-bold'>registrations</div>
131+
<WhenAreaChart data={registrationGrowth} />
132+
</Col>
133+
<Col className='mt-3'>
134+
<div className='text-center text-muted fw-bold'>items</div>
135+
<WhenComposedChart data={itemGrowth} areaNames={['posts', 'comments', 'jobs']} areaAxis='left' lineNames={['comments/posts', 'territories']} lineAxis='right' barNames={['zaps']} />
136+
</Col>
137+
</Row>
138+
</Layout>
139+
)
140+
} else {
141+
return (
142+
<Layout>
143+
<SubAnalyticsHeader />
144+
<Row>
145+
<Col className='mt-3'>
146+
<div className='text-center text-muted fw-bold'>items</div>
147+
<WhenLineChart data={itemGrowthSubs} />
148+
</Col>
149+
<Col className='mt-3'>
150+
<div className='text-center text-muted fw-bold'>sats</div>
151+
<WhenLineChart data={revenueGrowthSubs} />
152+
</Col>
153+
</Row>
154+
</Layout>
155+
)
156+
}
157+
}

0 commit comments

Comments
 (0)