Custom date selector for more pages (#567)

* add custom range option to top items page

* add custom range option to profile page

* add date filter option to chart pages

* cleanup

* fix x-axis date labels

* date picker improvements

* enhancements to custom date selection

* remove unneeded condition

---------

Co-authored-by: rleed <rleed1@pm.me>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
rleed 2023-11-08 21:15:36 -03:00 committed by GitHub
parent 8f590425dc
commit 3a56782572
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 609 additions and 465 deletions

View File

@ -1,3 +1,6 @@
import { timeUnitForRange } from '../../lib/time'
import { whenRange } from './item'
const PLACEHOLDERS_NUM = 616 const PLACEHOLDERS_NUM = 616
export function interval (when) { export function interval (when) {
@ -15,27 +18,13 @@ export function interval (when) {
} }
} }
export function timeUnit (when) { export function withClause (range) {
switch (when) { const unit = timeUnitForRange(range)
case 'week':
case 'month':
return 'day'
case 'year':
case 'forever':
return 'month'
default:
return 'hour'
}
}
export function withClause (when) {
const ival = interval(when)
const unit = timeUnit(when)
return ` return `
WITH range_values AS ( WITH range_values AS (
SELECT date_trunc('${unit}', ${ival ? "now_utc() - interval '" + ival + "'" : "'2021-06-07'::timestamp"}) as minval, SELECT date_trunc('${unit}', $1) as minval,
date_trunc('${unit}', now_utc()) as maxval), date_trunc('${unit}', $2) as maxval),
times AS ( times AS (
SELECT generate_series(minval, maxval, interval '1 ${unit}') as time SELECT generate_series(minval, maxval, interval '1 ${unit}') as time
FROM range_values FROM range_values
@ -43,54 +32,50 @@ export function withClause (when) {
` `
} }
// HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables export function intervalClause (range, table) {
export function intervalClause (when, table, and) { const unit = timeUnitForRange(range)
const unit = timeUnit(when)
if (when === 'forever') {
return and ? '' : 'TRUE'
}
return `"${table}".created_at >= date_trunc('${unit}', now_utc() - interval '${interval(when)}') ${and ? 'AND' : ''} ` return `"${table}".created_at >= date_trunc('${unit}', timezone('America/Chicago', $1)) AND "${table}".created_at <= date_trunc('${unit}', timezone('America/Chicago', $2)) `
} }
export function viewIntervalClause (when, view, and) { export function viewIntervalClause (range, view) {
if (when === 'forever') { return `"${view}".day >= date_trunc('day', timezone('America/Chicago', $1)) AND "${view}".day <= date_trunc('day', timezone('America/Chicago', $2)) `
return and ? '' : 'TRUE'
}
return `"${view}".day >= date_trunc('day', timezone('America/Chicago', now() - interval '${interval(when)}')) ${and ? 'AND' : ''} `
} }
export default { export default {
Query: { Query: {
registrationGrowth: async (parent, { when }, { models }) => { registrationGrowth: async (parent, { when, from, to }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'referrals', 'value', sum(referrals)), json_build_object('name', 'referrals', 'value', sum(referrals)),
json_build_object('name', 'organic', 'value', sum(organic)) json_build_object('name', 'organic', 'value', sum(organic))
) AS data ) AS data
FROM reg_growth_days FROM reg_growth_days
WHERE ${viewIntervalClause(when, 'reg_growth_days', false)} WHERE ${viewIntervalClause(range, 'reg_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'referrals', 'value', count("referrerId")), json_build_object('name', 'referrals', 'value', count("referrerId")),
json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId")) json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId"))
) AS data ) AS data
FROM times FROM times
LEFT JOIN users ON ${intervalClause(when, 'users', true)} time = date_trunc('${timeUnit(when)}', created_at) LEFT JOIN users ON ${intervalClause(range, 'users')} AND time = date_trunc('${timeUnitForRange(range)}', created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
}, },
spenderGrowth: async (parent, { when }, { models }) => { spenderGrowth: async (parent, { when, to, from }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'any', 'value', floor(avg("any"))), json_build_object('name', 'any', 'value', floor(avg("any"))),
json_build_object('name', 'jobs', 'value', floor(avg(jobs))), json_build_object('name', 'jobs', 'value', floor(avg(jobs))),
json_build_object('name', 'boost', 'value', floor(avg(boost))), json_build_object('name', 'boost', 'value', floor(avg(boost))),
@ -99,13 +84,13 @@ export default {
json_build_object('name', 'donation', 'value', floor(avg(donations))) json_build_object('name', 'donation', 'value', floor(avg(donations)))
) AS data ) AS data
FROM spender_growth_days FROM spender_growth_days
WHERE ${viewIntervalClause(when, 'spender_growth_days', false)} WHERE ${viewIntervalClause(range, 'spender_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'any', 'value', count(DISTINCT "userId")), json_build_object('name', 'any', 'value', count(DISTINCT "userId")),
json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')), json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')),
@ -118,31 +103,33 @@ export default {
LEFT JOIN LEFT JOIN
((SELECT "ItemAct".created_at, "userId", act::text as act ((SELECT "ItemAct".created_at, "userId", act::text as act
FROM "ItemAct" FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)}) WHERE ${intervalClause(range, 'ItemAct')})
UNION ALL UNION ALL
(SELECT created_at, "userId", 'DONATION' as act (SELECT created_at, "userId", 'DONATION' as act
FROM "Donation" FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(range, 'Donation')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
}, },
itemGrowth: async (parent, { when }, { models }) => { itemGrowth: async (parent, { when, to, from }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'posts', 'value', sum(posts)), json_build_object('name', 'posts', 'value', sum(posts)),
json_build_object('name', 'comments', 'value', sum(comments)), json_build_object('name', 'comments', 'value', sum(comments)),
json_build_object('name', 'jobs', 'value', sum(jobs)), json_build_object('name', 'jobs', 'value', sum(jobs)),
json_build_object('name', 'comments/posts', 'value', ROUND(sum(comments)/GREATEST(sum(posts), 1), 2)) json_build_object('name', 'comments/posts', 'value', ROUND(sum(comments)/GREATEST(sum(posts), 1), 2))
) AS data ) AS data
FROM item_growth_days FROM item_growth_days
WHERE ${viewIntervalClause(when, 'item_growth_days', false)} WHERE ${viewIntervalClause(range, 'item_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'comments', 'value', count("parentId")), json_build_object('name', 'comments', 'value', count("parentId")),
json_build_object('name', 'jobs', 'value', count("subName") FILTER (WHERE "subName" = 'jobs')), json_build_object('name', 'jobs', 'value', count("subName") FILTER (WHERE "subName" = 'jobs')),
@ -150,14 +137,16 @@ export default {
json_build_object('name', 'comments/posts', 'value', ROUND(count("parentId")/GREATEST(count("Item".id)-count("parentId"), 1), 2)) json_build_object('name', 'comments/posts', 'value', ROUND(count("parentId")/GREATEST(count("Item".id)-count("parentId"), 1), 2))
) AS data ) AS data
FROM times FROM times
LEFT JOIN "Item" ON ${intervalClause(when, 'Item', true)} time = date_trunc('${timeUnit(when)}', created_at) LEFT JOIN "Item" ON ${intervalClause(range, 'Item')} AND time = date_trunc('${timeUnitForRange(range)}', created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
}, },
spendingGrowth: async (parent, { when }, { models }) => { spendingGrowth: async (parent, { when, to, from }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'jobs', 'value', sum(jobs)), json_build_object('name', 'jobs', 'value', sum(jobs)),
json_build_object('name', 'boost', 'value', sum(boost)), json_build_object('name', 'boost', 'value', sum(boost)),
json_build_object('name', 'fees', 'value', sum(fees)), json_build_object('name', 'fees', 'value', sum(fees)),
@ -165,13 +154,13 @@ export default {
json_build_object('name', 'donations', 'value', sum(donations)) json_build_object('name', 'donations', 'value', sum(donations))
) AS data ) AS data
FROM spending_growth_days FROM spending_growth_days
WHERE ${viewIntervalClause(when, 'spending_growth_days', false)} WHERE ${viewIntervalClause(range, 'spending_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)), json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)), json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
@ -183,18 +172,20 @@ export default {
LEFT JOIN LEFT JOIN
((SELECT "ItemAct".created_at, msats, act::text as act ((SELECT "ItemAct".created_at, msats, act::text as act
FROM "ItemAct" FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)}) WHERE ${intervalClause(range, 'ItemAct')})
UNION ALL UNION ALL
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act (SELECT created_at, sats * 1000 as msats, 'DONATION' as act
FROM "Donation" FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(range, 'Donation')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
}, },
stackerGrowth: async (parent, { when }, { models }) => { stackerGrowth: async (parent, { when, to, from }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'any', 'value', floor(avg("any"))), json_build_object('name', 'any', 'value', floor(avg("any"))),
json_build_object('name', 'posts', 'value', floor(avg(posts))), json_build_object('name', 'posts', 'value', floor(avg(posts))),
json_build_object('name', 'comments', 'value', floor(floor(avg(comments)))), json_build_object('name', 'comments', 'value', floor(floor(avg(comments)))),
@ -202,13 +193,13 @@ export default {
json_build_object('name', 'referrals', 'value', floor(avg(referrals))) json_build_object('name', 'referrals', 'value', floor(avg(referrals)))
) AS data ) AS data
FROM stackers_growth_days FROM stackers_growth_days
WHERE ${viewIntervalClause(when, 'stackers_growth_days', false)} WHERE ${viewIntervalClause(range, 'stackers_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'any', 'value', count(distinct user_id)), json_build_object('name', 'any', 'value', count(distinct user_id)),
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')), json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
@ -221,35 +212,37 @@ export default {
((SELECT "ItemAct".created_at, "Item"."userId" as user_id, CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type ((SELECT "ItemAct".created_at, "Item"."userId" as user_id, CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') WHERE ${intervalClause(range, 'ItemAct')} AND "ItemAct".act = 'TIP')
UNION ALL UNION ALL
(SELECT created_at, "userId" as user_id, 'EARN' as type (SELECT created_at, "userId" as user_id, 'EARN' as type
FROM "Earn" FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)}) WHERE ${intervalClause(range, 'Earn')})
UNION ALL UNION ALL
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type (SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
FROM "ReferralAct" FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(range, 'ReferralAct')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
}, },
stackingGrowth: async (parent, { when }, { models }) => { stackingGrowth: async (parent, { when, to, from }, { models }) => {
const range = whenRange(when, from, to)
if (when !== 'day') { if (when !== 'day') {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnit(when)}', day) as time, json_build_array( SELECT date_trunc('${timeUnitForRange(range)}', day) as time, json_build_array(
json_build_object('name', 'rewards', 'value', sum(rewards)), json_build_object('name', 'rewards', 'value', sum(rewards)),
json_build_object('name', 'posts', 'value', sum(posts)), json_build_object('name', 'posts', 'value', sum(posts)),
json_build_object('name', 'comments', 'value', sum(comments)), json_build_object('name', 'comments', 'value', sum(comments)),
json_build_object('name', 'referrals', 'value', sum(referrals)) json_build_object('name', 'referrals', 'value', sum(referrals))
) AS data ) AS data
FROM stacking_growth_days FROM stacking_growth_days
WHERE ${viewIntervalClause(when, 'stacking_growth_days', false)} WHERE ${viewIntervalClause(range, 'stacking_growth_days')}
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
return await models.$queryRawUnsafe( return await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)), json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)), json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
@ -264,17 +257,17 @@ export default {
0 as referral 0 as referral
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') WHERE ${intervalClause(range, 'ItemAct')} AND "ItemAct".act = 'TIP')
UNION ALL UNION ALL
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral (SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
FROM "ReferralAct" FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)}) WHERE ${intervalClause(range, 'ReferralAct')})
UNION ALL UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral (SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
FROM "Earn" FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(range, 'Earn')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`, ...range)
} }
} }
} }

View File

@ -18,7 +18,7 @@ import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema,
import { sendUserNotification } from '../webPush' import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
import { datePivot } from '../../lib/time' import { datePivot, dayMonthYearToDate, whenToFrom } from '../../lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { imageFeesInfo, uploadIdsFromText } from './image'
export async function commentFilterClause (me, models) { export async function commentFilterClause (me, models) {
@ -195,26 +195,17 @@ export const whereClause = (...clauses) => {
return clause ? ` WHERE ${clause} ` : '' return clause ? ` WHERE ${clause} ` : ''
} }
function whenClause (when, type) { function whenClause (when, table) {
let interval = `"${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL ` return `"${table}".created_at <= $2 and "${table}".created_at >= $1`
}
export function whenRange (when, from, to = new Date()) {
switch (when) { switch (when) {
case 'forever': case 'custom':
interval = '' return [new Date(from), new Date(to)]
break
case 'week':
interval += "'7 days'"
break
case 'month':
interval += "'1 month'"
break
case 'year':
interval += "'1 year'"
break
default: default:
interval += "'1 day'" return [dayMonthYearToDate(whenToFrom(when)), new Date(to)]
break
} }
return interval
} }
const activeOrMine = (me) => { const activeOrMine = (me) => {
@ -309,7 +300,7 @@ export default {
return count return count
}, },
items: async (parent, { sub, sort, type, cursor, name, when, by, limit = LIMIT }, { me, models }) => { items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table let items, user, pins, subFull, table
@ -354,18 +345,16 @@ export default {
${selectClause(type)} ${selectClause(type)}
${relationClause(type)} ${relationClause(type)}
${whereClause( ${whereClause(
`"${table}"."userId" = $2`, `"${table}"."userId" = $3`,
`"${table}".created_at <= $1`,
subClause(sub, 5, subClauseTable(type)),
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type), await filterClause(me, models, type),
typeClause(type), typeClause(type),
whenClause(when || 'forever', type))} whenClause(when || 'forever', table))}
${orderByClause(by, me, models, type)} ${orderByClause(by, me, models, type)}
OFFSET $3 OFFSET $4
LIMIT $4`, LIMIT $5`,
orderBy: orderByClause(by, me, models, type) orderBy: orderByClause(by, me, models, type)
}, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr) }, ...whenRange(when, from, to || decodedCursor.time), user.id, decodedCursor.offset, limit)
break break
case 'recent': case 'recent':
items = await itemQueryWithMeta({ items = await itemQueryWithMeta({
@ -399,19 +388,18 @@ export default {
${relationClause(type)} ${relationClause(type)}
${joinZapRankPersonalView(me, models)} ${joinZapRankPersonalView(me, models)}
${whereClause( ${whereClause(
'"Item".created_at <= $1',
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 4, subClauseTable(type)), subClause(sub, 5, subClauseTable(type)),
typeClause(type), typeClause(type),
whenClause(when, type), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY rank DESC
OFFSET $2 OFFSET $3
LIMIT $3`, LIMIT $4`,
orderBy: 'ORDER BY rank DESC' orderBy: 'ORDER BY rank DESC'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr) }, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
} else { } else {
items = await itemQueryWithMeta({ items = await itemQueryWithMeta({
me, me,
@ -420,19 +408,18 @@ export default {
${selectClause(type)} ${selectClause(type)}
${relationClause(type)} ${relationClause(type)}
${whereClause( ${whereClause(
'"Item".created_at <= $1',
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 4, subClauseTable(type)), subClause(sub, 5, subClauseTable(type)),
typeClause(type), typeClause(type),
whenClause(when, type), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
muteClause(me))} muteClause(me))}
${orderByClause(by || 'zaprank', me, models, type)} ${orderByClause(by || 'zaprank', me, models, type)}
OFFSET $2 OFFSET $3
LIMIT $3`, LIMIT $4`,
orderBy: orderByClause(by || 'zaprank', me, models, type) orderBy: orderByClause(by || 'zaprank', me, models, type)
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr) }, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
} }
break break
default: default:
@ -1052,7 +1039,7 @@ export const createMentions = async (item, models) => {
}) })
} }
} catch (e) { } catch (e) {
console.log('mention failure', e) console.error('mention failure', e)
} }
} }

