From 245419185f6c065685bec352fcd660417490d989 Mon Sep 17 00:00:00 2001 From: Keyan <34140557+huumn@users.noreply.github.com> Date: Fri, 11 Oct 2024 19:14:18 -0500 Subject: [PATCH] wallet streaks (#1468) * wallet streaks backend * notifications and badges * reuseable streak fragment * squash migrations * push notifications * update cowboy notification setting label text --- api/resolvers/notifications.js | 8 + api/typeDefs/notifications.js | 1 + api/typeDefs/user.js | 2 + components/badge.js | 93 +++++++ components/hat.js | 59 ----- components/item-info.js | 13 +- components/item-job.js | 4 +- components/nav/common.js | 25 +- components/notifications.js | 19 +- components/territory-header.js | 4 +- components/user-header.js | 4 +- components/user-list.js | 6 +- fragments/comments.js | 21 +- fragments/invites.js | 6 +- fragments/items.js | 21 +- fragments/notifications.js | 1 + fragments/subs.js | 17 +- fragments/users.js | 170 ++++++------- lib/constants.js | 64 +++-- lib/webPush.js | 16 +- pages/rewards/index.js | 2 + pages/settings/index.js | 2 +- .../migration.sql | 27 ++ prisma/schema.prisma | 31 ++- scripts/newsletter.js | 2 + styles/globals.scss | 4 + svgs/holster.svg | 3 + svgs/horse.svg | 18 ++ svgs/revolver.svg | 1 + svgs/saddle.svg | 3 + worker/paidAction.js | 12 +- worker/streak.js | 235 +++++++++++------- worker/thisDay.js | 2 + 33 files changed, 573 insertions(+), 323 deletions(-) create mode 100644 components/badge.js delete mode 100644 components/hat.js create mode 100644 prisma/migrations/20241009200148_wallet_streaks/migration.sql create mode 100644 svgs/holster.svg create mode 100644 svgs/horse.svg create mode 100644 svgs/revolver.svg create mode 100644 svgs/saddle.svg diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9cd3b9c1..20b6ceac 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -465,6 +465,14 @@ export default { ` return res.length ? res[0].days : null + }, + type: async (n, args, { models }) => { + const res = await models.$queryRaw` + SELECT "type" + FROM "Streak" + WHERE id = ${Number(n.id)} + ` + return res.length ? res[0].type : null } }, Earn: { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 14b44a7b..4eabb356 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -79,6 +79,7 @@ export default gql` id: ID! sortTime: Date! days: Int + type: String! } type Earn { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index fcd0bfbd..0f208951 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -192,6 +192,8 @@ export default gql` spent(when: String, from: String, to: String): Int referrals(when: String, from: String, to: String): Int streak: Int + gunStreak: Int + horseStreak: Int maxStreak: Int isContributor: Boolean githubId: String diff --git a/components/badge.js b/components/badge.js new file mode 100644 index 00000000..10b01734 --- /dev/null +++ b/components/badge.js @@ -0,0 +1,93 @@ +import Badge from 'react-bootstrap/Badge' +import OverlayTrigger from 'react-bootstrap/OverlayTrigger' +import Tooltip from 'react-bootstrap/Tooltip' +import CowboyHatIcon from '@/svgs/cowboy.svg' +import AnonIcon from '@/svgs/spy-fill.svg' +import { numWithUnits } from '@/lib/format' +import { USER_ID } from '@/lib/constants' +import GunIcon from '@/svgs/revolver.svg' +import HorseIcon from '@/svgs/horse.svg' +import classNames from 'classnames' + +const BADGES = [ + { + icon: CowboyHatIcon, + streakName: 'streak' + }, + { + icon: HorseIcon, + streakName: 'horseStreak' + }, + { + icon: GunIcon, + streakName: 'gunStreak', + sizeDelta: 2 + } +] + +export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) { + if (!user || Number(user.id) === USER_ID.ad) return null + if (Number(user.id) === USER_ID.anon) { + return ( + + {badge + ? ( + + + ) + : } + + ) + } + + return ( + + {BADGES.map(({ icon, streakName, sizeDelta }, i) => ( + 0 && spacingClassName)} + IconForBadge={icon} + height={height} + width={width} + sizeDelta={sizeDelta} + /> + ))} + + ) +} + +function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) { + const streak = user.optional[streakName] + if (streak === null) { + return null + } + + return ( + + + + ) +} + +export function BadgeTooltip ({ children, overlayText, placement }) { + return ( + + {overlayText} + + } + trigger={['hover', 'focus']} + > + {children} + + ) +} diff --git a/components/hat.js b/components/hat.js deleted file mode 100644 index 3838061a..00000000 --- a/components/hat.js +++ /dev/null @@ -1,59 +0,0 @@ -import Badge from 'react-bootstrap/Badge' -import OverlayTrigger from 'react-bootstrap/OverlayTrigger' -import Tooltip from 'react-bootstrap/Tooltip' -import CowboyHatIcon from '@/svgs/cowboy.svg' -import AnonIcon from '@/svgs/spy-fill.svg' -import { numWithUnits } from '@/lib/format' -import { USER_ID } from '@/lib/constants' - -export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) { - if (!user || Number(user.id) === USER_ID.ad) return null - if (Number(user.id) === USER_ID.anon) { - return ( - - {badge - ? ( - - - ) - : } - - ) - } - - const streak = user.optional.streak - if (streak === null) { - return null - } - - return ( - - {badge - ? ( - - - {streak || 'new'} - ) - : } - - ) -} - -export function HatTooltip ({ children, overlayText, placement }) { - return ( - - {overlayText} - - } - trigger={['hover', 'focus']} - > - {children} - - ) -} diff --git a/components/item-info.js b/components/item-info.js index e55b48fc..ba0c3732 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -14,7 +14,7 @@ import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this' import BookmarkDropdownItem from './bookmark' import SubscribeDropdownItem from './subscribe' import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share' -import Hat from './hat' +import Badges from './badge' import { USER_ID } from '@/lib/constants' import ActionDropdown from './action-dropdown' import MuteDropdownItem from './mute' @@ -111,12 +111,11 @@ export default function ItemInfo ({ \ {showUser && - - - @{item.user.name} - {embellishUser} - - } + + @{item.user.name} + + {embellishUser} + } {timeSince(new Date(item.createdAt))} diff --git a/components/item-job.js b/components/item-job.js index b8c7876c..f5e7f274 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -8,7 +8,7 @@ import Link from 'next/link' import { timeSince } from '@/lib/time' import EmailIcon from '@/svgs/mail-open-line.svg' import Share from './share' -import Hat from './hat' +import Badges from './badge' import { MEDIA_URL } from '@/lib/constants' import { abbrNum } from '@/lib/format' import { Badge } from 'react-bootstrap' @@ -54,7 +54,7 @@ export default function ItemJob ({ item, toc, rank, children }) { {item.boost > 0 && {abbrNum(item.boost)} boost \ } - @{item.user.name} + @{item.user.name} diff --git a/components/nav/common.js b/components/nav/common.js index c5344688..fa2a5617 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -14,7 +14,7 @@ import HiddenWalletSummary from '../hidden-wallet-summary' import { abbrNum, msatsToSats } from '../../lib/format' import { useServiceWorker } from '../serviceworker' import { signOut } from 'next-auth/react' -import Hat from '../hat' +import Badges from '../badge' import { randInRange } from '../../lib/rand' import { useLightning } from '../lightning' import LightningIcon from '../../svgs/bolt.svg' @@ -165,12 +165,21 @@ export function NavWalletSummary ({ className }) { export function MeDropdown ({ me, dropNavKey }) { if (!me) return null return ( -
+
- - {`@${me.name}`} - +
+ + {`@${me.name}`} + {!me.bioId && + + + {' '} + + } + + +
@@ -205,10 +214,6 @@ export function MeDropdown ({ me, dropNavKey }) {
- {!me.bioId && - - {' '} - }
) } @@ -377,7 +382,7 @@ export function AnonDropdown ({ path }) { - @anon + @anon diff --git a/components/notifications.js b/components/notifications.js index 22478990..7fb10dbe 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -14,6 +14,8 @@ import UserAdd from '@/svgs/user-add-fill.svg' import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants' import CowboyHatIcon from '@/svgs/cowboy.svg' import BaldIcon from '@/svgs/bald.svg' +import GunIcon from '@/svgs/revolver.svg' +import HorseIcon from '@/svgs/horse.svg' import { RootProvider } from './root' import Alert from 'react-bootstrap/Alert' import styles from './notifications.module.css' @@ -39,6 +41,8 @@ import { useRetryCreateItem } from './use-item-submit' import { payBountyCacheMods } from './pay-bounty' import { useToast } from './toast' import classNames from 'classnames' +import HolsterIcon from '@/svgs/holster.svg' +import SaddleIcon from '@/svgs/saddle.svg' function Notification ({ n, fresh }) { const type = n.__typename @@ -168,23 +172,28 @@ const defaultOnClick = n => { function Streak ({ n }) { function blurb (n) { - const index = Number(n.id) % Math.min(FOUND_BLURBS.length, LOST_BLURBS.length) + const type = n.type ?? 'COWBOY_HAT' + const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length) if (n.days) { return `After ${numWithUnits(n.days, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' - })}, ` + LOST_BLURBS[index] + })}, ` + LOST_BLURBS[type][index] } - return FOUND_BLURBS[index] + return FOUND_BLURBS[type][index] } + const Icon = n.days + ? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon + : n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon + return (
-
{n.days ? : }
+
- you {n.days ? 'lost your' : 'found a'} cowboy hat + you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}
{blurb(n)}
diff --git a/components/territory-header.js b/components/territory-header.js index f204dacc..6e63d392 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -5,7 +5,7 @@ import Link from 'next/link' import Text from './text' import { numWithUnits } from '@/lib/format' import styles from './item.module.css' -import Hat from './hat' +import Badges from './badge' import { useMe } from './me' import Share from './share' import { gql, useMutation } from '@apollo/client' @@ -41,7 +41,7 @@ export function TerritoryInfo ({ sub }) {
founded by - @{sub.user.name} + @{sub.user.name} on {new Date(sub.createdAt).toDateString()} diff --git a/components/user-header.js b/components/user-header.js index f7971060..75409918 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -17,7 +17,7 @@ import Avatar from './avatar' import { userSchema } from '@/lib/validate' import { useShowModal } from './modal' import { numWithUnits } from '@/lib/format' -import Hat from './hat' +import Badges from './badge' import SubscribeUserDropdownItem from './subscribeUser' import ActionDropdown from './action-dropdown' import CodeIcon from '@/svgs/terminal-box-fill.svg' @@ -178,7 +178,7 @@ function NymView ({ user, isMe, setEditting }) { const { me } = useMe() return (
-
@{user.name}
+
@{user.name}
{isMe && } {!isMe && me && } diff --git a/components/user-list.js b/components/user-list.js index ea452e74..c2c2f953 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -7,7 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { useQuery } from '@apollo/client' import MoreFooter from './more-footer' import { useData } from './use-data' -import Hat from './hat' +import Badges from './badge' import { useMe } from './me' import { MEDIA_URL } from '@/lib/constants' import { NymActionDropdown } from '@/components/user-header' @@ -57,7 +57,7 @@ export function UserListRow ({ user, stats, className, onNymClick, showHat = tru style={{ textUnderlineOffset: '0.25em' }} onClick={onNymClick} > - @{user.name}{showHat && }{selected && } + @{user.name}{showHat && }{selected && } {stats && (
@@ -81,7 +81,7 @@ export function UserBase ({ user, className, children, nymActionDropdown }) {
- @{user.name} + @{user.name} {nymActionDropdown && }
diff --git a/fragments/comments.js b/fragments/comments.js index 2abe51ee..0813dc9c 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -1,6 +1,18 @@ import { gql } from '@apollo/client' +// we can't import from users because of circular dependency +const STREAK_FIELDS = gql` + fragment StreakFields on User { + optional { + streak + gunStreak + horseStreak + } + } +` + export const COMMENT_FIELDS = gql` + ${STREAK_FIELDS} fragment CommentFields on Item { id position @@ -11,10 +23,8 @@ export const COMMENT_FIELDS = gql` user { id name - optional { - streak - } meMute + ...StreakFields } sats meAnonSats @client @@ -45,6 +55,7 @@ export const COMMENT_FIELDS = gql` ` export const COMMENTS_ITEM_EXT_FIELDS = gql` + ${STREAK_FIELDS} fragment CommentItemExtFields on Item { text root { @@ -61,10 +72,8 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql` } user { name - optional { - streak - } id + ...StreakFields } } }` diff --git a/fragments/invites.js b/fragments/invites.js index 7642aa81..038ba53f 100644 --- a/fragments/invites.js +++ b/fragments/invites.js @@ -1,6 +1,8 @@ import { gql } from '@apollo/client' +import { STREAK_FIELDS } from './users' export const INVITE_FIELDS = gql` + ${STREAK_FIELDS} fragment InviteFields on Invite { id createdAt @@ -14,9 +16,7 @@ export const INVITE_FIELDS = gql` user { id name - optional { - streak - } + ...StreakFields } poor } diff --git a/fragments/items.js b/fragments/items.js index 195693d7..6e1a9f40 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -1,7 +1,19 @@ import { gql } from '@apollo/client' import { COMMENTS } from './comments' +// we can't import from users because of circular dependency +const STREAK_FIELDS = gql` + fragment StreakFields on User { + optional { + streak + gunStreak + horseStreak + } + } +` + export const ITEM_FIELDS = gql` + ${STREAK_FIELDS} fragment ItemFields on Item { id parentId @@ -12,10 +24,8 @@ export const ITEM_FIELDS = gql` user { id name - optional { - streak - } meMute + ...StreakFields } sub { name @@ -69,6 +79,7 @@ export const ITEM_FIELDS = gql` export const ITEM_FULL_FIELDS = gql` ${ITEM_FIELDS} + ${STREAK_FIELDS} fragment ItemFullFields on Item { ...ItemFields text @@ -82,9 +93,7 @@ export const ITEM_FULL_FIELDS = gql` user { id name - optional { - streak - } + ...StreakFields } sub { name diff --git a/fragments/notifications.js b/fragments/notifications.js index 1b09f081..ce588ccc 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -86,6 +86,7 @@ export const NOTIFICATIONS = gql` id sortTime days + type } ... on Earn { id diff --git a/fragments/subs.js b/fragments/subs.js index c9c4423e..8b84f8d7 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -2,6 +2,17 @@ import { gql } from '@apollo/client' import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items' import { COMMENTS_ITEM_EXT_FIELDS } from './comments' +// we can't import from users because of circular dependency +const STREAK_FIELDS = gql` + fragment StreakFields on User { + optional { + streak + gunStreak + horseStreak + } + } +` + export const SUB_FIELDS = gql` fragment SubFields on Sub { name @@ -26,15 +37,13 @@ export const SUB_FIELDS = gql` export const SUB_FULL_FIELDS = gql` ${SUB_FIELDS} - + ${STREAK_FIELDS} fragment SubFullFields on Sub { ...SubFields user { name id - optional { - streak - } + ...StreakFields } }` diff --git a/fragments/users.js b/fragments/users.js index 8e0f9203..26d92144 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -3,47 +3,58 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments' import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items' import { SUB_FULL_FIELDS } from './subs' -export const ME = gql` - { - me { - id - name - bioId - photoId - privates { - autoDropBolt11s - diagnostics - noReferralLinks - fiatCurrency - autoWithdrawMaxFeePercent - autoWithdrawThreshold - withdrawMaxFeeDefault - satsFilter - hideFromTopUsers - hideWalletBalance - hideWelcomeBanner - imgproxyOnly - showImagesAndVideos - nostrCrossposting - sats - tipDefault - tipRandom - tipRandomMin - tipRandomMax - tipPopover - turboTipping - zapUndos - upvotePopover - wildWestMode - disableFreebies - } - optional { - isContributor - stacked - streak - } +export const STREAK_FIELDS = gql` + fragment StreakFields on User { + optional { + streak + gunStreak + horseStreak } - }` + } +` + +export const ME = gql` +${STREAK_FIELDS} +{ + me { + id + name + bioId + photoId + privates { + autoDropBolt11s + diagnostics + noReferralLinks + fiatCurrency + autoWithdrawMaxFeePercent + autoWithdrawThreshold + withdrawMaxFeeDefault + satsFilter + hideFromTopUsers + hideWalletBalance + hideWelcomeBanner + imgproxyOnly + showImagesAndVideos + nostrCrossposting + sats + tipDefault + tipRandom + tipRandomMin + tipRandomMax + tipPopover + turboTipping + zapUndos + upvotePopover + wildWestMode + disableFreebies + } + optional { + isContributor + stacked + } + ...StreakFields + } +}` export const SETTINGS_FIELDS = gql` fragment SettingsFields on User { @@ -101,61 +112,52 @@ export const SETTINGS_FIELDS = gql` }` export const SETTINGS = gql` -${SETTINGS_FIELDS} -query Settings { - settings { - ...SettingsFields - } -}` + ${SETTINGS_FIELDS} + query Settings { + settings { + ...SettingsFields + } + }` -export const SET_SETTINGS = -gql` -${SETTINGS_FIELDS} -mutation setSettings($settings: SettingsInput!) { - setSettings(settings: $settings) { - ...SettingsFields - } -} -` +export const SET_SETTINGS = gql` + ${SETTINGS_FIELDS} + mutation setSettings($settings: SettingsInput!) { + setSettings(settings: $settings) { + ...SettingsFields + } + }` -export const DELETE_WALLET = -gql` -mutation removeWallet { - removeWallet -} -` +export const DELETE_WALLET = gql` + mutation removeWallet { + removeWallet + }` -export const NAME_QUERY = -gql` +export const NAME_QUERY = gql` query nameAvailable($name: String!) { nameAvailable(name: $name) - } -` + }` -export const NAME_MUTATION = -gql` +export const NAME_MUTATION = gql` mutation setName($name: String!) { setName(name: $name) } ` -export const WELCOME_BANNER_MUTATION = -gql` +export const WELCOME_BANNER_MUTATION = gql` mutation hideWelcomeBanner { hideWelcomeBanner } ` -export const USER_SUGGESTIONS = -gql` +export const USER_SUGGESTIONS = gql` query userSuggestions($q: String!, $limit: Limit) { userSuggestions(q: $q, limit: $limit) { name } }` -export const USER_SEARCH = -gql` +export const USER_SEARCH = gql` +${STREAK_FIELDS} query searchUsers($q: String!, $limit: Limit, $similarity: Float) { searchUsers(q: $q, limit: $limit, similarity: $similarity) { id @@ -165,15 +167,16 @@ gql` nposts optional { - streak stacked spent referrals } + ...StreakFields } }` export const USER_FIELDS = gql` + ${STREAK_FIELDS} fragment UserFields on User { id name @@ -187,16 +190,17 @@ export const USER_FIELDS = gql` optional { stacked - streak maxStreak isContributor githubId nostrAuthPubkey twitterId } + ...StreakFields }` export const MY_SUBSCRIBED_USERS = gql` + ${STREAK_FIELDS} query MySubscribedUsers($cursor: String) { mySubscribedUsers(cursor: $cursor) { users { @@ -207,9 +211,7 @@ export const MY_SUBSCRIBED_USERS = gql` meSubscriptionComments meMute - optional { - streak - } + ...StreakFields } cursor } @@ -217,6 +219,7 @@ export const MY_SUBSCRIBED_USERS = gql` ` export const MY_MUTED_USERS = gql` + ${STREAK_FIELDS} query MyMutedUsers($cursor: String) { myMutedUsers(cursor: $cursor) { users { @@ -226,10 +229,7 @@ export const MY_MUTED_USERS = gql` meSubscriptionPosts meSubscriptionComments meMute - - optional { - streak - } + ...StreakFields } cursor } @@ -237,6 +237,7 @@ export const MY_MUTED_USERS = gql` ` export const TOP_USERS = gql` + ${STREAK_FIELDS} query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) { topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) { users { @@ -247,11 +248,11 @@ export const TOP_USERS = gql` nposts(when: $when, from: $from, to: $to) optional { - streak stacked(when: $when, from: $from, to: $to) spent(when: $when, from: $from, to: $to) referrals(when: $when, from: $from, to: $to) } + ...StreakFields } cursor } @@ -259,6 +260,7 @@ export const TOP_USERS = gql` ` export const TOP_COWBOYS = gql` + ${STREAK_FIELDS} query TopCowboys($cursor: String) { topCowboys(cursor: $cursor) { users { @@ -269,11 +271,11 @@ export const TOP_COWBOYS = gql` nposts(when: "forever") optional { - streak stacked(when: "forever") spent(when: "forever") referrals(when: "forever") } + ...StreakFields } cursor } diff --git a/lib/constants.js b/lib/constants.js index 76640fa2..ad327236 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -119,22 +119,54 @@ export const TERRITORY_PERIOD_COST = (billingType) => { } } -export const FOUND_BLURBS = [ - 'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.', - 'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.', - "This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.", - "A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.", - "A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.", - 'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.' -] -export const LOST_BLURBS = [ - 'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.', - "you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.", - 'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, tIme to start searching for another one.', - 'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.', - "your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.", - 'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.' -] +export const FOUND_BLURBS = { + COWBOY_HAT: [ + 'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.', + 'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.', + "This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.", + "A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.", + "A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.", + 'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.' + ], + GUN: [ + 'A gun is a tool, and like all tools, it can be used for good or evil. Use it wisely.', + 'In these wild lands, a gun can be your best friend or worst enemy. Handle it with care and respect.', + 'This firearm is more than just a weapon; it\'s your lifeline in the untamed West. Treat it well.', + 'A gun in the right hands can mean the difference between life and death. Make sure your aim is true.', + 'This gun is your ticket to survival in the frontier. Treat it with care and respect.' + ], + HORSE: [ + 'A loyal steed is worth its weight in gold. Treat this horse well, and it\'ll carry you through thick and thin.', + 'From dusty trails to raging rivers, this horse will be your constant companion. Treat it with respect.', + 'This horse has chosen you as much as you\'ve chosen it. Together, you\'ll forge a path through the frontier.', + 'Your new horse is both transportation and friend. In the loneliness of the prairie, you\'ll be glad for its company.', + 'Swift hooves and a sturdy back - this horse has the spirit of the West. Ride it with pride and care.' + ] +} +export const LOST_BLURBS = { + COWBOY_HAT: [ + 'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.', + "you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.", + 'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, time to start searching for another one.', + 'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.', + "your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.", + 'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.' + ], + GUN: [ + 'your gun slipped from its holster while crossing a treacherous ravine. It\'s lost to the depths, but a new one awaits in the next town.', + 'you were forced to toss your gun to distract a grizzly bear. It saved your life, but now you\'ll need to find a new firearm.', + 'your gun was confiscated by the local sheriff after a misunderstanding. Time to clear your name and find a new sidearm.', + 'your trusty six-shooter jammed beyond repair during a shootout. Luckily you survived, but now you need a replacement.', + 'you traded your gun for medicine to save a sick child. A noble deed, but the frontier is unforgiving - best find a new weapon soon.' + ], + HORSE: [ + 'your horse spooked at a rattlesnake and bolted into the night. You\'ll need to find a new steed to continue your journey.', + 'you lost your horse in a game of chance. The stakes were high, but now you\'re on foot until you can acquire a new mount.', + 'your horse was stolen by bandits while you slept. Time to track down a new companion for the long road ahead.', + 'your loyal steed fell ill and you had to leave it at a ranch to recover. You\'ll need a new horse to press on with your travels.', + 'your horse was requisitioned by the cavalry for an urgent mission. They left you with a voucher, but you\'ll need to find a new mount soon.' + ] +} export const ADMIN_ITEMS = [ // FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy diff --git a/lib/webPush.js b/lib/webPush.js index 3cb2b3c8..5d054aed 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -363,14 +363,14 @@ export async function notifyWithdrawal (userId, wdrwl) { } export async function notifyNewStreak (userId, streak) { - const index = streak.id % FOUND_BLURBS.length - const blurb = FOUND_BLURBS[index] + const index = streak.id % FOUND_BLURBS[streak.type].length + const blurb = FOUND_BLURBS[streak.type][index] try { await sendUserNotification(userId, { - title: 'you found a cowboy hat', + title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`, body: blurb, - tag: 'STREAK-FOUND' + tag: `STREAK-FOUND-${streak.type}` }) } catch (err) { console.error(err) @@ -378,14 +378,14 @@ export async function notifyNewStreak (userId, streak) { } export async function notifyStreakLost (userId, streak) { - const index = streak.id % LOST_BLURBS.length - const blurb = LOST_BLURBS[index] + const index = streak.id % LOST_BLURBS[streak.type].length + const blurb = LOST_BLURBS[streak.type][index] try { await sendUserNotification(userId, { - title: 'you lost your cowboy hat', + title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`, body: blurb, - tag: 'STREAK-LOST' + tag: `STREAK-LOST-${streak.type}` }) } catch (err) { console.error(err) diff --git a/pages/rewards/index.js b/pages/rewards/index.js index 5db87856..164710f4 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -53,6 +53,8 @@ ${ITEM_FULL_FIELDS} optional { streak + gunStreak + horseStreak stacked spent referrals diff --git a/pages/settings/index.js b/pages/settings/index.js index a5e72eb7..d0bcb10e 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -324,7 +324,7 @@ export default function Settings ({ ssrData }) { groupClassName='mb-0' />
privacy
diff --git a/prisma/migrations/20241009200148_wallet_streaks/migration.sql b/prisma/migrations/20241009200148_wallet_streaks/migration.sql new file mode 100644 index 00000000..4dc30058 --- /dev/null +++ b/prisma/migrations/20241009200148_wallet_streaks/migration.sql @@ -0,0 +1,27 @@ +-- CreateEnum +CREATE TYPE "StreakType" AS ENUM ('COWBOY_HAT', 'GUN', 'HORSE'); + +-- AlterTable +ALTER TABLE "Streak" ADD COLUMN "type" "StreakType" NOT NULL DEFAULT 'COWBOY_HAT'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "gunStreak" INTEGER, +ADD COLUMN "horseStreak" INTEGER; + +-- CreateIndex +CREATE INDEX "Streak_type_idx" ON "Streak"("type"); + +-- CreateIndex +CREATE INDEX "users_streak_idx" ON "users"("streak"); + +-- CreateIndex +CREATE INDEX "users_gunStreak_idx" ON "users"("gunStreak"); + +-- CreateIndex +CREATE INDEX "users_horseStreak_idx" ON "users"("horseStreak"); + +-- DropIndex +DROP INDEX "Streak.startedAt_userId_unique"; + +-- CreateIndex +CREATE UNIQUE INDEX "Streak_startedAt_userId_type_key" ON "Streak"("startedAt", "userId", "type"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1a9a3795..7a629e44 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,8 @@ model User { slashtagId String? @unique(map: "users.slashtagId_unique") noteCowboyHat Boolean @default(true) streak Int? + gunStreak Int? + horseStreak Int? subs String[] hideCowboyHat Boolean @default(false) Bookmarks Bookmark[] @@ -138,6 +140,9 @@ model User { @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@index([inviteId], map: "users.inviteId_index") + @@index([streak]) + @@index([gunStreak]) + @@index([horseStreak]) @@map("users") } @@ -300,17 +305,25 @@ model Arc { @@index([toId, fromId]) } -model Streak { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - startedAt DateTime @db.Date - endedAt DateTime? @db.Date - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +enum StreakType { + COWBOY_HAT + GUN + HORSE +} - @@unique([startedAt, userId], map: "Streak.startedAt_userId_unique") +model Streak { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + startedAt DateTime @db.Date + endedAt DateTime? @db.Date + userId Int + type StreakType @default(COWBOY_HAT) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([startedAt, userId, type]) @@index([userId], map: "Streak.userId_index") + @@index([type]) } model NostrRelay { diff --git a/scripts/newsletter.js b/scripts/newsletter.js index 1238c77f..9a249ac9 100644 --- a/scripts/newsletter.js +++ b/scripts/newsletter.js @@ -32,6 +32,8 @@ query TopCowboys($cursor: String) { name optional { streak + gunStreak + horseStreak } } cursor diff --git a/styles/globals.scss b/styles/globals.scss index 15f0b5ee..86f33c5c 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -220,6 +220,10 @@ $zindex-sticky: 900; scroll-margin-top: 60px; } +.ms-xs { + margin-left: 0.125rem; +} + .text-monospace { font-family: monospace; } diff --git a/svgs/holster.svg b/svgs/holster.svg new file mode 100644 index 00000000..ecbd697d --- /dev/null +++ b/svgs/holster.svg @@ -0,0 +1,3 @@ + + + diff --git a/svgs/horse.svg b/svgs/horse.svg new file mode 100644 index 00000000..fc04c67e --- /dev/null +++ b/svgs/horse.svg @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/svgs/revolver.svg b/svgs/revolver.svg new file mode 100644 index 00000000..d40f0ded --- /dev/null +++ b/svgs/revolver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/saddle.svg b/svgs/saddle.svg new file mode 100644 index 00000000..12009e09 --- /dev/null +++ b/svgs/saddle.svg @@ -0,0 +1,3 @@ + + + diff --git a/worker/paidAction.js b/worker/paidAction.js index ff6e5469..b2015c3b 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -144,9 +144,19 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln } await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd }) + + // any paid action is eligible for a cowboy hat streak await tx.$executeRaw` INSERT INTO pgboss.job (name, data) - VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}))` + VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))` + if (dbInvoice.invoiceForward) { + // only paid forwards are eligible for a gun streak + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES + ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'GUN')), + ('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))` + } return { confirmedAt: new Date(lndInvoice.confirmed_at), diff --git a/worker/streak.js b/worker/streak.js index 2b70f7c4..e38a3d87 100644 --- a/worker/streak.js +++ b/worker/streak.js @@ -1,124 +1,169 @@ import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' +import { Prisma } from '@prisma/client' -const STREAK_THRESHOLD = 100 +const COWBOY_HAT_STREAK_THRESHOLD = 100 +const GUN_STREAK_THRESHOLD = 1000 +const HORSE_STREAK_THRESHOLD = 1000 export async function computeStreaks ({ models }) { // get all eligible users in the last day // if the user doesn't have an active streak, add one // if they have an active streak but didn't maintain it, end it - const endingStreaks = await models.$queryRaw` - WITH day_streaks (id) AS ( - SELECT "userId" - FROM - ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent - FROM "ItemAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date - AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') - GROUP BY "userId") - UNION ALL - (SELECT "userId", sats as sats_spent - FROM "Donation" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date + for (const type of ['COWBOY_HAT', 'GUN', 'HORSE']) { + const endingStreaks = await models.$queryRaw` + WITH day_streaks (id) AS ( + ${getStreakQuery(type)} + ), existing_streaks (id, started_at) AS ( + SELECT "userId", "startedAt" + FROM "Streak" + WHERE "Streak"."endedAt" IS NULL + AND "type" = ${type}::"StreakType" + ), new_streaks (id) AS ( + SELECT day_streaks.id + FROM day_streaks + LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id + WHERE existing_streaks.id IS NULL + ), ending_streaks (id) AS ( + SELECT existing_streaks.id + FROM existing_streaks + LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id + WHERE day_streaks.id IS NULL + ), extending_streaks (id, started_at) AS ( + SELECT existing_streaks.id, existing_streaks.started_at + FROM existing_streaks + JOIN day_streaks ON existing_streaks.id = day_streaks.id + ), + -- a bunch of mutations + streak_insert AS ( + INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at) + SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc() + FROM new_streaks + ), user_update_new_streaks AS ( + UPDATE users SET ${getStreakColumn(type)} = 1 FROM new_streaks WHERE new_streaks.id = users.id + ), user_update_end_streaks AS ( + UPDATE users SET ${getStreakColumn(type)} = NULL FROM ending_streaks WHERE ending_streaks.id = users.id + ), user_update_extend_streaks AS ( + UPDATE users + SET ${getStreakColumn(type)} = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at + FROM extending_streaks WHERE extending_streaks.id = users.id ) - UNION ALL - (SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent - FROM "SubAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date - AND "type" = 'BILLING' - GROUP BY "userId")) spending - GROUP BY "userId" - HAVING sum(sats_spent) >= 100 - ), existing_streaks (id, started_at) AS ( - SELECT "userId", "startedAt" - FROM "Streak" - WHERE "Streak"."endedAt" IS NULL - ), new_streaks (id) AS ( - SELECT day_streaks.id - FROM day_streaks - LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id - WHERE existing_streaks.id IS NULL - ), ending_streaks (id) AS ( - SELECT existing_streaks.id - FROM existing_streaks - LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id - WHERE day_streaks.id IS NULL - ), extending_streaks (id, started_at) AS ( - SELECT existing_streaks.id, existing_streaks.started_at - FROM existing_streaks - JOIN day_streaks ON existing_streaks.id = day_streaks.id - ), - -- a bunch of mutations - streak_insert AS ( - INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at) - SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, now_utc(), now_utc() - FROM new_streaks - ), user_update_new_streaks AS ( - UPDATE users SET streak = 1 FROM new_streaks WHERE new_streaks.id = users.id - ), user_update_end_streaks AS ( - UPDATE users SET streak = NULL FROM ending_streaks WHERE ending_streaks.id = users.id - ), user_update_extend_streaks AS ( - UPDATE users - SET streak = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at - FROM extending_streaks WHERE extending_streaks.id = users.id - ) - UPDATE "Streak" - SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc() - FROM ending_streaks - WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL - RETURNING "Streak".id, ending_streaks."id" AS "userId"` + UPDATE "Streak" + SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc() + FROM ending_streaks + WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL AND "type" = ${type}::"StreakType" + RETURNING "Streak".*` - Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak))) + Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak))) + } } -export async function checkStreak ({ data: { id }, models }) { +export async function checkStreak ({ data: { id, type = 'COWBOY_HAT' }, models }) { // if user is actively streaking skip - let streak = await models.streak.findFirst({ + const user = await models.user.findUnique({ where: { - userId: Number(id), - endedAt: null + id: Number(id) } }) - if (streak) { + console.log('checking streak', id, type, isStreakActive(type, user)) + + if (isStreakActive(type, user)) { return } - [streak] = await models.$queryRaw` + const [streak] = await models.$queryRaw` WITH streak_started (id) AS ( - SELECT "userId" - FROM - ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent - FROM "ItemAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date - AND "userId" = ${Number(id)} - AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') - GROUP BY "userId") - UNION ALL - (SELECT "userId", sats as sats_spent - FROM "Donation" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date - AND "userId" = ${Number(id)} - ) - UNION ALL - (SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent - FROM "SubAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date - AND "userId" = ${Number(id)} - AND "type" = 'BILLING' - GROUP BY "userId") - ) spending - GROUP BY "userId" - HAVING sum(sats_spent) >= ${STREAK_THRESHOLD} + ${getStreakQuery(type, id)} ), user_start_streak AS ( - UPDATE users SET streak = 0 FROM streak_started WHERE streak_started.id = users.id + UPDATE users SET ${getStreakColumn(type)} = 0 FROM streak_started WHERE streak_started.id = users.id ) - INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at) - SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc() + INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at) + SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, ${type}::"StreakType", now_utc(), now_utc() FROM streak_started - RETURNING "Streak".id` + RETURNING "Streak".*` if (!streak) return // new streak started for user notifyNewStreak(id, streak) } + +function getStreakQuery (type, userId) { + const dayFragment = userId + ? Prisma.sql`(now() AT TIME ZONE 'America/Chicago')::date` + : Prisma.sql`(now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date` + + if (type === 'GUN') { + return Prisma.sql` + SELECT "Invoice"."userId" + FROM "Invoice" + JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId" + WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + AND "Invoice"."actionState" = 'PAID' + ${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty} + GROUP BY "Invoice"."userId" + HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}` + } + + if (type === 'HORSE') { + return Prisma.sql` + SELECT "Withdrawl"."userId" + FROM "Withdrawl" + JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" + JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id + WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + AND "Invoice"."actionState" = 'PAID' + ${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty} + GROUP BY "Withdrawl"."userId" + HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}` + } + + return Prisma.sql` + SELECT "userId" + FROM + ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent + FROM "ItemAct" + WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') + ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} + GROUP BY "userId") + UNION ALL + (SELECT "userId", sats as sats_spent + FROM "Donation" + WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} + ) + UNION ALL + (SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent + FROM "SubAct" + WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} + AND "type" = 'BILLING' + GROUP BY "userId")) spending + GROUP BY "userId" + HAVING sum(sats_spent) >= ${COWBOY_HAT_STREAK_THRESHOLD}` +} + +function isStreakActive (type, user) { + if (type === 'GUN') { + return typeof user.gunStreak === 'number' + } + + if (type === 'HORSE') { + return typeof user.horseStreak === 'number' + } + + return typeof user.streak === 'number' +} + +function getStreakColumn (type) { + if (type === 'GUN') { + return Prisma.sql`"gunStreak"` + } + + if (type === 'HORSE') { + return Prisma.sql`"horseStreak"` + } + + return Prisma.sql`"streak"` +} diff --git a/worker/thisDay.js b/worker/thisDay.js index cb4dcfd3..cbdabaf0 100644 --- a/worker/thisDay.js +++ b/worker/thisDay.js @@ -177,6 +177,8 @@ const THIS_DAY = gql` id optional { streak + gunStreak + horseStreak } } ncomments(when: "custom", from: $from, to: $to)