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 { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
|
||||
import { viewGroup } from './growth'
|
||||
import { whenRange } from '@/lib/time'
|
||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||
import assertApiKeyNotPermitted from './apiKey'
|
||||
|
||||
const contributors = new Set()
|
||||
|
@ -488,6 +488,50 @@ export default {
|
|||
FROM users
|
||||
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}`
|
||||
},
|
||||
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!
|
||||
mySubscribedUsers(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 {
|
||||
|
@ -182,4 +185,14 @@ export default gql`
|
|||
twitterId: 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>
|
||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||
</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>
|
||||
</Link>
|
||||
<Dropdown.Divider />
|
||||
|
|
|
@ -64,7 +64,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
|||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||
</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>
|
||||
</Link>
|
||||
<Dropdown.Divider />
|
||||
|
|
|
@ -3,9 +3,11 @@ import { Select, DatePicker } from './form'
|
|||
import { WHENS } from '@/lib/constants'
|
||||
import { whenToFrom } from '@/lib/time'
|
||||
|
||||
export function UsageHeader () {
|
||||
export function UsageHeader ({ pathname = null }) {
|
||||
const router = useRouter()
|
||||
|
||||
const path = pathname || 'stackers'
|
||||
|
||||
const select = async values => {
|
||||
const { when, ...query } = values
|
||||
|
||||
|
@ -13,7 +15,8 @@ export function UsageHeader () {
|
|||
if (query.from && !query.to) return
|
||||
|
||||
await router.push({
|
||||
pathname: `/stackers/${when}`,
|
||||
|
||||
pathname: `/${path}/${when}`,
|
||||
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 Link from 'next/link'
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import Nav from 'react-bootstrap/Nav'
|
||||
import Layout from '@/components/layout'
|
||||
import MoreFooter from '@/components/more-footer'
|
||||
import { WALLET_HISTORY } from '@/fragments/wallet'
|
||||
|
@ -16,6 +17,7 @@ import ItemJob from '@/components/item-job'
|
|||
import PageLoading from '@/components/page-loading'
|
||||
import PayerData from '@/components/payer-data'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import navStyles from '../settings/settings.module.css'
|
||||
|
||||
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 }) {
|
||||
const router = useRouter()
|
||||
const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } })
|
||||
|
@ -179,7 +207,7 @@ export default function Satistics ({ ssrData }) {
|
|||
}
|
||||
|
||||
const incstr = [...inc].join(',')
|
||||
router.push(`/satistics?inc=${incstr}`)
|
||||
router.push(`/satistics/history?inc=${incstr}`)
|
||||
}
|
||||
|
||||
function included (filter) {
|
||||
|
@ -192,7 +220,7 @@ export default function Satistics ({ ssrData }) {
|
|||
return (
|
||||
<Layout>
|
||||
<div className='mt-2'>
|
||||
<h2 className='text-center'>satistics</h2>
|
||||
<SatisticsHeader />
|
||||
<Form
|
||||
initial={{
|
||||
invoice: included('invoice'),
|
Loading…
Reference in New Issue