View File

@ -1,29 +1,33 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { withClause, intervalClause, timeUnit } from './growth' import { withClause, intervalClause } from './growth'
import { whenRange } from './item'
import { timeUnitForRange } from '../../lib/time'
export default { export default {
Query: { Query: {
referrals: async (parent, { when }, { models, me }) => { referrals: async (parent, { when, from, to }, { models, me }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
const range = whenRange(when, from, to)
const [{ totalSats }] = await models.$queryRawUnsafe(` const [{ totalSats }] = await models.$queryRawUnsafe(`
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats" SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
FROM "ReferralAct" FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', true)} WHERE ${intervalClause(range, 'ReferralAct')}
"ReferralAct"."referrerId" = $1 AND "ReferralAct"."referrerId" = $3
`, Number(me.id)) `, ...range, Number(me.id))
const [{ totalReferrals }] = await models.$queryRawUnsafe(` const [{ totalReferrals }] = await models.$queryRawUnsafe(`
SELECT count(*)::INTEGER as "totalReferrals" SELECT count(*)::INTEGER as "totalReferrals"
FROM users FROM users
WHERE ${intervalClause(when, 'users', true)} WHERE ${intervalClause(range, 'users')}
"referrerId" = $1 AND "referrerId" = $3
`, Number(me.id)) `, ...range, Number(me.id))
const stats = await models.$queryRawUnsafe( const stats = await models.$queryRawUnsafe(
`${withClause(when)} `${withClause(range)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')), json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0))) json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
@ -33,15 +37,15 @@ export default {
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act ((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
FROM "ReferralAct" FROM "ReferralAct"
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId" JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE ${intervalClause(when, 'ReferralAct', true)} WHERE ${intervalClause(range, 'ReferralAct')}
"ReferralAct"."referrerId" = $1) AND "ReferralAct"."referrerId" = $3)
UNION ALL UNION ALL
(SELECT created_at, 0.0 as sats, 'REFERREE' as act (SELECT created_at, 0.0 as sats, 'REFERREE' as act
FROM users FROM users
WHERE ${intervalClause(when, 'users', true)} WHERE ${intervalClause(range, 'users')}
"referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at) AND "referrerId" = $3)) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`, Number(me.id)) ORDER BY time ASC`, ...range, Number(me.id))
return { return {
totalSats, totalSats,

View File

@ -1,5 +1,6 @@
import { ITEM_FILTER_THRESHOLD } from '../../lib/constants' import { ITEM_FILTER_THRESHOLD } from '../../lib/constants'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { whenToFrom } from '../../lib/time'
import { getItem } from './item' import { getItem } from './item'
const STOP_WORDS = ['a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', const STOP_WORDS = ['a', 'an', 'and', 'are', 'as', 'at', 'be', 'but',
@ -124,20 +125,25 @@ export default {
switch (sort) { switch (sort) {
case 'recent': case 'recent':
sortArr.push({ createdAt: 'desc' }) sortArr.push({ createdAt: 'desc' })
sortArr.push('_score')
break break
case 'comments': case 'comments':
sortArr.push({ ncomments: 'desc' }) sortArr.push({ ncomments: 'desc' })
sortArr.push('_score')
break break
case 'sats': case 'sats':
sortArr.push({ sats: 'desc' }) sortArr.push({ sats: 'desc' })
sortArr.push('_score')
break break
case 'match': case 'match':
sortArr.push('_score')
sortArr.push({ wvotes: 'desc' })
break break
default: default:
sortArr.push({ wvotes: 'desc' }) sortArr.push({ wvotes: 'desc' })
sortArr.push('_score')
break break
} }
sortArr.push('_score')
if (query.length) { if (query.length) {
whatArr.push({ whatArr.push({
@ -181,32 +187,14 @@ export default {
}) })
} }
let whenGte
switch (when) {
case 'day':
whenGte = 'now-1d'
break
case 'week':
whenGte = 'now-7d'
break
case 'month':
whenGte = 'now-30d'
break
case 'year':
whenGte = 'now-365d'
break
default:
break
}
const whenRange = when === 'custom' const whenRange = when === 'custom'
? { ? {
gte: new Date(whenFrom), gte: whenFrom,
lte: new Date(Math.min(new Date(whenTo), decodedCursor.time)) lte: new Date(Math.min(new Date(whenTo), decodedCursor.time))
} }
: { : {
lte: decodedCursor.time, lte: decodedCursor.time,
gte: whenGte gte: whenToFrom(when)
} }
try { try {

View File

@ -4,9 +4,9 @@ import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { msatsToSats } from '../../lib/format' import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, whenRange } from './item'
import { datePivot } from '../../lib/time'
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants' import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants'
import { viewIntervalClause, intervalClause } from './growth'
const contributors = new Set() const contributors = new Set()
@ -22,66 +22,6 @@ const loadContributors = async (set) => {
} }
} }
export function within (table, within) {
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
switch (within) {
case 'day':
interval += "'1 day'"
break
case 'week':
interval += "'7 days'"
break
case 'month':
interval += "'1 month'"
break
case 'year':
interval += "'1 year'"
break
default:
interval = ''
break
}
return interval
}
export function viewWithin (table, within) {
let interval = ' AND "' + table + '".day >= date_trunc(\'day\', timezone(\'America/Chicago\', $1 at time zone \'UTC\' - interval '
switch (within) {
case 'day':
interval += "'1 day'))"
break
case 'week':
interval += "'7 days'))"
break
case 'month':
interval += "'1 month'))"
break
case 'year':
interval += "'1 year'))"
break
default:
// HACK: we need to use the time parameter otherwise prisma *cries* about it
interval = ' AND users.created_at <= $1'
break
}
return interval
}
export function withinDate (within) {
switch (within) {
case 'day':
return datePivot(new Date(), { days: -1 })
case 'week':
return datePivot(new Date(), { days: -7 })
case 'month':
return datePivot(new Date(), { days: -30 })
case 'year':
return datePivot(new Date(), { days: -365 })
default:
return new Date(0)
}
}
async function authMethods (user, args, { models, me }) { async function authMethods (user, args, { models, me }) {
const accounts = await models.account.findMany({ const accounts = await models.account.findMany({
where: { where: {
@ -149,8 +89,9 @@ export default {
users users
} }
}, },
topUsers: async (parent, { cursor, when, by, limit = LIMIT }, { models, me }) => { topUsers: async (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time)
let users let users
if (when !== 'day') { if (when !== 'day') {
@ -171,13 +112,13 @@ export default {
FROM user_stats_days FROM user_stats_days
JOIN users on users.id = user_stats_days.id JOIN users on users.id = user_stats_days.id
WHERE NOT users."hideFromTopUsers" WHERE NOT users."hideFromTopUsers"
${viewWithin('user_stats_days', when)} AND ${viewIntervalClause(range, 'user_stats_days')}
GROUP BY users.id GROUP BY users.id
ORDER BY ${column} DESC NULLS LAST, users.created_at DESC ORDER BY ${column} DESC NULLS LAST, users.created_at DESC
) )
SELECT * FROM u WHERE ${column} > 0 SELECT * FROM u WHERE ${column} > 0
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
return { return {
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
@ -191,56 +132,53 @@ export default {
FROM FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct" FROM "ItemAct"
WHERE "ItemAct".created_at <= $1 WHERE ${intervalClause(range, 'ItemAct')}
${within('ItemAct', when)}
GROUP BY "userId") GROUP BY "userId")
UNION ALL UNION ALL
(SELECT "userId", sats as sats_spent (SELECT "userId", sats as sats_spent
FROM "Donation" FROM "Donation"
WHERE created_at <= $1 WHERE ${intervalClause(range, 'Donation')})) spending
${within('Donation', when)})) spending
JOIN users on spending."userId" = users.id JOIN users on spending."userId" = users.id
AND NOT users."hideFromTopUsers" AND NOT users."hideFromTopUsers"
GROUP BY users.id, users.name GROUP BY users.id, users.name
ORDER BY spent DESC NULLS LAST, users.created_at DESC ORDER BY spent DESC NULLS LAST, users.created_at DESC
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
} else if (by === 'posts') { } else if (by === 'posts') {
users = await models.$queryRawUnsafe(` users = await models.$queryRawUnsafe(`
SELECT users.*, count(*)::INTEGER as nposts SELECT users.*, count(*)::INTEGER as nposts
FROM users FROM users
JOIN "Item" on "Item"."userId" = users.id JOIN "Item" on "Item"."userId" = users.id
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL WHERE "Item"."parentId" IS NULL
AND NOT users."hideFromTopUsers" AND NOT users."hideFromTopUsers"
${within('Item', when)} AND ${viewIntervalClause(range, 'Item')}
GROUP BY users.id GROUP BY users.id
ORDER BY nposts DESC NULLS LAST, users.created_at DESC ORDER BY nposts DESC NULLS LAST, users.created_at DESC
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
} else if (by === 'comments') { } else if (by === 'comments') {
users = await models.$queryRawUnsafe(` users = await models.$queryRawUnsafe(`
SELECT users.*, count(*)::INTEGER as ncomments SELECT users.*, count(*)::INTEGER as ncomments
FROM users FROM users
JOIN "Item" on "Item"."userId" = users.id JOIN "Item" on "Item"."userId" = users.id
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NOT NULL WHERE "Item"."parentId" IS NOT NULL
AND NOT users."hideFromTopUsers" AND NOT users."hideFromTopUsers"
${within('Item', when)} AND ${intervalClause(range, 'Item')}
GROUP BY users.id GROUP BY users.id
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
} else if (by === 'referrals') { } else if (by === 'referrals') {
users = await models.$queryRawUnsafe(` users = await models.$queryRawUnsafe(`
SELECT users.*, count(*)::INTEGER as referrals SELECT users.*, count(*)::INTEGER as referrals
FROM users FROM users
JOIN "users" referree on users.id = referree."referrerId" JOIN "users" referree on users.id = referree."referrerId"
WHERE referree.created_at <= $1
AND NOT users."hideFromTopUsers" AND NOT users."hideFromTopUsers"
${within('referree', when)} AND ${intervalClause(range, 'referree')}
GROUP BY users.id GROUP BY users.id
ORDER BY referrals DESC NULLS LAST, users.created_at DESC ORDER BY referrals DESC NULLS LAST, users.created_at DESC
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
} else { } else {
users = await models.$queryRawUnsafe(` users = await models.$queryRawUnsafe(`
SELECT u.id, u.name, u.streak, u."photoId", u."hideCowboyHat", floor(sum(amount)/1000) as stacked SELECT u.id, u.name, u.streak, u."photoId", u."hideCowboyHat", floor(sum(amount)/1000) as stacked
@ -249,25 +187,27 @@ export default {
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
JOIN users on "Item"."userId" = users.id JOIN users on "Item"."userId" = users.id
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> users.id AND "ItemAct".created_at <= $1 WHERE act <> 'BOOST' AND "ItemAct"."userId" <> users.id
AND NOT users."hideFromTopUsers" AND NOT users."hideFromTopUsers"
${within('ItemAct', when)}) AND ${intervalClause(range, 'ItemAct')})
UNION ALL UNION ALL
(SELECT users.*, "Earn".msats as amount (SELECT users.*, "Earn".msats as amount
FROM "Earn" FROM "Earn"
JOIN users on users.id = "Earn"."userId" JOIN users on users.id = "Earn"."userId"
WHERE "Earn".msats > 0 ${within('Earn', when)} WHERE "Earn".msats > 0
AND ${intervalClause(range, 'Earn')}
AND NOT users."hideFromTopUsers") AND NOT users."hideFromTopUsers")
UNION ALL UNION ALL
(SELECT users.*, "ReferralAct".msats as amount (SELECT users.*, "ReferralAct".msats as amount
FROM "ReferralAct" FROM "ReferralAct"
JOIN users on users.id = "ReferralAct"."referrerId" JOIN users on users.id = "ReferralAct"."referrerId"
WHERE "ReferralAct".msats > 0 ${within('ReferralAct', when)} WHERE "ReferralAct".msats > 0
AND ${intervalClause(range, 'ReferralAct')}
AND NOT users."hideFromTopUsers")) u AND NOT users."hideFromTopUsers")) u
GROUP BY u.id, u.name, u.created_at, u."photoId", u.streak, u."hideCowboyHat" GROUP BY u.id, u.name, u.created_at, u."photoId", u.streak, u."hideCowboyHat"
ORDER BY stacked DESC NULLS LAST, created_at DESC ORDER BY stacked DESC NULLS LAST, created_at DESC
OFFSET $2 OFFSET $3
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) LIMIT $4`, ...range, decodedCursor.offset, limit)
} }
return { return {
@ -696,16 +636,18 @@ export default {
FROM "Streak" WHERE "userId" = ${user.id}` FROM "Streak" WHERE "userId" = ${user.id}`
return max return max
}, },
nitems: async (user, { when }, { models }) => { nitems: async (user, { when, from, to }, { models }) => {
if (typeof user.nitems !== 'undefined') { if (typeof user.nitems !== 'undefined') {
return user.nitems return user.nitems
} }
const [gte, lte] = whenRange(when, from, to)
return await models.item.count({ return await models.item.count({
where: { where: {
userId: user.id, userId: user.id,
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })
@ -725,51 +667,57 @@ export default {
return !!mute return !!mute
}, },
nposts: async (user, { when }, { models }) => { nposts: async (user, { when, from, to }, { models }) => {
if (typeof user.nposts !== 'undefined') { if (typeof user.nposts !== 'undefined') {
return user.nposts return user.nposts
} }
const [gte, lte] = whenRange(when, from, to)
return await models.item.count({ return await models.item.count({
where: { where: {
userId: user.id, userId: user.id,
parentId: null, parentId: null,
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })
}, },
ncomments: async (user, { when }, { models }) => { ncomments: async (user, { when, from, to }, { models }) => {
if (typeof user.ncomments !== 'undefined') { if (typeof user.ncomments !== 'undefined') {
return user.ncomments return user.ncomments
} }
const [gte, lte] = whenRange(when, from, to)
return await models.item.count({ return await models.item.count({
where: { where: {
userId: user.id, userId: user.id,
parentId: { not: null }, parentId: { not: null },
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })
}, },
nbookmarks: async (user, { when }, { models }) => { nbookmarks: async (user, { when, from, to }, { models }) => {
if (typeof user.nBookmarks !== 'undefined') { if (typeof user.nBookmarks !== 'undefined') {
return user.nBookmarks return user.nBookmarks
} }
const [gte, lte] = whenRange(when, from, to)
return await models.bookmark.count({ return await models.bookmark.count({
where: { where: {
userId: user.id, userId: user.id,
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })
}, },
stacked: async (user, { when }, { models }) => { stacked: async (user, { when, from, to }, { models }) => {
if (typeof user.stacked !== 'undefined') { if (typeof user.stacked !== 'undefined') {
return user.stacked return user.stacked
} }
@ -778,34 +726,36 @@ export default {
// forever // forever
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0 return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
} else if (when === 'day') { } else if (when === 'day') {
const range = whenRange(when, from, to)
const [{ stacked }] = await models.$queryRawUnsafe(` const [{ stacked }] = await models.$queryRawUnsafe(`
SELECT sum(amount) as stacked SELECT sum(amount) as stacked
FROM FROM
((SELECT coalesce(sum("ItemAct".msats),0) as amount ((SELECT coalesce(sum("ItemAct".msats),0) as amount
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE act = 'TIP' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2 WHERE act = 'TIP' AND "ItemAct"."userId" <> $3 AND "Item"."userId" = $3
AND "ItemAct".created_at >= $1) AND ${intervalClause(range, 'ItemAct')})
UNION ALL UNION ALL
(SELECT coalesce(sum("ReferralAct".msats),0) as amount (SELECT coalesce(sum("ReferralAct".msats),0) as amount
FROM "ReferralAct" FROM "ReferralAct"
WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $2 WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $3
AND "ReferralAct".created_at >= $1) AND ${intervalClause(range, 'ReferralAct')})
UNION ALL UNION ALL
(SELECT coalesce(sum("Earn".msats), 0) as amount (SELECT coalesce(sum("Earn".msats), 0) as amount
FROM "Earn" FROM "Earn"
WHERE "Earn".msats > 0 AND "Earn"."userId" = $2 WHERE "Earn".msats > 0 AND "Earn"."userId" = $3
AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id)) AND ${intervalClause(range, 'Earn')})) u`, ...range, Number(user.id))
return (stacked && msatsToSats(stacked)) || 0 return (stacked && msatsToSats(stacked)) || 0
} }
return 0 return 0
}, },
spent: async (user, { when }, { models }) => { spent: async (user, { when, from, to }, { models }) => {
if (typeof user.spent !== 'undefined') { if (typeof user.spent !== 'undefined') {
return user.spent return user.spent
} }
const [gte, lte] = whenRange(when, from, to)
const { _sum: { msats } } = await models.itemAct.aggregate({ const { _sum: { msats } } = await models.itemAct.aggregate({
_sum: { _sum: {
msats: true msats: true
@ -813,23 +763,26 @@ export default {
where: { where: {
userId: user.id, userId: user.id,
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })
return (msats && msatsToSats(msats)) || 0 return (msats && msatsToSats(msats)) || 0
}, },
referrals: async (user, { when }, { models }) => { referrals: async (user, { when, from, to }, { models }) => {
if (typeof user.referrals !== 'undefined') { if (typeof user.referrals !== 'undefined') {
return user.referrals return user.referrals
} }
const [gte, lte] = whenRange(when, from, to)
return await models.user.count({ return await models.user.count({
where: { where: {
referrerId: user.id, referrerId: user.id,
createdAt: { createdAt: {
gte: withinDate(when) gte,
lte
} }
} }
}) })

View File

@ -7,12 +7,12 @@ export default gql`
} }
extend type Query { extend type Query {
registrationGrowth(when: String): [TimeData!]! registrationGrowth(when: String, from: String, to: String): [TimeData!]!
itemGrowth(when: String): [TimeData!]! itemGrowth(when: String, from: String, to: String): [TimeData!]!
spendingGrowth(when: String): [TimeData!]! spendingGrowth(when: String, from: String, to: String): [TimeData!]!
spenderGrowth(when: String): [TimeData!]! spenderGrowth(when: String, from: String, to: String): [TimeData!]!
stackingGrowth(when: String): [TimeData!]! stackingGrowth(when: String, from: String, to: String): [TimeData!]!
stackerGrowth(when: String): [TimeData!]! stackerGrowth(when: String, from: String, to: String): [TimeData!]!
} }
type TimeData { type TimeData {

View File

@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
export default gql` export default gql`
extend type Query { extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): Items items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Int): Items
item(id: ID!): Item item(id: ID!): Item
pageTitleAndUnshorted(url: String!): TitleUnshorted pageTitleAndUnshorted(url: String!): TitleUnshorted
dupes(url: String!): [Item!] dupes(url: String!): [Item!]

View File

@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
export default gql` export default gql`
extend type Query { extend type Query {
referrals(when: String): Referrals! referrals(when: String, from: String, to: String): Referrals!
} }
type Referrals { type Referrals {

View File

@ -7,7 +7,7 @@ export default gql`
user(name: String!): User user(name: String!): User
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, by: String, limit: Int): Users topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Int): Users
topCowboys(cursor: String): Users topCowboys(cursor: String): Users
searchUsers(q: String!, limit: Int, similarity: Float): [User!]! searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
hasNewNotes: Boolean! hasNewNotes: Boolean!
@ -61,13 +61,13 @@ export default gql`
id: ID! id: ID!
createdAt: Date! createdAt: Date!
name: String name: String
nitems(when: String): Int! nitems(when: String, from: String, to: String): Int!
nposts(when: String): Int! nposts(when: String, from: String, to: String): Int!
ncomments(when: String): Int! ncomments(when: String, from: String, to: String): Int!
nbookmarks(when: String): Int! nbookmarks(when: String, from: String, to: String): Int!
stacked(when: String): Int! stacked(when: String, from: String, to: String): Int!
spent(when: String): Int! spent(when: String, from: String, to: String): Int!
referrals(when: String): Int! referrals(when: String, from: String, to: String): Int!
freePosts: Int! freePosts: Int!
freeComments: Int! freeComments: Int!
hasInvites: Boolean! hasInvites: Boolean!

View File

@ -14,16 +14,17 @@ import { Cell } from 'recharts/lib/component/Cell'
import { Pie } from 'recharts/lib/polar/Pie' import { Pie } from 'recharts/lib/polar/Pie'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { timeUnitForRange } from '../lib/time'
const dateFormatter = when => { const dateFormatter = (when, from, to) => {
const unit = xAxisName(when, from, to)
return timeStr => { return timeStr => {
const date = new Date(timeStr) const date = new Date(timeStr)
switch (when) { switch (unit) {
case 'day':
case 'week': case 'week':
case 'month':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}` return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
case 'year': case 'month':
case 'forever':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}` return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
default: default:
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}` return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
@ -31,16 +32,25 @@ const dateFormatter = when => {
} }
} }
function xAxisName (when) { const labelFormatter = (when, from, to) => {
const unit = xAxisName(when, from, to)
const dateFormat = dateFormatter(when, from, to)
return timeStr => `${unit} ${dateFormat(timeStr)}`
}
function xAxisName (when, from, to) {
if (from) {
return timeUnitForRange([from, to])
}
switch (when) { switch (when) {
case 'week': case 'week':
case 'month': case 'month':
return 'days' return 'day'
case 'year': case 'year':
case 'forever': case 'forever':
return 'months' return 'month'
default: default:
return 'hours' return 'hour'
} }
} }
@ -72,6 +82,8 @@ export function WhenAreaChart ({ data }) {
data = transformData(data) data = transformData(data)
// need to grab when // need to grab when
const when = router.query.when const when = router.query.when
const from = router.query.from
const to = router.query.to
return ( return (
<ResponsiveContainer width='100%' height={300} minWidth={300}> <ResponsiveContainer width='100%' height={300} minWidth={300}>
@ -85,11 +97,11 @@ export function WhenAreaChart ({ data }) {
}} }}
> >
<XAxis <XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} dataKey='time' tickFormatter={dateFormatter(when, from, to)} name={xAxisName(when, from, to)}
tick={{ fill: 'var(--theme-grey)' }} tick={{ fill: 'var(--theme-grey)' }}
/> />
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} /> <Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)} <Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
@ -107,6 +119,8 @@ export function WhenLineChart ({ data }) {
data = transformData(data) data = transformData(data)
// need to grab when // need to grab when
const when = router.query.when const when = router.query.when
const from = router.query.from
const to = router.query.to
return ( return (
<ResponsiveContainer width='100%' height={300} minWidth={300}> <ResponsiveContainer width='100%' height={300} minWidth={300}>
@ -120,11 +134,11 @@ export function WhenLineChart ({ data }) {
}} }}
> >
<XAxis <XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} dataKey='time' tickFormatter={dateFormatter(when, from, to)} name={xAxisName(when, from, to)}
tick={{ fill: 'var(--theme-grey)' }} tick={{ fill: 'var(--theme-grey)' }}
/> />
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} /> <Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)} <Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
@ -147,6 +161,8 @@ export function WhenComposedChart ({
data = transformData(data) data = transformData(data)
// need to grab when // need to grab when
const when = router.query.when const when = router.query.when
const from = router.query.from
const to = router.query.to
return ( return (
<ResponsiveContainer width='100%' height={300} minWidth={300}> <ResponsiveContainer width='100%' height={300} minWidth={300}>
@ -160,12 +176,12 @@ export function WhenComposedChart ({
}} }}
> >
<XAxis <XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} dataKey='time' tickFormatter={dateFormatter(when, from, to)} name={xAxisName(when, from, to)}
tick={{ fill: 'var(--theme-grey)' }} tick={{ fill: 'var(--theme-grey)' }}
/> />
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} /> <Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{barNames?.map((v, i) => {barNames?.map((v, i) =>
<Bar yAxisId={barAxis} key={v} type='monotone' dataKey={v} name={v} stroke='var(--bs-info)' fill='var(--bs-info)' />)} <Bar yAxisId={barAxis} key={v} type='monotone' dataKey={v} name={v} stroke='var(--bs-info)' fill='var(--bs-info)' />)}

View File

@ -2,7 +2,7 @@ import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form' import BootstrapForm from 'react-bootstrap/Form'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik' import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import Col from 'react-bootstrap/Col' import Col from 'react-bootstrap/Col'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
@ -26,13 +26,16 @@ import 'react-datepicker/dist/react-datepicker.css'
import { debounce } from './use-debounce-callback' import { debounce } from './use-debounce-callback'
import { ImageUpload } from './image' import { ImageUpload } from './image'
import { AWS_S3_URL_REGEXP } from '../lib/constants' import { AWS_S3_URL_REGEXP } from '../lib/constants'
import { dayMonthYear, dayMonthYearToDate, whenToFrom } from '../lib/time'
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, cost, ...props children, variant, value, onClick, disabled, cost, ...props
}) { }) {
const formik = useFormikContext() const formik = useFormikContext()
useEffect(() => { useEffect(() => {
formik?.setFieldValue('cost', cost) if (cost) {
formik?.setFieldValue('cost', cost)
}
}, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost]) }, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost])
return ( return (
@ -712,7 +715,8 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
const StorageKeyPrefixContext = createContext() const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
@ -754,10 +758,12 @@ export function Form ({
// extract cost from formik fields // extract cost from formik fields
// (cost may also be set in a formik field named 'amount') // (cost may also be set in a formik field named 'amount')
let cost = values?.cost || values?.amount let cost = values?.cost || values?.amount
// add potential image fees which are set in a different field if (cost) {
// to differentiate between fees (in receipts for example) // add potential image fees which are set in a different field
cost += (values?.imageFeesInfo?.totalFees || 0) // to differentiate between fees (in receipts for example)
values.cost = cost cost += (values?.imageFeesInfo?.totalFees || 0)
values.cost = cost
}
const options = await onSubmit(values, ...args) const options = await onSubmit(values, ...args)
if (!storageKeyPrefix || options?.keepLocalStorage) return if (!storageKeyPrefix || options?.keepLocalStorage) return
@ -814,7 +820,7 @@ export function Select ({ label, items, groupClassName, onChange, noForm, overri
}} }}
isInvalid={invalid} isInvalid={invalid}
> >
{items.map(item => <option key={item}>{item}</option>)} {items?.map(item => <option key={item}>{item}</option>)}
</BootstrapForm.Select> </BootstrapForm.Select>
<BootstrapForm.Control.Feedback type='invalid'> <BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error} {meta.touched && meta.error}
@ -823,28 +829,81 @@ export function Select ({ label, items, groupClassName, onChange, noForm, overri
) )
} }
export function DatePicker ({ fromName, toName, noForm, onMount, ...props }) { export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to, className, ...props }) {
const formik = noForm ? null : useFormikContext() const formik = noForm ? null : useFormikContext()
const onChangeHandler = props.onChange
const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName }) const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName })
const [,, toHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: toName }) const [,, toHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: toName })
const { minDate, maxDate } = props
const [{ innerFrom, innerTo }, setRange] = useState({
innerFrom: from || whenToFrom(when),
innerTo: to || dayMonthYear(new Date())
})
useEffect(() => { useEffect(() => {
if (onMount) { const tfrom = from || whenToFrom(when)
const [from, to] = onMount() const tto = to || dayMonthYear(new Date())
setRange({ innerFrom: tfrom, innerTo: tto })
if (!noForm) {
fromHelpers.setValue(tfrom)
toHelpers.setValue(tto)
}
}, [when, from, to])
const dateFormat = useMemo(() => {
const now = new Date(2013, 11, 31)
let str = now.toLocaleDateString()
str = str.replace('31', 'dd')
str = str.replace('12', 'MM')
str = str.replace('2013', 'yy')
return str
}, [])
const innerOnChange = ([from, to], e) => {
from = dayMonthYear(from)
to = to ? dayMonthYear(to) : undefined
setRange({ innerFrom: from, innerTo: to })
if (!noForm) {
fromHelpers.setValue(from) fromHelpers.setValue(from)
toHelpers.setValue(to) toHelpers.setValue(to)
} }
}, []) onChange(formik, [from, to], e)
}
const onChangeRawHandler = (e) => {
// raw user data can be incomplete while typing, so quietly bail on exceptions
try {
const dateStrings = e.target.value.split('-', 2)
const dates = dateStrings.map(s => new Date(s))
let [from, to] = dates
if (from) {
if (minDate) from = new Date(Math.max(from, minDate))
try {
if (maxDate) to = new Date(Math.min(to, maxDate))
// if end date isn't valid, set it to the start date
if (!(to instanceof Date && !isNaN(to)) || to < from) to = from
} catch {
to = from
}
innerOnChange([from, to], e)
}
} catch { }
}
return ( return (
<ReactDatePicker <ReactDatePicker
className={`form-control text-center ${className}`}
selectsRange
maxDate={new Date()}
minDate={new Date('2021-05-01')}
{...props} {...props}
onChange={([from, to], e) => { selected={dayMonthYearToDate(innerFrom)}
fromHelpers.setValue(from?.toISOString()) startDate={dayMonthYearToDate(innerFrom)}
toHelpers.setValue(to?.toISOString()) endDate={innerTo ? dayMonthYearToDate(innerTo) : undefined}
onChangeHandler(formik, [from, to], e) dateFormat={dateFormat}
}} onChangeRaw={onChangeRawHandler}
onChange={innerOnChange}
/> />
) )
} }

