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:
Ben Allen 2024-04-28 17:05:23 -04:00 committed by GitHub
parent 4be54b74df
commit 8a735791ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 242 additions and 7 deletions

View File

@ -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)
}
},

View File

@ -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!]!
}
`

View File

@ -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 />

View File

@ -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 />

View File

@ -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
})
}

View File

@ -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
}
}
}`

View File

@ -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>
)
}

View File

@ -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'),