Add dashboard to satistics page (#1099)
* updated graphs ad queries * fixed query * fixed graph data pull * converted msats to sats * linter fix * Fixed labels for graphs * linter * linter * feat: style header * lint * fix: mobile navbar link and graph titles * style charts * change key names * refine satistics graphs --------- Co-authored-by: Dillon <dilloncortez@gmail.com> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
parent
4be54b74df
commit
8a735791ce
|
@ -7,7 +7,7 @@ import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '
|
||||||
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
|
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
|
||||||
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
|
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
|
||||||
import { viewGroup } from './growth'
|
import { viewGroup } from './growth'
|
||||||
import { whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
|
|
||||||
const contributors = new Set()
|
const contributors = new Set()
|
||||||
|
@ -488,6 +488,50 @@ export default {
|
||||||
FROM users
|
FROM users
|
||||||
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}))
|
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}))
|
||||||
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
||||||
|
},
|
||||||
|
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
|
||||||
|
const range = whenRange(when, from, to)
|
||||||
|
return await models.$queryRawUnsafe(`
|
||||||
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
||||||
|
json_build_array(
|
||||||
|
json_build_object('name', 'comments', 'value', COALESCE(SUM(comments), 0)),
|
||||||
|
json_build_object('name', 'posts', 'value', COALESCE(SUM(posts), 0)),
|
||||||
|
json_build_object('name', 'territories', 'value', COALESCE(SUM(territories), 0)),
|
||||||
|
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0))
|
||||||
|
) AS data
|
||||||
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
|
WHERE id = ${me.id}
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`, ...range)
|
||||||
|
},
|
||||||
|
userStatsIncomingSats: async (parent, { when, from, to }, { me, models }) => {
|
||||||
|
const range = whenRange(when, from, to)
|
||||||
|
return await models.$queryRawUnsafe(`
|
||||||
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
||||||
|
json_build_array(
|
||||||
|
json_build_object('name', 'zaps', 'value', ROUND(COALESCE(SUM(msats_tipped), 0) / 1000)),
|
||||||
|
json_build_object('name', 'rewards', 'value', ROUND(COALESCE(SUM(msats_rewards), 0) / 1000)),
|
||||||
|
json_build_object('name', 'referrals', 'value', ROUND( COALESCE(SUM(msats_referrals), 0) / 1000)),
|
||||||
|
json_build_object('name', 'territories', 'value', ROUND(COALESCE(SUM(msats_revenue), 0) / 1000))
|
||||||
|
) AS data
|
||||||
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
|
WHERE id = ${me.id}
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`, ...range)
|
||||||
|
},
|
||||||
|
userStatsOutgoingSats: async (parent, { when, from, to }, { me, models }) => {
|
||||||
|
const range = whenRange(when, from, to)
|
||||||
|
return await models.$queryRawUnsafe(`
|
||||||
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
||||||
|
json_build_array(
|
||||||
|
json_build_object('name', 'fees', 'value', FLOOR(COALESCE(SUM(msats_fees), 0) / 1000)),
|
||||||
|
json_build_object('name', 'donations', 'value', FLOOR(COALESCE(SUM(msats_donated), 0) / 1000)),
|
||||||
|
json_build_object('name', 'territories', 'value', FLOOR(COALESCE(SUM(msats_billing), 0) / 1000))
|
||||||
|
) AS data
|
||||||
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
|
WHERE id = ${me.id}
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`, ...range)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,9 @@ export default gql`
|
||||||
hasNewNotes: Boolean!
|
hasNewNotes: Boolean!
|
||||||
mySubscribedUsers(cursor: String): Users!
|
mySubscribedUsers(cursor: String): Users!
|
||||||
myMutedUsers(cursor: String): Users!
|
myMutedUsers(cursor: String): Users!
|
||||||
|
userStatsActions(when: String, from: String, to: String): [TimeData!]!
|
||||||
|
userStatsIncomingSats(when: String, from: String, to: String): [TimeData!]!
|
||||||
|
userStatsOutgoingSats(when: String, from: String, to: String): [TimeData!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsersNullable {
|
type UsersNullable {
|
||||||
|
@ -182,4 +185,14 @@ export default gql`
|
||||||
twitterId: String
|
twitterId: String
|
||||||
nostrAuthPubkey: String
|
nostrAuthPubkey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NameValue {
|
||||||
|
name: String!
|
||||||
|
value: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeData {
|
||||||
|
time: Date!
|
||||||
|
data: [NameValue!]!
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -186,7 +186,7 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||||
<Link href='/wallet' passHref legacyBehavior>
|
<Link href='/wallet' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
<Link href='/satistics/history?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Dropdown.Divider />
|
<Dropdown.Divider />
|
||||||
|
|
|
@ -64,7 +64,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||||
<Link href='/wallet' passHref legacyBehavior>
|
<Link href='/wallet' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
<Link href='/satistics/history/?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Dropdown.Divider />
|
<Dropdown.Divider />
|
||||||
|
|
|
@ -3,9 +3,11 @@ import { Select, DatePicker } from './form'
|
||||||
import { WHENS } from '@/lib/constants'
|
import { WHENS } from '@/lib/constants'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
|
|
||||||
export function UsageHeader () {
|
export function UsageHeader ({ pathname = null }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const path = pathname || 'stackers'
|
||||||
|
|
||||||
const select = async values => {
|
const select = async values => {
|
||||||
const { when, ...query } = values
|
const { when, ...query } = values
|
||||||
|
|
||||||
|
@ -13,7 +15,8 @@ export function UsageHeader () {
|
||||||
if (query.from && !query.to) return
|
if (query.from && !query.to) return
|
||||||
|
|
||||||
await router.push({
|
await router.push({
|
||||||
pathname: `/stackers/${when}`,
|
|
||||||
|
pathname: `/${path}/${when}`,
|
||||||
query
|
query
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -353,3 +353,28 @@ export const USER_WITH_SUBS = gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
export const USER_STATS = gql`
|
||||||
|
query UserStats($when: String, $from: String, $to: String) {
|
||||||
|
userStatsActions(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userStatsIncomingSats(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userStatsOutgoingSats(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Layout from '@/components/layout'
|
||||||
|
import { USER_STATS } from '@/fragments/users'
|
||||||
|
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 { SatisticsHeader } from '../history'
|
||||||
|
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||||
|
import Tooltip from 'react-bootstrap/Tooltip'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({ query: USER_STATS, authRequired: true })
|
||||||
|
|
||||||
|
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
|
||||||
|
loading: () => <WhenAreaChartSkeleton />
|
||||||
|
})
|
||||||
|
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
||||||
|
loading: () => <WhenComposedChartSkeleton />
|
||||||
|
})
|
||||||
|
|
||||||
|
const SatisticsTooltip = ({ children, overlayText }) => {
|
||||||
|
return (
|
||||||
|
<OverlayTrigger
|
||||||
|
placement='bottom'
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{overlayText}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Satistics ({ ssrData }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { when, from, to } = router.query
|
||||||
|
|
||||||
|
const { data } = useQuery(USER_STATS, { variables: { when, from, to } })
|
||||||
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
const { userStatsActions, userStatsIncomingSats, userStatsOutgoingSats } = data || ssrData
|
||||||
|
|
||||||
|
const totalStacked = userStatsIncomingSats.reduce((total, a) => total + a.data?.reduce((acc, d) => acc + d.value, 0), 0)
|
||||||
|
const totalSpent = userStatsOutgoingSats.reduce((total, a) => total + a.data?.reduce((acc, d) => acc + d.value, 0), 0)
|
||||||
|
const totalEngagement = userStatsActions.reduce((total, a) => total + a.data?.reduce((acc, d) => acc + d.value, 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className='mt-2'>
|
||||||
|
<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' />
|
||||||
|
<div className='mt-3'>
|
||||||
|
<div className='d-flex row justify-content-between'>
|
||||||
|
<div className='col-md-4 mb-2'>
|
||||||
|
<h4>stacked</h4>
|
||||||
|
<div className='card'>
|
||||||
|
<div className='card-body'>
|
||||||
|
<SatisticsTooltip overlayText={numWithUnits(totalStacked, { abbreviate: false, format: true })}>
|
||||||
|
<h2 className='text-center text-nowrap mb-0 text-muted'>
|
||||||
|
{numWithUnits(totalStacked, { abbreviate: true, format: true })}
|
||||||
|
</h2>
|
||||||
|
</SatisticsTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='col-md-4 mb-2'>
|
||||||
|
<h4>spent</h4>
|
||||||
|
<div className='card'>
|
||||||
|
<div className='card-body'>
|
||||||
|
<SatisticsTooltip overlayText={numWithUnits(totalSpent, { abbreviate: false, format: true })}>
|
||||||
|
<h2 className='text-center text-nowrap mb-0 text-muted'>
|
||||||
|
{numWithUnits(totalSpent, { abbreviate: true, format: true })}
|
||||||
|
</h2>
|
||||||
|
</SatisticsTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='col-md-4'>
|
||||||
|
<h4>actions</h4>
|
||||||
|
<div className='card'>
|
||||||
|
<div className='card-body'>
|
||||||
|
<h2 className='text-center mb-0 text-muted'>
|
||||||
|
{new Intl.NumberFormat().format(totalEngagement)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='row mt-5'>
|
||||||
|
{userStatsIncomingSats.length > 0 &&
|
||||||
|
<div className='col-md-6'>
|
||||||
|
<div className='text-center text-muted fw-bold'>stacking</div>
|
||||||
|
<WhenAreaChart data={userStatsIncomingSats} />
|
||||||
|
</div>}
|
||||||
|
{userStatsOutgoingSats.length > 0 &&
|
||||||
|
<div className='col-md-6'>
|
||||||
|
<div className='text-center text-muted fw-bold'>spending</div>
|
||||||
|
<WhenAreaChart data={userStatsOutgoingSats} />
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className='row mt-5'>
|
||||||
|
{userStatsActions.length > 0 &&
|
||||||
|
<div className='col-md-12'>
|
||||||
|
<div className='text-center text-muted fw-bold'>items</div>
|
||||||
|
<WhenComposedChart data={userStatsActions} areaNames={['posts', 'comments']} areaAxis='left' lineNames={['territories', 'referrals']} lineAxis='right' />
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Nav from 'react-bootstrap/Nav'
|
||||||
import Layout from '@/components/layout'
|
import Layout from '@/components/layout'
|
||||||
import MoreFooter from '@/components/more-footer'
|
import MoreFooter from '@/components/more-footer'
|
||||||
import { WALLET_HISTORY } from '@/fragments/wallet'
|
import { WALLET_HISTORY } from '@/fragments/wallet'
|
||||||
|
@ -16,6 +17,7 @@ import ItemJob from '@/components/item-job'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
import PayerData from '@/components/payer-data'
|
import PayerData from '@/components/payer-data'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
|
import navStyles from '../settings/settings.module.css'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
|
||||||
|
|
||||||
|
@ -163,6 +165,32 @@ function Fact ({ fact }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SatisticsHeader () {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathParts = router.asPath.split('?')[0].split('/').filter(segment => !!segment)
|
||||||
|
const activeKey = pathParts[1] ?? 'history'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className='mb-2 text-start'>satistics</h2>
|
||||||
|
<Nav
|
||||||
|
className={navStyles.nav}
|
||||||
|
activeKey={activeKey}
|
||||||
|
>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/satistics/history?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||||
|
<Nav.Link eventKey='history'>history</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/satistics/graphs/day' passHref legacyBehavior>
|
||||||
|
<Nav.Link eventKey='graphs'>graphs</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
</Nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Satistics ({ ssrData }) {
|
export default function Satistics ({ ssrData }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } })
|
const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } })
|
||||||
|
@ -179,7 +207,7 @@ export default function Satistics ({ ssrData }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const incstr = [...inc].join(',')
|
const incstr = [...inc].join(',')
|
||||||
router.push(`/satistics?inc=${incstr}`)
|
router.push(`/satistics/history?inc=${incstr}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function included (filter) {
|
function included (filter) {
|
||||||
|
@ -192,7 +220,7 @@ export default function Satistics ({ ssrData }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
<h2 className='text-center'>satistics</h2>
|
<SatisticsHeader />
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
invoice: included('invoice'),
|
invoice: included('invoice'),
|
Loading…
Reference in New Issue