diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 278a89cc..236d1693 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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) } }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 3113fbed..1596c371 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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!]! + } ` diff --git a/components/nav/common.js b/components/nav/common.js index e5114a65..2e3c7aa2 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -186,7 +186,7 @@ export function MeDropdown ({ me, dropNavKey }) { wallet - + satistics diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index ff32d177..4a295057 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -64,7 +64,7 @@ export default function OffCanvas ({ me, dropNavKey }) { wallet - + satistics diff --git a/components/usage-header.js b/components/usage-header.js index 925ded9c..6e76fcef 100644 --- a/components/usage-header.js +++ b/components/usage-header.js @@ -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 }) } diff --git a/fragments/users.js b/fragments/users.js index 23aa658f..e6bfd085 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 + } + } + }` diff --git a/pages/satistics/graphs/[when].js b/pages/satistics/graphs/[when].js new file mode 100644 index 00000000..72aa6fc3 --- /dev/null +++ b/pages/satistics/graphs/[when].js @@ -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: () => +}) +const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), { + loading: () => +}) + +const SatisticsTooltip = ({ children, overlayText }) => { + return ( + + {overlayText} + + } + > + + {children} + + + ) +} + +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 + 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 ( + +
+ +
+
+ +
+
+
+

stacked

+
+
+ +

+ {numWithUnits(totalStacked, { abbreviate: true, format: true })} +

+
+
+
+
+
+

spent

+
+
+ +

+ {numWithUnits(totalSpent, { abbreviate: true, format: true })} +

+
+
+
+
+
+

actions

+
+
+

+ {new Intl.NumberFormat().format(totalEngagement)} +

+
+
+
+
+
+ {userStatsIncomingSats.length > 0 && +
+
stacking
+ +
} + {userStatsOutgoingSats.length > 0 && +
+
spending
+ +
} +
+
+ {userStatsActions.length > 0 && +
+
items
+ +
} +
+
+
+
+
+
+ ) +} diff --git a/pages/satistics.js b/pages/satistics/history.js similarity index 88% rename from pages/satistics.js rename to pages/satistics/history.js index 39b603d6..e356dac9 100644 --- a/pages/satistics.js +++ b/pages/satistics/history.js @@ -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 ( + <> +

satistics

+ + + ) +} + 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 (
-

satistics

+