wallet streaks (#1468)

* wallet streaks backend

* notifications and badges

* reuseable streak fragment

* squash migrations

* push notifications

* update cowboy notification setting label text
This commit is contained in:
Keyan 2024-10-11 19:14:18 -05:00 committed by GitHub
parent 915fc87596
commit 245419185f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 573 additions and 323 deletions

View File

@ -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: {

View File

@ -79,6 +79,7 @@ export default gql`
id: ID!
sortTime: Date!
days: Int
type: String!
}
type Earn {

View File

@ -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

93
components/badge.js Normal file
View File

@ -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 (
<BadgeTooltip overlayText='anonymous'>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<AnonIcon className={`${badgeClassName} fill-dark align-middle`} height={height} width={width} />
</Badge>)
: <span><AnonIcon className={`${badgeClassName} align-middle`} height={height} width={width} /></span>}
</BadgeTooltip>
)
}
return (
<span className={className}>
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
<SNBadge
key={streakName}
user={user}
badge={badge}
streakName={streakName}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
height={height}
width={width}
sizeDelta={sizeDelta}
/>
))}
</span>
)
}
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
const streak = user.optional[streakName]
if (streak === null) {
return null
}
return (
<BadgeTooltip
overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</BadgeTooltip>
)
}
export function BadgeTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -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 (
<HatTooltip overlayText='anonymous'>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<AnonIcon className={`${className} fill-dark align-middle`} height={height} width={width} />
</Badge>)
: <span><AnonIcon className={`${className} align-middle`} height={height} width={width} /></span>}
</HatTooltip>
)
}
const streak = user.optional.streak
if (streak === null) {
return null
}
return (
<HatTooltip overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<CowboyHatIcon className={`${className} fill-dark`} height={height} width={width} />
<span className='ms-1 text-dark'>{streak || 'new'}</span>
</Badge>)
: <span><CowboyHatIcon className={className} height={height} width={width} /></span>}
</HatTooltip>
)
}
export function HatTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -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 ({
<span> \ </span>
<span>
{showUser &&
<UserPopover name={item.user.name}>
<Link href={`/${item.user.name}`}>
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
{embellishUser}
</Link>
</UserPopover>}
<Link href={`/${item.user.name}`}>
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
{embellishUser}
</Link>}
<span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))}

View File

@ -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 && <span>{abbrNum(item.boost)} boost \ </span>}
<span>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
@{item.user.name}<Badges badgeClassName='fill-grey' height={12} width={12} user={item.user} />
</Link>
<span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>

View File

@ -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 (
<div className='position-relative'>
<div className=''>
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<Nav.Link eventKey={me.name} as='span' className='p-0'>
{`@${me.name}`}<Hat user={me} />
</Nav.Link>
<div className='d-flex align-items-center'>
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
{`@${me.name}`}
{!me.bioId &&
<span className='d-inline-block p-1'>
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}>
<span className='invisible'>{' '}</span>
</span>
</span>}
</Nav.Link>
<Badges user={me} />
</div>
</Dropdown.Toggle>
<Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior>
@ -205,10 +214,6 @@ export function MeDropdown ({ me, dropNavKey }) {
<LogoutDropdownItem />
</Dropdown.Menu>
</Dropdown>
{!me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
)
}
@ -377,7 +382,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end' autoClose>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: USER_ID.anon }} />
@anon<Badges user={{ id: USER_ID.anon }} />
</Nav.Link>
</Dropdown.Toggle>
<Dropdown.Menu className='p-3'>

View File

@ -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 (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span>
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>

View File

@ -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 }) {
<div className='text-muted'>
<span>founded by </span>
<Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
@{sub.user.name}<Badges badgeClassName='fill-grey' height={12} width={12} user={sub.user} />
</Link>
<span> on </span>
<span className='fw-bold'>{new Date(sub.createdAt).toDateString()}</span>

View File

@ -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 (
<div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
<div className={styles.username}>@{user.name}<Badges className='ms-2' user={user} badgeClassName='fill-grey' /></div>
{isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
{!isMe && me && <NymActionDropdown user={user} />}

View File

@ -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 && <Hat className='ms-1 fill-grey' height={14} width={14} user={user} />}{selected && <CheckCircle className='ms-3 fill-primary' height={14} width={14} />}
@{user.name}{showHat && <Badges badgeClassName='fill-grey' height={14} width={14} user={user} />}{selected && <CheckCircle className='ms-3 fill-primary' height={14} width={14} />}
</Link>
{stats && (
<div className={styles.other}>
@ -81,7 +81,7 @@ export function UserBase ({ user, className, children, nymActionDropdown }) {
<div className={styles.hunk}>
<div className='d-flex'>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
@{user.name}<Badges badgeClassName='fill-grey' height={14} width={14} user={user} />
</Link>
{nymActionDropdown && <NymActionDropdown user={user} className='' />}
</div>

View File

@ -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
}
}
}`

View File

@ -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
}

View File

@ -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

View File

@ -86,6 +86,7 @@ export const NOTIFICATIONS = gql`
id
sortTime
days
type
}
... on Earn {
id

View File

@ -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
}
}`

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -53,6 +53,8 @@ ${ITEM_FULL_FIELDS}
optional {
streak
gunStreak
horseStreak
stacked
spent
referrals

View File

@ -324,7 +324,7 @@ export default function Settings ({ ssrData }) {
groupClassName='mb-0'
/>
<Checkbox
label='I find or lose a cowboy hat'
label='I find or lose cowboy essentials (e.g. cowboy hat)'
name='noteCowboyHat'
/>
<div className='form-label'>privacy</div>

View File

@ -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");

View File

@ -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 {

View File

@ -32,6 +32,8 @@ query TopCowboys($cursor: String) {
name
optional {
streak
gunStreak
horseStreak
}
}
cursor

View File

@ -220,6 +220,10 @@ $zindex-sticky: 900;
scroll-margin-top: 60px;
}
.ms-xs {
margin-left: 0.125rem;
}
.text-monospace {
font-family: monospace;
}

3
svgs/holster.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="800" height="800" viewBox="0 0 800 800" xmlns="http://www.w3.org/2000/svg">
<path d="M52.0625 28.125L52.0625 228.989C63.5 229.611 128 210.5 219.464 210.5C245.62 205.513 309.5 188.311 339 161.286C365 142.5 398.509 137.5 424.75 137.5C472.566 137.5 496.897 150.534 507.658 153.898L517.838 157.077L514.06 283.869C582.583 280.122 653.807 273.45 732.062 263.809C722.568 262.503 715.254 256.038 710.562 249.002C705.309 256.88 696.773 264.062 685.562 264.062C674.351 264.062 665.815 256.88 660.562 249.002C655.309 256.88 646.773 264.062 635.562 264.062C624.351 264.062 615.815 256.88 610.562 249.002C605.309 256.88 596.773 264.062 585.562 264.062C572.304 264.062 562.77 254.022 558.005 244.659C553.239 235.297 550.892 224.859 549.289 214.691C547.817 205.355 547.128 196.266 546.792 189.062H546.5V179.044C546.485 177.837 546.485 176.63 546.5 175.423V28.125H52.0625ZM574.625 39.0625V160.938H596.5V39.0625H574.625ZM624.625 39.0625V160.938H646.5V39.0625H624.625ZM674.625 39.0625V160.938H696.5V39.0625H674.625ZM724.625 39.0625V160.938H746.5V39.0625H724.625ZM410.556 160.709C388.928 161.025 367.119 167.263 344.492 185.866C300.964 221.647 259.966 228.53 219.464 229.611C180.081 230.663 125.078 236.216 75.5 249.002C75.5 249.002 78.5 261.673 84 278.5C90.3739 298 100.755 321.903 117.286 347.8C150.348 399.592 205.597 455.2 266.595 488.589L272.717 491.941L281.759 552.372C290.552 553.019 299.197 553.53 307.73 553.9L298.355 481.516C202.5 425.5 138.217 347.8 110.77 267.87C121.291 265.058 129.317 262.898 138.217 261.673C214.873 252.073 281.861 268.972 374.085 196.305C384.699 187.941 396.686 184.536 408.933 183.927C410.466 183.851 412 183.817 413.535 183.825C424.294 183.872 435.145 185.85 445.347 188.311C460.649 192.003 475.636 196.842 488.419 200.07L489.094 177.484C469.003 170.891 447.327 163.344 424.75 161.286C421.468 160.983 418.177 160.795 414.881 160.724C413.44 160.693 411.998 160.689 410.556 160.709ZM575.006 189.062C575.325 195.308 575.883 202.764 577.072 210.309C578.425 218.891 580.681 227.203 583.072 231.903C585.464 236.603 585.645 235.938 585.562 235.938C585.479 235.938 585.661 236.603 588.053 231.903C590.443 227.203 592.7 218.891 594.053 210.309C595.24 202.763 595.8 195.308 596.118 189.062H575.006ZM625.006 189.062C625.325 195.308 625.883 202.764 627.072 210.309C628.425 218.891 630.681 227.203 633.072 231.903C635.464 236.603 635.645 235.938 635.562 235.938C635.479 235.938 635.661 236.603 638.053 231.903C640.443 227.203 642.7 218.891 644.053 210.309C645.24 202.763 645.8 195.308 646.118 189.062H625.006ZM675.006 189.062C675.325 195.308 675.883 202.764 677.072 210.309C678.425 218.891 680.681 227.203 683.072 231.903C685.464 236.603 685.645 235.938 685.562 235.938C685.479 235.938 685.661 236.603 688.053 231.903C690.443 227.203 692.7 218.891 694.053 210.309C695.24 202.763 695.8 195.308 696.118 189.062H675.006ZM725.006 189.062C725.325 195.308 725.883 202.764 727.072 210.309C728.425 218.891 730.681 227.203 733.072 231.903C735.464 236.603 735.645 235.938 735.562 235.938C735.479 235.938 735.661 236.603 738.053 231.903C740.443 227.203 742.7 218.891 744.053 210.309C745.24 202.763 745.8 195.308 746.118 189.062H725.006ZM414.331 212.003C405.085 211.847 397.431 213.713 391.492 218.394C294.833 294.555 217.144 281.841 147.3 289.011C173.316 351.289 235.939 416.139 318.064 460.349L324.417 463.77L336.185 554.62C383.072 554.92 428.006 550.748 478.231 541.792L487.56 228.989C470.883 225.484 454.264 219.395 438.752 215.653C429.681 213.466 421.524 212.122 414.331 212.003ZM502.888 565.622C416.181 582.627 344.089 586.542 258.559 578.477L276.012 678.664C359.869 684.164 420.449 682.395 500.019 665.997L502.888 565.622ZM473.527 699.68C432.811 706.469 395.585 709.394 356.261 709.603L362.797 760.058C382.492 764.899 401.156 765.43 417.431 763.034C448.366 758.481 468.656 742.269 472.566 731.875L473.527 699.68ZM305.125 708.499L309.285 736.303C316.872 741.321 324.825 745.76 333.078 749.584L327.863 709.319C320.424 709.131 312.831 708.845 305.125 708.499Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

18
svgs/horse.svg Normal file
View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="223" height="223" viewBox="0 0 222.03 222.03">
<path d="M47.048,49.264c0,0-6.253,9.383-14.662,10.361c0,0-0.979,9.966-11.142,4.498c-2.71-1.46-3.721-7.625-0.981-11.535
l2.154-3.135c0,0,5.666-17.59,10.752-22.68c0,0,1.761-8.79,0-12.897c0,0,3.127,1.761,4.102,3.517c0,0-0.387-5.284-1.369-6.26
c0,0,9.582,5.875,10.17,9.2c0,0,19.354-5.677,35.189,9.582c0,0-6.059-1.563-8.207-0.78c0,0,19.349,10.95,22.872,23.847
c0,0-6.256-4.111-8.213-3.523c0,0,13.878,12.123,11.337,33.233c0,0,1.569,4.307,17.792,5.087c0,0,25.221,0.198,34.997,14.475
c0,0,16.226-1.173,28.744,14.074c0,0,11.143,19.357,24.441,16.805c0,0-13.099,6.064-19.357,0.597c0,0,2.352,10.361,36.362,17.401
c0,0-49.703,19.795-53.965-15.445c-0.662-5.58,1.566-23.265-10.361-19.943c0,0,5.479,18.383-20.528,35.784
c0,0-6.059,7.034,0.585,22.089c0,0,3.334,10.557-9.771,11.922c0,0-12.129,5.083-17.209,15.835c0,0-4.297,2.146-7.625,2.146
c0,0-2.344,8.6-6.842,7.234c0,0-12.51-1.17-7.82-6.265l1.761-2.731c0,0-6.059,1.561,0-6.448c0,0,2.932-3.718,14.08-3.522
c0,0,2.148-4.688,13.098-9.191c0,0,7.436-3.712-1.95-19.352c0,0-4.501-13.488-7.433-15.641c0,0,0.588-7.82-2.346-11.147
c0,0-19.943,11.526-51.817-16.811l-6.059,5.681c0,0-14.467-0.391-17.789-7.234c0,0-8.6,5.083-5.278,18.376
c0,0,0.78,4.498-0.195,6.649c0,0,6.646-0.78,5.866,8.021c0,0,0.975,10.745-3.91,8.009c0,0-4.303-2.347-4.69-8.795
c0,0-1.957-5.669-5.086-7.82c0,0-3.121-3.913,0.783-10.167c0,0,1.566-5.083,1.767-9.965c0,0,1.173-5.675,2.542-6.845
c0,0-13.687-2.353-14.079,24.624l2.349,0.786c0,0,6.839,12.117-2.154,16.615c0,0-3.13,1.377-4.492-3.522l-0.981-10.557
c0,0-4.69-8.802-1.174-16.231c0,0,5.078-6.053,6.647-16.225c0,0,0.393-8.012,13.884-5.875c0,0,10.749,3.523,19.157,0.198
c0,0-0.612-15.899,5.677-24.054C51.555,74.976,58.391,55.124,47.048,49.264z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

1
svgs/revolver.svg Normal file
View File

@ -0,0 +1 @@
<svg width="512" height="512" viewBox="0 75 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M481.14 125.357c-18.78 5.476-34.912 14.487-46.952 32.973h46.953v-32.973zm-188.915 50.01l-13.125.002-.116 35.74H491.47l-.343-35.74H292.225v-.003zm-29.125.002l-33.07.003-97.298.008c-16.018 27.973-16.89 57.78 1.04 94.07H262.8l.063-20.22H168.09a8 8 0 1 1 0-16h94.8v-22.68h-95.15a8 8 0 1 1 0-16h95.3l.06-19.18zm-161.377.01c-7.834 28.723-12.348 45.61-18.73 58.69-6.78 13.893-15.75 23.88-32.3 41.7C11.077 351.204 17.48 389.416 20.46 432.083c12.07 14.128 29.67 21.282 48.724 23.54 17.703 2.097 36.135-.286 50.816-4.597-.272-47.016 8.213-93.296 40.84-139.84l5.264-7.507 6.724 6.23c18.24 16.9 40.922 21.272 63.205 17.717 22.283-3.555 43.756-15.464 57.254-30.285 9.92-10.894 12.492-23.074 11.66-37.932h-26.115l-.084 26.04h-.695c-9.56 10.992-33.904 24.083-47.803 24.146-13.556.06-35.84-13.197-47.896-24.145H123.88l-2.253-4.266c-20.284-38.435-21.828-74.208-7.06-105.803h-12.844zm-74.88 2.47c7.33 23.547 19.127 43.547 34.825 60.796 2.733-3.822 4.952-7.508 6.945-11.593 2.33-4.772 4.44-10.37 6.715-17.44-.225-.142-.403-.248-.635-.394-7.68-4.854-17.46-11.227-27.117-17.58-10.508-6.916-13.477-8.943-20.734-13.79zm252.09 49.26l-.042 13.66v2.638h82.72V227.11h-82.676zM88.642 293.9c16.474 0 30 13.525 30 29.998 0 16.474-13.526 30-30 30-16.473 0-30-13.526-30-30 0-16.473 13.527-29.998 30-29.998zm0 15.998c-7.826 0-14 6.174-14 14 0 7.827 6.174 14 14 14 7.827 0 14-6.173 14-14 0-7.826-6.173-14-14-14zm-18.025 67.676a13 13 0 0 1 12.625 12.998 13 13 0 1 1-26 0 13 13 0 0 1 13.375-12.998z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

3
svgs/saddle.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -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),

View File

@ -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"`
}

View File

@ -177,6 +177,8 @@ const THIS_DAY = gql`
id
optional {
streak
gunStreak
horseStreak
}
}
ncomments(when: "custom", from: $from, to: $to)