show sources and history of rewards

This commit is contained in:
keyan 2023-08-15 12:41:51 -05:00
parent 679f766f07
commit e4831e65d5
11 changed files with 198 additions and 34 deletions

View File

@ -5,33 +5,64 @@ import { ANON_USER_ID } from '../../lib/constants'
export default {
Query: {
expectedRewards: async (parent, args, { models }) => {
rewards: async (parent, { when }, { models }) => {
if (when && isNaN(new Date(when))) {
throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
}
const [result] = await models.$queryRaw`
SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
WITH day_cte (day) AS (
SELECT COALESCE(${when}::text::timestamp - interval '1 day', date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
)
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,
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)),
json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0)),
json_build_object('name', 'anon earnings', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0))
json_build_object('name', 'anon''s stack', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0))
) AS sources
FROM (
FROM day_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') = date_trunc('day', now() AT TIME ZONE 'America/Chicago') AND "ItemAct".act <> 'TIP')
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_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') = date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
UNION ALL
(SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
) subquery`
return result || { total: 0, sources: [] }
return result || { 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')
)
SELECT coalesce(sum(sats), 0) as total, json_agg("Earn".*) as rewards
FROM day_cte
CROSS JOIN LATERAL (
(SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank
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
ORDER BY "Earn".msats DESC)
) "Earn"`
return result
}
},
Mutation: {

View File

@ -2,15 +2,28 @@ import { gql } from 'graphql-tag'
export default gql`
extend type Query {
expectedRewards: ExpectedRewards!
rewards(when: String): Rewards!
meRewards(when: String!): MeRewards
}
extend type Mutation {
donateToRewards(sats: Int!): Int!
}
type ExpectedRewards {
type Rewards {
total: Int!
time: Date!
sources: [NameValue!]!
}
type Reward {
type: String
rank: Int
sats: Int!
}
type MeRewards {
total: Int!
rewards: [Reward!]
}
`

View File

@ -179,15 +179,19 @@ export function WhenComposedChart ({
}
export function GrowthPieChart ({ data }) {
const nonZeroData = data.filter(d => d.value > 0)
return (
<ResponsiveContainer width='100%' height={250} minWidth={200}>
<PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<Pie
dataKey='value'
isAnimationActive={false}
data={data}
data={nonZeroData}
cx='50%'
cy='50%'
minAngle={5}
paddingAngle={0}
outerRadius={80}
fill='var(--bs-secondary)'
label

View File

@ -5,14 +5,14 @@ import { SSR } from '../lib/constants'
const REWARDS = gql`
{
expectedRewards {
rewards {
total
}
}`
export default function Rewards () {
const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
const total = data?.expectedRewards?.total
const total = data?.rewards?.total
return (
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>

View File

@ -73,7 +73,7 @@ function NotificationLayout ({ children, nid, href, as, fresh }) {
const defaultOnClick = n => {
const type = n.__typename
if (type === 'Earn') return {}
if (type === 'Earn') return { href: `/rewards/${new Date(n.sortTime).toISOString().slice(0, 10)}` }
if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'Referral') return { href: '/referrals/month' }

33
fragments/rewards.js Normal file
View File

@ -0,0 +1,33 @@
import gql from 'graphql-tag'
export const REWARDS = gql`
query rewards($when: String) {
rewards(when: $when) {
total
time
sources {
name
value
}
}
}`
export const ME_REWARDS = gql`
query meRewards($when: String) {
rewards(when: $when) {
total
time
sources {
name
value
}
}
meRewards(when: $when) {
total
rewards {
type
rank
sats
}
}
}`

78
pages/rewards/[when].js Normal file
View File

@ -0,0 +1,78 @@
import { useQuery } from '@apollo/client'
import PageLoading from '../../components/page-loading'
import { ME_REWARDS } from '../../fragments/rewards'
import { CenterLayout } from '../../components/layout'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { fixedDecimal } from '../../lib/format'
import Trophy from '../../svgs/trophy-fill.svg'
const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <div>Loading...</div>
})
export const getServerSideProps = getGetServerSideProps(ME_REWARDS, null,
(data, params) => data.rewards.total === 0 || new Date(data.rewards.time) > new Date())
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 <PageLoading />
const { rewards: { total, sources, time }, meRewards } = data || ssrData
const when = router.query.when
return (
<CenterLayout footerLinks>
<div className='py-3'>
<h4 className='fw-bold text-muted ps-0'>
{when && <div className='text-muted fst-italic fs-6 fw-normal pb-1'>On {timeString(time)} at 12a CT</div>}
{total} sats were rewarded
</h4>
<div className='my-3 w-100'>
<GrowthPieChart data={sources} />
</div>
{meRewards &&
<>
<h4 className='fw-bold text-muted text-center'>
you earned {meRewards.total} sats ({fixedDecimal(meRewards.total * 100 / total, 2)}%)
</h4>
<div>
{meRewards.rewards?.map((r, i) => <Reward key={[r.rank, r.type].join('-')} {...r} />)}
</div>
</>}
</div>
</CenterLayout>
)
}
function Reward ({ rank, type, sats }) {
if (!rank) return null
const color = rank <= 3 ? 'text-primary' : 'text-muted'
let category = type
switch (type) {
case 'TIP_POST':
category = 'in post zapping'
break
case 'TIP_COMMENT':
category = 'in comment zapping'
break
case 'POST':
category = 'among posts'
break
case 'COMMENT':
category = 'among comments'
break
}
return (
<div className={color}>
<Trophy height={20} width={20} /> <b>#{rank}</b> {category} for <i><b>{sats} sats</b></i>
</div>
)
}

View File

@ -2,26 +2,26 @@ import { gql } from 'graphql-tag'
import { useMemo } from 'react'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import { getGetServerSideProps } from '../api/ssrApollo'
import { Form, Input, SubmitButton } from '../components/form'
import { CenterLayout } from '../components/layout'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { Form, Input, SubmitButton } from '../../components/form'
import { CenterLayout } from '../../components/layout'
import { useMutation, useQuery } from '@apollo/client'
import Link from 'next/link'
import { amountSchema } from '../lib/validate'
import { amountSchema } from '../../lib/validate'
import Countdown from 'react-countdown'
import { numWithUnits } from '../lib/format'
import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import { numWithUnits } from '../../lib/format'
import PageLoading from '../../components/page-loading'
import { useShowModal } from '../../components/modal'
import dynamic from 'next/dynamic'
import { SSR } from '../lib/constants'
import { SSR } from '../../lib/constants'
const GrowthPieChart = dynamic(() => import('../components/charts').then(mod => mod.GrowthPieChart), {
const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <div>Loading...</div>
})
const REWARDS = gql`
{
expectedRewards {
rewards {
total
sources {
name
@ -66,7 +66,7 @@ export default function Rewards ({ ssrData }) {
const { data } = useQuery(REWARDS, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (!data && !ssrData) return <PageLoading />
const { expectedRewards: { total, sources } } = data || ssrData
const { rewards: { total, sources } } = data || ssrData
return (
<CenterLayout footerLinks>
@ -74,7 +74,7 @@ export default function Rewards ({ ssrData }) {
<div>
<RewardLine total={total} />
</div>
<Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-reset'>
<Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-info fw-normal'>
<small><small><small>learn about rewards</small></small></small>
</Link>
</h4>

View File

@ -87,9 +87,9 @@ function Satus ({ status }) {
function Detail ({ fact }) {
if (fact.type === 'earn') {
return (
<div className='px-3' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards'>here</Link>.
</div>
<Link href={`/rewards/${new Date(fact.createdAt).toISOString().slice(0, 10)}`} className='px-3 text-reset' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations.
</Link>
)
}
if (fact.type === 'donation') {

View File

@ -22,7 +22,7 @@ $theme-colors: (
"grey" : #e9ecef,
"grey-medium" : #d2d2d2,
"grey-darkmode": #8c8c8c,
"nostr": #8d45dd,
"nostr": #8d45dd
);
$body-bg: #fcfcff;
@ -167,6 +167,10 @@ $grid-gutter-width: 2rem;
}
}
.text-primary svg {
fill: var(--bs-primary);
}
.line-height-1 {
line-height: 1;
}

1
svgs/trophy-fill.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.0049 16.941V19.0029H18.0049V21.0029H6.00488V19.0029H11.0049V16.941C7.05857 16.4489 4.00488 13.0825 4.00488 9.00293V3.00293H20.0049V9.00293C20.0049 13.0825 16.9512 16.4489 13.0049 16.941ZM1.00488 5.00293H3.00488V9.00293H1.00488V5.00293ZM21.0049 5.00293H23.0049V9.00293H21.0049V5.00293Z"></path></svg>

After

Width:  |  Height:  |  Size: 372 B