From b6c822f40e8a6eb3bee4f87cf2048d3fcfbd0e6f Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 29 Aug 2023 19:13:21 -0500 Subject: [PATCH] allow viewing reward ranges --- api/resolvers/rewards.js | 65 ++++++++++++++++------- api/typeDefs/notifications.js | 1 + api/typeDefs/rewards.js | 4 +- components/footer-rewards.js | 3 +- components/notifications.js | 6 ++- fragments/notifications.js | 1 + fragments/rewards.js | 2 +- lib/time.js | 4 +- pages/rewards/{[when].js => [...when].js} | 42 ++++++++------- pages/rewards/index.js | 2 +- styles/globals.scss | 4 ++ 11 files changed, 86 insertions(+), 48 deletions(-) rename pages/rewards/{[when].js => [...when].js} (59%) diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index c420406f..4f3667ce 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -3,20 +3,33 @@ import { amountSchema, ssValidate } from '../../lib/validate' import serialize from './serial' import { ANON_USER_ID } from '../../lib/constants' import { getItem } from './item' +import { datePivot, dayMonthYear } from '../../lib/time' export default { Query: { rewards: async (parent, { when }, { models }) => { - if (when && isNaN(new Date(when))) { - throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } }) + if (when) { + if (when.length > 2) { + throw new GraphQLError('too many dates', { extensions: { code: 'BAD_USER_INPUT' } }) + } + when = when.map(w => { + if (isNaN(new Date(w))) { + throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } }) + } + return dayMonthYear(datePivot(new Date(w), { days: -1 })) + }) + } else { + // default to tomorrow's rewards + when = [dayMonthYear(new Date())] } - const [result] = await models.$queryRaw` - WITH day_cte (day) AS ( - SELECT COALESCE(${when}::text::timestamp - interval '1 day', date_trunc('day', now() AT TIME ZONE 'America/Chicago')) + const results = await models.$queryRaw` + WITH days_cte (day) AS ( + SELECT t::date + FROM generate_series(${when[0]}::text::timestamp, ${when[when.length - 1]}::text::timestamp, interval '1 day') AS t ) SELECT coalesce(FLOOR(sum(sats)), 0) as total, - COALESCE(${when}::text::timestamp, date_trunc('day', (now() + interval '1 day') AT TIME ZONE 'America/Chicago')) as time, + days_cte.day + interval '1 day' as time, json_build_array( json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)), json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)), @@ -24,16 +37,16 @@ export default { json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0)), json_build_object('name', 'anon''s stack', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0)) ) AS sources - FROM day_cte + FROM days_cte CROSS JOIN LATERAL ( (SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type FROM "ItemAct" LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id - WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day AND "ItemAct".act <> 'TIP') + WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day AND "ItemAct".act <> 'TIP') UNION ALL (SELECT sats::FLOAT, 'DONATION' as type FROM "Donation" - WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day) + WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day) UNION ALL -- any earnings from anon's stack that are not forwarded to other users (SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type @@ -41,33 +54,47 @@ export default { JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' - AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day + AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day GROUP BY "ItemAct".id, "ItemAct".msats HAVING COUNT("ItemForward".id) = 0) - ) subquery` + ) subquery + GROUP BY days_cte.day + ORDER BY days_cte.day ASC` - return result || { total: 0, time: 0, sources: [] } + return results || [{ total: 0, time: 0, sources: [] }] }, meRewards: async (parent, { when }, { me, models }) => { if (!me) { return null } - const [result] = await models.$queryRaw` - WITH day_cte (day) AS ( - SELECT date_trunc('day', ${when}::text::timestamp AT TIME ZONE 'America/Chicago') + if (!when || !Array.isArray(when) || when.length > 2) { + throw new GraphQLError('invalid date range', { extensions: { code: 'BAD_USER_INPUT' } }) + } + for (const w of when) { + if (isNaN(new Date(w))) { + throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } }) + } + } + + const results = await models.$queryRaw` + WITH days_cte (day) AS ( + SELECT t::date + FROM generate_series(${when[0]}::text::timestamp, ${when[when.length - 1]}::text::timestamp, interval '1 day') AS t ) SELECT coalesce(sum(sats), 0) as total, json_agg("Earn".*) as rewards - FROM day_cte + FROM days_cte CROSS JOIN LATERAL ( (SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank, "typeId" FROM "Earn" WHERE "Earn"."userId" = ${me.id} - AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day + AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day ORDER BY "Earn".msats DESC) - ) "Earn"` + ) "Earn" + GROUP BY days_cte.day + ORDER BY days_cte.day ASC` - return result + return results } }, Mutation: { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index ffd5faf6..d18d410b 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -65,6 +65,7 @@ export default gql` type Earn { id: ID! earnedSats: Int! + minSortTime: Date! sortTime: Date! sources: EarnSources } diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index eebd1e1c..220ea7bb 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -2,8 +2,8 @@ import { gql } from 'graphql-tag' export default gql` extend type Query { - rewards(when: String): Rewards! - meRewards(when: String!): MeRewards + rewards(when: [String!]): [Rewards!] + meRewards(when: [String!]!): [MeRewards] } extend type Mutation { diff --git a/components/footer-rewards.js b/components/footer-rewards.js index 75525acc..a00e2ba9 100644 --- a/components/footer-rewards.js +++ b/components/footer-rewards.js @@ -12,8 +12,7 @@ const REWARDS = gql` export default function Rewards () { const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' }) - const total = data?.rewards?.total - + const total = data?.rewards?.[0]?.total return ( {total ? : 'rewards'} diff --git a/components/notifications.js b/components/notifications.js index 80f99718..20d1726e 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -7,7 +7,7 @@ import { NOTIFICATIONS } from '../fragments/notifications' import MoreFooter from './more-footer' import Invite from './invite' import { ignoreClick } from '../lib/clicks' -import { timeSince } from '../lib/time' +import { dayMonthYear, timeSince } from '../lib/time' import Link from 'next/link' import Check from '../svgs/check-double-line.svg' import HandCoin from '../svgs/hand-coin-fill.svg' @@ -156,12 +156,14 @@ function Streak ({ n }) { } function EarnNotification ({ n }) { + const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}` + return (
- you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards{timeSince(new Date(n.sortTime))} + you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards{time}
{n.sources &&
diff --git a/fragments/notifications.js b/fragments/notifications.js index 37acb3ae..0c42a81d 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -40,6 +40,7 @@ export const NOTIFICATIONS = gql` ... on Earn { id sortTime + minSortTime earnedSats sources { posts diff --git a/fragments/rewards.js b/fragments/rewards.js index a4291766..511cca10 100644 --- a/fragments/rewards.js +++ b/fragments/rewards.js @@ -15,7 +15,7 @@ export const REWARDS = gql` export const ME_REWARDS = gql` ${ITEM_FULL_FIELDS} - query meRewards($when: String) { + query meRewards($when: [String!]) { rewards(when: $when) { total time diff --git a/lib/time.js b/lib/time.js index 3573d4b7..e1443b54 100644 --- a/lib/time.js +++ b/lib/time.js @@ -33,6 +33,8 @@ function datePivot (date, ) } +const dayMonthYear = when => new Date(when).toISOString().slice(0, 10) + function timeLeft (timeStamp) { const now = new Date() const secondsPast = (timeStamp - now.getTime()) / 1000 @@ -57,4 +59,4 @@ function timeLeft (timeStamp) { const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) -module.exports = { timeSince, datePivot, timeLeft, sleep } +module.exports = { timeSince, dayMonthYear, datePivot, timeLeft, sleep } diff --git a/pages/rewards/[when].js b/pages/rewards/[...when].js similarity index 59% rename from pages/rewards/[when].js rename to pages/rewards/[...when].js index 0843a66d..62c2b973 100644 --- a/pages/rewards/[when].js +++ b/pages/rewards/[...when].js @@ -8,6 +8,7 @@ import { getGetServerSideProps } from '../../api/ssrApollo' import { fixedDecimal } from '../../lib/format' import Trophy from '../../svgs/trophy-fill.svg' import { ListItem } from '../../components/items' +import { dayMonthYear } from '../../lib/time' const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), { loading: () =>
Loading...
@@ -15,38 +16,39 @@ const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod export const getServerSideProps = getGetServerSideProps({ query: ME_REWARDS, - notFound: (data, params) => data.rewards.total === 0 || new Date(data.rewards.time) > new Date() + notFound: (data, params) => data.rewards.reduce((a, r) => a || new Date(r.time) > new Date(), false) }) -const timeString = when => new Date(when).toISOString().slice(0, 10) - export default function Rewards ({ ssrData }) { const router = useRouter() const { data } = useQuery(ME_REWARDS, { variables: { ...router.query } }) if (!data && !ssrData) return - const { rewards: { total, sources, time }, meRewards } = data || ssrData - const when = router.query.when + const { rewards, meRewards } = data || ssrData return ( -
-

- {when &&
On {timeString(time)} at 12a CT
} - {total} sats were rewarded -

-
- -
- {meRewards && - <> -

- you earned {meRewards.total} sats ({fixedDecimal(meRewards.total * 100 / total, 2)}%) +
+ {rewards.map(({ total, sources, time }, i) => ( +
+

+ {time &&
On {dayMonthYear(time)} at 12a CT
} + {total} sats were rewarded

-
- {meRewards.rewards?.map((r, i) => )} +
+
- } + {meRewards[i] && +
+

+ you earned {meRewards[i].total} sats ({fixedDecimal(meRewards[i].total * 100 / total, 2)}%) +

+
+ {meRewards[i].rewards?.map((r, i) => )} +
+
} +
+ ))}
) diff --git a/pages/rewards/index.js b/pages/rewards/index.js index d64a617f..a0a83e3a 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -66,7 +66,7 @@ export default function Rewards ({ ssrData }) { const { data } = useQuery(REWARDS, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) if (!data && !ssrData) return - const { rewards: { total, sources } } = data || ssrData + const { rewards: [{ total, sources }] } = data || ssrData return ( diff --git a/styles/globals.scss b/styles/globals.scss index fe4c4441..e8ce3d7c 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -173,6 +173,10 @@ $btn-close-bg: none; } } +.justify-self-center { + justify-self: center; +} + .text-primary svg { fill: var(--bs-primary); }