View File

@ -33,6 +33,8 @@
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border: 1px solid var(--theme-borderColor); border: 1px solid var(--theme-borderColor);
padding: 0rem 0.5rem; padding: 0rem 0.5rem;
display: flex;
align-items: center;
} }
.clearButton:hover, .clearButton:active { .clearButton:hover, .clearButton:active {

View File

@ -78,9 +78,9 @@ const defaultOnClick = n => {
if (type === 'Earn') { if (type === 'Earn') {
let href = '/rewards/' let href = '/rewards/'
if (n.minSortTime !== n.sortTime) { if (n.minSortTime !== n.sortTime) {
href += `${new Date(n.minSortTime).toISOString().slice(0, 10)}/` href += `${dayMonthYear(new Date(n.minSortTime))}/`
} }
href += new Date(n.sortTime).toISOString().slice(0, 10) href += dayMonthYear(new Date(n.sortTime))
return { href } return { href }
} }
if (type === 'Invitification') return { href: '/invites' } if (type === 'Invitification') return { href: '/invites' }

View File

@ -4,6 +4,7 @@ import SearchIcon from '../svgs/search-line.svg'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Form, Input, Select, DatePicker, SubmitButton } from './form' import { Form, Input, Select, DatePicker, SubmitButton } from './form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { dayMonthYear, whenToFrom } from '../lib/time'
export default function Search ({ sub }) { export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
@ -36,6 +37,8 @@ export default function Search ({ sub }) {
if (values.sort === '' || values.sort === 'zaprank') delete values.sort if (values.sort === '' || values.sort === 'zaprank') delete values.sort
if (values.when === '' || values.when === 'forever') delete values.when if (values.when === '' || values.when === 'forever') delete values.when
if (values.when !== 'custom') { delete values.from; delete values.to } if (values.when !== 'custom') { delete values.from; delete values.to }
if (values.from && !values.to) return
await router.push({ await router.push({
pathname: prefix + '/search', pathname: prefix + '/search',
query: values query: values
@ -47,21 +50,14 @@ export default function Search ({ sub }) {
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all' const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all'
const sort = router.query.sort || 'zaprank' const sort = router.query.sort || 'zaprank'
const when = router.query.when || 'forever' const when = router.query.when || 'forever'
const from = router.query.from || new Date().toISOString()
const to = router.query.to || new Date().toISOString()
const [datePicker, setDatePicker] = useState(when === 'custom')
// The following state is needed for the date picker (and driven by the date picker).
// Substituting router.query or formik values would cause network lag and/or timezone issues.
const [range, setRange] = useState({ start: new Date(from), end: new Date(to) })
return ( return (
<> <>
<div className={styles.searchSection}> <div className={styles.searchSection}>
<Container className={`px-md-0 ${styles.searchContainer}`}> <Container className={`px-md-0 ${styles.searchContainer}`}>
<Form <Form
initial={{ q, what, sort, when, from, to }} initial={{ q, what, sort, when, from: '', to: '' }}
onSubmit={search} onSubmit={values => search({ ...values })}
> >
<div className={`${styles.active} my-3`}> <div className={`${styles.active} my-3`}>
<Input <Input
@ -81,7 +77,7 @@ export default function Search ({ sub }) {
<SearchIcon width={22} height={22} /> <SearchIcon width={22} height={22} />
</SubmitButton> </SubmitButton>
</div> </div>
{filter && {filter && router.query.q &&
<div className='text-muted fw-bold d-flex align-items-center flex-wrap pb-2'> <div className='text-muted fw-bold d-flex align-items-center flex-wrap pb-2'>
<div className='text-muted fw-bold d-flex align-items-center pb-2'> <div className='text-muted fw-bold d-flex align-items-center pb-2'>
<Select <Select
@ -107,9 +103,8 @@ export default function Search ({ sub }) {
<Select <Select
groupClassName='mb-0 mx-2' groupClassName='mb-0 mx-2'
onChange={(formik, e) => { onChange={(formik, e) => {
search({ ...formik?.values, when: e.target.value, from: from || new Date().toISOString(), to: to || new Date().toISOString() }) const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: dayMonthYear(new Date()) } : {}
setDatePicker(e.target.value === 'custom') search({ ...formik?.values, when: e.target.value, ...range })
if (e.target.value === 'custom') setRange({ start: new Date(), end: new Date() })
}} }}
name='when' name='when'
size='sm' size='sm'
@ -118,24 +113,17 @@ export default function Search ({ sub }) {
/> />
</>} </>}
</div> </div>
{datePicker && {when === 'custom' &&
<DatePicker <DatePicker
fromName='from' toName='to' fromName='from'
className='form-control p-0 px-2 mb-2 text-center' toName='to'
onMount={() => { className='p-0 px-2 mb-2'
setRange({ start: new Date(from), end: new Date(to) }) onChange={(formik, [from, to], e) => {
return [from, to] search({ ...formik?.values, from, to })
}} }}
onChange={(formik, [start, end], e) => { from={router.query.from}
setRange({ start, end }) to={router.query.to}
search({ ...formik?.values, from: start && start.toISOString(), to: end && end.toISOString() }) when={when}
}}
selected={range.start}
startDate={range.start} endDate={range.end}
selectsRange
dateFormat='MM/dd/yy'
maxDate={new Date()}
minDate={new Date('2021-05-01')}
/>} />}
</div>} </div>}
</Form> </Form>

View File

@ -1,6 +1,7 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Form, Select } from './form' import { Form, Select, DatePicker } from './form'
import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants' import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants'
import { dayMonthYear, whenToFrom } from '../lib/time'
export default function TopHeader ({ sub, cat }) { export default function TopHeader ({ sub, cat }) {
const router = useRouter() const router = useRouter()
@ -24,6 +25,8 @@ export default function TopHeader ({ sub, cat }) {
delete query.by delete query.by
} }
} }
if (when !== 'custom') { delete query.from; delete query.to }
if (query.from && !query.to) return
await router.push({ await router.push({
pathname: `${prefix}/top/${what}/${when || 'day'}`, pathname: `${prefix}/top/${what}/${when || 'day'}`,
@ -39,41 +42,58 @@ export default function TopHeader ({ sub, cat }) {
<div className='d-flex'> <div className='d-flex'>
<Form <Form
className='me-auto' className='me-auto'
initial={{ what, by, when }} initial={{ what, by, when, from: '', to: '' }}
onSubmit={top} onSubmit={top}
> >
<div className='text-muted fw-bold my-3 d-flex align-items-center'> <div className='text-muted fw-bold my-3 d-flex align-items-center flex-wrap'>
top <div className='text-muted fw-bold my-2 d-flex align-items-center'>
<Select top
groupClassName='mx-2 mb-0' <Select
onChange={(formik, e) => top({ ...formik?.values, what: e.target.value })} groupClassName='mx-2 mb-0'
name='what' onChange={(formik, e) => top({ ...formik?.values, what: e.target.value })}
size='sm' name='what'
overrideValue={what} size='sm'
items={router?.query?.sub ? ['posts', 'comments'] : ['posts', 'comments', 'stackers', 'cowboys']} overrideValue={what}
/> items={router?.query?.sub ? ['posts', 'comments'] : ['posts', 'comments', 'stackers', 'cowboys']}
{cat !== 'cowboys' && />
<> {cat !== 'cowboys' &&
by <>
<Select by
groupClassName='mx-2 mb-0' <Select
onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })} groupClassName='mx-2 mb-0'
name='by' onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })}
size='sm' name='by'
overrideValue={by} size='sm'
items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS} overrideValue={by}
/> items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS}
for />
<Select for
groupClassName='mb-0 ms-2' <Select
onChange={(formik, e) => top({ ...formik?.values, when: e.target.value })} groupClassName='mb-0 mx-2'
name='when' onChange={(formik, e) => {
size='sm' const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: dayMonthYear(new Date()) } : {}
overrideValue={when} top({ ...formik?.values, when: e.target.value, ...range })
items={WHENS} }}
/> name='when'
</>} size='sm'
overrideValue={when}
items={WHENS}
/>
</>}
</div>
{when === 'custom' &&
<DatePicker
fromName='from'
toName='to'
className='p-0 px-2 my-2'
onChange={(formik, [from, to], e) => {
top({ ...formik?.values, from, to })
}}
from={router.query.from}
to={router.query.to}
when={when}
/>}
</div> </div>
</Form> </Form>
</div> </div>

View File

@ -1,23 +1,56 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Select } from './form' import { Select, DatePicker } from './form'
import { WHENS } from '../lib/constants' import { WHENS } from '../lib/constants'
import { dayMonthYear, whenToFrom } from '../lib/time'
export function UsageHeader () { export function UsageHeader () {
const router = useRouter() const router = useRouter()
const select = async values => {
const { when, ...query } = values
if (when !== 'custom') { delete query.from; delete query.to }
if (query.from && !query.to) return
await router.push({
pathname: `/stackers/${when}`,
query
})
}
const when = router.query.when || 'day'
return ( return (
<div className='text-muted fw-bold my-3 d-flex align-items-center'> <div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
stacker analytics for <div className='text-muted fw-bold my-2 d-flex align-items-center'>
<Select stacker analytics for
groupClassName='mb-0 ms-2' <Select
className='w-auto' groupClassName='mb-0 mx-2'
name='when' className='w-auto'
size='sm' name='when'
items={WHENS} size='sm'
value={router.query.when || 'day'} items={WHENS}
noForm value={when}
onChange={(formik, e) => router.push(`/stackers/${e.target.value}`)} noForm
/> onChange={(formik, e) => {
const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: dayMonthYear(new Date()) } : {}
select({ when: e.target.value, ...range })
}}
/>
</div>
{when === 'custom' &&
<DatePicker
noForm
fromName='from'
toName='to'
className='p-0 px-2 mb-0'
onChange={(formik, [from, to], e) => {
select({ when, from, to })
}}
from={router.query.from}
to={router.query.to}
when={when}
/>}
</div> </div>
) )
} }

View File

@ -24,12 +24,12 @@ export const SUB_ITEMS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
${COMMENTS_ITEM_EXT_FIELDS} ${COMMENTS_ITEM_EXT_FIELDS}
query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) { query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $from: String, $to: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
sub(name: $sub) { sub(name: $sub) {
...SubFields ...SubFields
} }
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) { items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, from: $from, to: $to, by: $by, limit: $limit) {
cursor cursor
items { items {
...ItemFields ...ItemFields

View File

@ -166,19 +166,19 @@ export const USER_FIELDS = gql`
}` }`
export const TOP_USERS = gql` export const TOP_USERS = gql`
query TopUsers($cursor: String, $when: String, $by: String, $limit: Int) { query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, $limit: Int) {
topUsers(cursor: $cursor, when: $when, by: $by, limit: $limit) { topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by, limit: $limit) {
users { users {
id id
name name
streak streak
hideCowboyHat hideCowboyHat
photoId photoId
stacked(when: $when) stacked(when: $when, from: $from, to: $to)
spent(when: $when) spent(when: $when, from: $from, to: $to)
ncomments(when: $when) ncomments(when: $when, from: $from, to: $to)
nposts(when: $when) nposts(when: $when, from: $from, to: $to)
referrals(when: $when) referrals(when: $when, from: $from, to: $to)
} }
cursor cursor
} }
@ -233,11 +233,11 @@ export const USER_WITH_ITEMS = gql`
${USER_FIELDS} ${USER_FIELDS}
${ITEM_FIELDS} ${ITEM_FIELDS}
${COMMENTS_ITEM_EXT_FIELDS} ${COMMENTS_ITEM_EXT_FIELDS}
query UserWithItems($name: String!, $sub: String, $cursor: String, $type: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) { query UserWithItems($name: String!, $sub: String, $cursor: String, $type: String, $when: String, $from: String, $to: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
user(name: $name) { user(name: $name) {
...UserFields ...UserFields
} }
items(sub: $sub, sort: "user", cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) { items(sub: $sub, sort: "user", cursor: $cursor, type: $type, name: $name, when: $when, from: $from, to: $to, by: $by, limit: $limit) {
cursor cursor
items { items {
...ItemFields ...ItemFields

View File

@ -35,7 +35,7 @@ function getClient (uri) {
Query: { Query: {
fields: { fields: {
topUsers: { topUsers: {
keyArgs: ['when', 'by'], keyArgs: ['when', 'by', 'from', 'to', 'limit'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.users)) { if (isFirstPage(incoming.cursor, existing?.users)) {
return incoming return incoming
@ -61,7 +61,7 @@ function getClient (uri) {
} }
}, },
items: { items: {
keyArgs: ['sub', 'sort', 'type', 'name', 'when', 'by'], keyArgs: ['sub', 'sort', 'type', 'name', 'when', 'by', 'from', 'to', 'limit'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) { if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming return incoming
@ -94,7 +94,7 @@ function getClient (uri) {
} }
}, },
search: { search: {
keyArgs: ['q', 'sub', 'sort', 'what', 'when'], keyArgs: ['q', 'sub', 'sort', 'what', 'when', 'from', 'to', 'limit'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) { if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming return incoming

View File

@ -34,7 +34,7 @@ export const DONT_LIKE_THIS_COST = 1
export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'] export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks']
export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals'] export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
export const ITEM_SORTS = ['zaprank', 'comments', 'sats'] export const ITEM_SORTS = ['zaprank', 'comments', 'sats']
export const WHENS = ['day', 'week', 'month', 'year', 'forever'] export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
export const ITEM_TYPES = context => { export const ITEM_TYPES = context => {
const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls'] const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls']
if (!context) { if (!context) {

View File

@ -34,6 +34,10 @@ export function datePivot (date,
} }
export const dayMonthYear = when => new Date(when).toISOString().slice(0, 10) export const dayMonthYear = when => new Date(when).toISOString().slice(0, 10)
export const dayMonthYearToDate = when => {
const [year, month, day] = when.split('-')
return new Date(+year, month - 1, day)
}
export function timeLeft (timeStamp) { export function timeLeft (timeStamp) {
const now = new Date() const now = new Date()
@ -57,4 +61,40 @@ export function timeLeft (timeStamp) {
} }
} }
export function timeUnitForRange ([from, to]) {
const date1 = new Date(from)
const date2 = new Date(to)
const diffTime = Math.abs(date2 - date1)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays < 7) {
return 'hour'
}
if (diffDays < 90) {
return 'day'
}
if (diffDays < 180) {
return 'week'
}
return 'month'
}
export const whenToFrom = (when) => {
switch (when) {
case 'day':
return dayMonthYear(new Date(), { hours: -24 })
case 'week':
return dayMonthYear(datePivot(new Date(), { days: -7 }))
case 'month':
return dayMonthYear(datePivot(new Date(), { days: -30 }))
case 'year':
return dayMonthYear(datePivot(new Date(), { days: -365 }))
default:
return dayMonthYear(new Date('2021-05-01'))
}
}
export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms))

View File

@ -6,7 +6,8 @@ import { useQuery } from '@apollo/client'
import { COMMENT_TYPE_QUERY, ITEM_SORTS, ITEM_TYPES, WHENS } from '../../lib/constants' import { COMMENT_TYPE_QUERY, ITEM_SORTS, ITEM_TYPES, WHENS } from '../../lib/constants'
import PageLoading from '../../components/page-loading' import PageLoading from '../../components/page-loading'
import { UserLayout } from '.' import { UserLayout } from '.'
import { Form, Select } from '../../components/form' import { Form, Select, DatePicker } from '../../components/form'
import { dayMonthYear, whenToFrom } from '../../lib/time'
const staticVariables = { sort: 'user' } const staticVariables = { sort: 'user' }
const variablesFunc = vars => ({ const variablesFunc = vars => ({
@ -47,6 +48,8 @@ function UserItemsHeader ({ type, name }) {
if (!type || type === 'all' || !ITEM_TYPES('user').includes(type)) type = 'all' if (!type || type === 'all' || !ITEM_TYPES('user').includes(type)) type = 'all'
if (!query.by || query.by === 'recent' || !ITEM_SORTS.includes(query.by)) delete query.by if (!query.by || query.by === 'recent' || !ITEM_SORTS.includes(query.by)) delete query.by
if (!query.when || query.when === 'forever' || !WHENS.includes(query.when) || query.when === 'forever') delete query.when if (!query.when || query.when === 'forever' || !WHENS.includes(query.when) || query.when === 'forever') delete query.when
if (query.when !== 'custom') { delete query.from; delete query.to }
if (query.from && !query.to) return
await router.push({ await router.push({
pathname: `/${name}/${type}`, pathname: `/${name}/${type}`,
@ -60,39 +63,55 @@ function UserItemsHeader ({ type, name }) {
return ( return (
<Form <Form
initial={{ type, by, when }} initial={{ type, by, when, from: '', to: '' }}
onSubmit={select} onSubmit={select}
> >
<div className='text-muted fw-bold mt-0 mb-3 d-flex justify-content-start align-items-center'> <div className='text-muted fw-bold mt-0 mb-3 d-flex justify-content-start align-items-center flex-wrap'>
<Select <div className='text-muted fw-bold mt-0 mb-2 d-flex justify-content-start align-items-center'>
groupClassName='mb-0 me-2' <Select
className='w-auto' groupClassName='mb-0 me-2'
name='type' className='w-auto'
size='sm' name='type'
overrideValue={type} size='sm'
items={ITEM_TYPES('user')} overrideValue={type}
onChange={(formik, e) => select({ ...formik?.values, type: e.target.value })} items={ITEM_TYPES('user')}
/> onChange={(formik, e) => select({ ...formik?.values, type: e.target.value })}
by />
<Select by
groupClassName='mb-0 mx-2' <Select
className='w-auto' groupClassName='mb-0 mx-2'
name='by' className='w-auto'
size='sm' name='by'
overrideValue={by} size='sm'
items={['recent', ...ITEM_SORTS]} overrideValue={by}
onChange={(formik, e) => select({ ...formik?.values, by: e.target.value })} items={['recent', ...ITEM_SORTS]}
/> onChange={(formik, e) => select({ ...formik?.values, by: e.target.value })}
for />
<Select for
groupClassName='mb-0 ms-2' <Select
className='w-auto' groupClassName='mb-0 mx-2'
name='when' className='w-auto'
size='sm' name='when'
items={WHENS} size='sm'
overrideValue={when} items={WHENS}
onChange={(formik, e) => select({ ...formik?.values, when: e.target.value })} overrideValue={when}
/> onChange={(formik, e) => {
const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: dayMonthYear(new Date()) } : {}
select({ ...formik?.values, when: e.target.value, ...range })
}}
/>
</div>
{when === 'custom' &&
<DatePicker
fromName='from' toName='to'
className='p-0 px-2 mb-2'
onChange={(formik, [from, to], e) => {
select({ ...formik?.values, from, to })
}}
from={router.query.from}
to={router.query.to}
when={when}
/>}
</div> </div>
</Form> </Form>
) )

View File

@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../api/ssrApollo' import { getGetServerSideProps } from '../../api/ssrApollo'
import { CopyInput, Select } from '../../components/form' import { CopyInput, Select, DatePicker } from '../../components/form'
import { CenterLayout } from '../../components/layout' import { CenterLayout } from '../../components/layout'
import { useMe } from '../../components/me' import { useMe } from '../../components/me'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
@ -10,15 +10,16 @@ import PageLoading from '../../components/page-loading'
import { WHENS } from '../../lib/constants' import { WHENS } from '../../lib/constants'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { numWithUnits } from '../../lib/format' import { numWithUnits } from '../../lib/format'
import { dayMonthYear, whenToFrom } from '../../lib/time'
const WhenComposedChart = dynamic(() => import('../../components/charts').then(mod => mod.WhenComposedChart), { const WhenComposedChart = dynamic(() => import('../../components/charts').then(mod => mod.WhenComposedChart), {
loading: () => <div>Loading...</div> loading: () => <div>Loading...</div>
}) })
const REFERRALS = gql` const REFERRALS = gql`
query Referrals($when: String!) query Referrals($when: String!, $from: String, $to: String)
{ {
referrals(when: $when) { referrals(when: $when, from: $from, to: $to) {
totalSats totalSats
totalReferrals totalReferrals
stats { stats {
@ -37,26 +38,58 @@ export default function Referrals ({ ssrData }) {
const router = useRouter() const router = useRouter()
const me = useMe() const me = useMe()
const { data } = useQuery(REFERRALS, { variables: { when: router.query.when } }) const select = async values => {
const { when, ...query } = values
if (when !== 'custom') { delete query.from; delete query.to }
if (query.from && !query.to) return
await router.push({
pathname: `/referrals/${when}`,
query
})
}
const { data } = useQuery(REFERRALS, { variables: { when: router.query.when, from: router.query.from, to: router.query.to } })
if (!data && !ssrData) return <PageLoading /> if (!data && !ssrData) return <PageLoading />
const { referrals: { totalSats, totalReferrals, stats } } = data || ssrData const { referrals: { totalSats, totalReferrals, stats } } = data || ssrData
const when = router.query.when
return ( return (
<CenterLayout footerLinks> <CenterLayout footerLinks>
<h4 className='fw-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'> <div className='fw-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center flex-wrap'>
{numWithUnits(totalReferrals, { unitPlural: 'referrals', unitSingular: 'referral' })} & {numWithUnits(totalSats, { abbreviate: false })} in the last <h4 className='fw-bold text-muted text-center d-flex align-items-center justify-content-center'>
<Select {numWithUnits(totalReferrals, { unitPlural: 'referrals', unitSingular: 'referral' })} & {numWithUnits(totalSats, { abbreviate: false })} in the last
groupClassName='mb-0 ms-2' <Select
className='w-auto' groupClassName='mb-0 mx-2'
name='when' className='w-auto'
size='sm' name='when'
items={WHENS} size='sm'
value={router.query.when || 'day'} items={WHENS}
noForm value={router.query.when || 'day'}
onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)} noForm
/> onChange={(formik, e) => {
</h4> const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: dayMonthYear(new Date()) } : {}
select({ when: e.target.value, ...range })
}}
/>
</h4>
{when === 'custom' &&
<DatePicker
noForm
fromName='from'
toName='to'
className='p-0 px-2 mb-2'
onChange={(formik, [from, to], e) => {
select({ when, from, to })
}}
from={router.query.from}
to={router.query.to}
when={router.query.when}
/>}
</div>
<WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} barAxis='right' /> <WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} barAxis='right' />
<div <div

View File

@ -19,44 +19,44 @@ const WhenComposedChart = dynamic(() => import('../../components/charts').then(m
}) })
const GROWTH_QUERY = gql` const GROWTH_QUERY = gql`
query Growth($when: String!) query Growth($when: String!, $from: String, $to: String)
{ {
registrationGrowth(when: $when) { registrationGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
value value
} }
} }
itemGrowth(when: $when) { itemGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
value value
} }
} }
spendingGrowth(when: $when) { spendingGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
value value
} }
} }
spenderGrowth(when: $when) { spenderGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
value value
} }
} }
stackingGrowth(when: $when) { stackingGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
value value
} }
} }
stackerGrowth(when: $when) { stackerGrowth(when: $when, from: $from, to: $to) {
time time
data { data {
name name
@ -69,10 +69,10 @@ export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY })
export default function Growth ({ ssrData }) { export default function Growth ({ ssrData }) {
const router = useRouter() const router = useRouter()
const { when } = router.query const { when, from, to } = router.query
const avg = ['year', 'forever'].includes(when) ? 'avg daily ' : '' const avg = ['year', 'forever'].includes(when) ? 'avg daily ' : ''
const { data } = useQuery(GROWTH_QUERY, { variables: { when } }) const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to } })
if (!data && !ssrData) return <PageLoading /> if (!data && !ssrData) return <PageLoading />
const { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } = data || ssrData const { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } = data || ssrData

View File

@ -7,8 +7,9 @@ import { SUB_ITEMS } from '../../../../fragments/subs'
import { COMMENT_TYPE_QUERY } from '../../../../lib/constants' import { COMMENT_TYPE_QUERY } from '../../../../lib/constants'
const staticVariables = { sort: 'top' } const staticVariables = { sort: 'top' }
const variablesFunc = vars => const variablesFunc = vars => {
({ includeComments: COMMENT_TYPE_QUERY.includes(vars.type), ...staticVariables, ...vars }) return ({ includeComments: COMMENT_TYPE_QUERY.includes(vars.type), ...staticVariables, ...vars })
}
export const getServerSideProps = getGetServerSideProps({ export const getServerSideProps = getGetServerSideProps({
query: SUB_ITEMS, query: SUB_ITEMS,
variables: variablesFunc, variables: variablesFunc,

View File

@ -855,4 +855,12 @@ div[contenteditable]:focus,
.popover-body { .popover-body {
color: var(--bs-body-color); color: var(--bs-body-color);
} }
} }
// To satisfy assumptions of the date picker component.
.react-datepicker__navigation-icon {
line-height: normal;
}
.react-datepicker__navigation-icon::before, .react-datepicker__navigation-icon::after {
box-sizing: content-box;
}

View File

@ -106,7 +106,7 @@ export function indexAllItems ({ apollo }) {
query: gql` query: gql`
${ITEM_SEARCH_FIELDS} ${ITEM_SEARCH_FIELDS}
query AllItems($cursor: String) { query AllItems($cursor: String) {
items(cursor: $cursor, sort: "recent", limit: 100, type: "all") { items(cursor: $cursor, sort: "recent", limit: 1000, type: "all") {
items { items {
...ItemSearchFields ...ItemSearchFields
} }