From 73170ba8a2fda4fdcf00c5d06c7c1f1f74182597 Mon Sep 17 00:00:00 2001 From: Edward Kung Date: Fri, 28 Feb 2025 17:15:18 -0800 Subject: [PATCH] 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 <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b --- api/resolvers/growth.js | 33 ++++ api/typeDefs/growth.js | 2 + components/footer.js | 2 +- components/sub-analytics-header.js | 79 +++++++++ ...age-header.js => user-analytics-header.js} | 4 +- lib/constants.js | 1 + lib/validate.js | 5 +- pages/satistics/graphs/[when].js | 4 +- pages/stackers/[sub]/[when].js | 157 ++++++++++++++++++ pages/stackers/[when].js | 115 ------------- 10 files changed, 280 insertions(+), 122 deletions(-) create mode 100644 components/sub-analytics-header.js rename components/{usage-header.js => user-analytics-header.js} (93%) create mode 100644 pages/stackers/[sub]/[when].js delete mode 100644 pages/stackers/[when].js diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 70681c92..0bdd4adf 100644 --- a/api/resolvers/growth.js +++ b/api/resolvers/growth.js @@ -121,6 +121,39 @@ export default { FROM ${viewGroup(range, 'stacking_growth')} GROUP BY time ORDER BY time ASC`, ...range) + }, + itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => { + const range = whenRange(when, from, to) + + const subExists = await models.sub.findUnique({ where: { name: sub } }) + if (!subExists) throw new Error('Sub not found') + + return await models.$queryRawUnsafe(` + SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array( + json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)), + json_build_object('name', 'comments', 'value', coalesce(sum(comments),0)) + ) AS data + FROM ${viewGroup(range, 'sub_stats')} + WHERE sub_name = $3 + GROUP BY time + ORDER BY time ASC`, ...range, sub) + }, + revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => { + const range = whenRange(when, from, to) + + const subExists = await models.sub.findUnique({ where: { name: sub } }) + if (!subExists) throw new Error('Sub not found') + + return await models.$queryRawUnsafe(` + SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array( + json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)), + json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)), + json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0)) + ) AS data + FROM ${viewGroup(range, 'sub_stats')} + WHERE sub_name = $3 + GROUP BY time + ORDER BY time ASC`, ...range, sub) } } } diff --git a/api/typeDefs/growth.js b/api/typeDefs/growth.js index f37dd414..66e35f7c 100644 --- a/api/typeDefs/growth.js +++ b/api/typeDefs/growth.js @@ -13,6 +13,8 @@ export default gql` spenderGrowth(when: String, from: String, to: String): [TimeData!]! stackingGrowth(when: String, from: String, to: String): [TimeData!]! stackerGrowth(when: String, from: String, to: String): [TimeData!]! + itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]! + revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]! } type TimeData { diff --git a/components/footer.js b/components/footer.js index beeda22c..bcd48da1 100644 --- a/components/footer.js +++ b/components/footer.js @@ -173,7 +173,7 @@ export default function Footer ({ links = true }) {
- + analytics \ diff --git a/components/sub-analytics-header.js b/components/sub-analytics-header.js new file mode 100644 index 00000000..7c3fbae4 --- /dev/null +++ b/components/sub-analytics-header.js @@ -0,0 +1,79 @@ +import { useRouter } from 'next/router' +import { Select, DatePicker } from './form' +import { useSubs } from './sub-select' +import { WHENS } from '@/lib/constants' +import { whenToFrom } from '@/lib/time' +import styles from './sub-select.module.css' +import classNames from 'classnames' + +export function SubAnalyticsHeader ({ pathname = null }) { + const router = useRouter() + + const path = pathname || 'stackers' + + const select = async values => { + const { sub, when, ...query } = values + + if (when !== 'custom') { delete query.from; delete query.to } + if (query.from && !query.to) return + + await router.push({ + + pathname: `/${path}/${sub}/${when}`, + query + }) + } + + const when = router.query.when || 'day' + const sub = router.query.sub || 'all' + + const subs = useSubs({ prependSubs: ['all'], sub, appendSubs: [], filterSubs: () => true }) + + return ( +
+
+ stacker analytics in + { + const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: Date.now() } : {} + select({ sub, when: e.target.value, ...range }) + }} + /> +
+ {when === 'custom' && + { + select({ sub, when, from: from.getTime(), to: to.getTime() }) + }} + from={router.query.from} + to={router.query.to} + when={when} + />} +
+ ) +} diff --git a/components/usage-header.js b/components/user-analytics-header.js similarity index 93% rename from components/usage-header.js rename to components/user-analytics-header.js index 65460f22..4f814ce9 100644 --- a/components/usage-header.js +++ b/components/user-analytics-header.js @@ -3,10 +3,10 @@ import { Select, DatePicker } from './form' import { WHENS } from '@/lib/constants' import { whenToFrom } from '@/lib/time' -export function UsageHeader ({ pathname = null }) { +export function UserAnalyticsHeader ({ pathname = null }) { const router = useRouter() - const path = pathname || 'stackers' + const path = pathname || 'satistics/graph' const select = async values => { const { when, ...query } = values diff --git a/lib/constants.js b/lib/constants.js index 0e8d27bc..9fa41260 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -2,6 +2,7 @@ // to be loaded from the server export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs') +export const RESERVED_SUB_NAMES = ['all', 'home'] export const PAID_ACTION_PAYMENT_METHODS = { FEE_CREDIT: 'FEE_CREDIT', diff --git a/lib/validate.js b/lib/validate.js index a9cda447..e0d55bfb 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -2,7 +2,8 @@ import { string, ValidationError, number, object, array, boolean, date } from '. import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES, - TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX + TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, + RESERVED_SUB_NAMES } from './constants' import { SUPPORTED_CURRENCIES } from './currency' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' @@ -306,7 +307,7 @@ export function territorySchema (args) { const isArchived = sub => sub.status === 'STOPPED' const filter = sub => editing ? !isEdit(sub) : !isArchived(sub) const exists = await subExists(name, { ...args, filter }) - return !exists + return !exists & !RESERVED_SUB_NAMES.includes(name) }, message: 'taken' }), diff --git a/pages/satistics/graphs/[when].js b/pages/satistics/graphs/[when].js index b732e7d8..c7a58db0 100644 --- a/pages/satistics/graphs/[when].js +++ b/pages/satistics/graphs/[when].js @@ -6,7 +6,7 @@ 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 { UserAnalyticsHeader } from '@/components/user-analytics-header' import { SatisticsHeader } from '..' import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons' import OverlayTrigger from 'react-bootstrap/OverlayTrigger' @@ -55,7 +55,7 @@ export default function Satistics ({ ssrData }) {
- +
diff --git a/pages/stackers/[sub]/[when].js b/pages/stackers/[sub]/[when].js new file mode 100644 index 00000000..eec02af0 --- /dev/null +++ b/pages/stackers/[sub]/[when].js @@ -0,0 +1,157 @@ +import { gql, useQuery } from '@apollo/client' +import { getGetServerSideProps } from '@/api/ssrApollo' +import Layout from '@/components/layout' +import Col from 'react-bootstrap/Col' +import Row from 'react-bootstrap/Row' +import { SubAnalyticsHeader } from '@/components/sub-analytics-header' +import { useRouter } from 'next/router' +import dynamic from 'next/dynamic' +import PageLoading from '@/components/page-loading' +import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons' + +const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), { + loading: () => +}) +const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), { + loading: () => +}) +const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), { + loading: () => +}) + +const GROWTH_QUERY = gql` + query Growth($when: String!, $from: String, $to: String, $sub: String, $subSelect: Boolean = false) + { + registrationGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + itemGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + spendingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + spenderGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + stackingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + stackerGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) { + time + data { + name + value + } + } + itemGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) { + time + data { + name + value + } + } + revenueGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) { + time + data { + name + value + } + } + }` + +const variablesFunc = vars => ({ ...vars, subSelect: vars.sub !== 'all' }) +export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY, variables: variablesFunc }) + +export default function Growth ({ ssrData }) { + const router = useRouter() + const { when, from, to, sub } = router.query + + const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to, sub, subSelect: sub !== 'all' } }) + if (!data && !ssrData) return + + const { + registrationGrowth, + itemGrowth, + spendingGrowth, + spenderGrowth, + stackingGrowth, + stackerGrowth, + itemGrowthSubs, + revenueGrowthSubs + } = data || ssrData + + if (sub === 'all') { + return ( + + + + +
stackers
+ + + +
stacking
+ + +
+ + +
spenders
+ + + +
spending
+ + +
+ + +
registrations
+ + + +
items
+ + +
+
+ ) + } else { + return ( + + + + +
items
+ + + +
sats
+ + +
+
+ ) + } +} diff --git a/pages/stackers/[when].js b/pages/stackers/[when].js deleted file mode 100644 index 453fbc08..00000000 --- a/pages/stackers/[when].js +++ /dev/null @@ -1,115 +0,0 @@ -import { gql, useQuery } from '@apollo/client' -import { getGetServerSideProps } from '@/api/ssrApollo' -import Layout from '@/components/layout' -import Col from 'react-bootstrap/Col' -import Row from 'react-bootstrap/Row' -import { UsageHeader } from '@/components/usage-header' -import { useRouter } from 'next/router' -import dynamic from 'next/dynamic' -import PageLoading from '@/components/page-loading' -import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons' - -const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), { - loading: () => -}) -const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), { - loading: () => -}) -const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), { - loading: () => -}) - -const GROWTH_QUERY = gql` - query Growth($when: String!, $from: String, $to: String) - { - registrationGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - itemGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - spendingGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - spenderGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - stackingGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - stackerGrowth(when: $when, from: $from, to: $to) { - time - data { - name - value - } - } - }` - -export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY }) - -export default function Growth ({ ssrData }) { - const router = useRouter() - const { when, from, to } = router.query - - const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to } }) - if (!data && !ssrData) return - - const { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } = data || ssrData - - return ( - - - - -
stackers
- - - -
stacking
- - -
- - -
spenders
- - - -
spending
- - -
- - -
registrations
- - - -
items
- - -
-
- ) -}