This commit is contained in:
keyan 2023-02-01 08:44:35 -06:00
parent a6ce93c2bb
commit 072e60c954
25 changed files with 322 additions and 40 deletions

View File

@ -181,6 +181,15 @@ export default {
GROUP BY "userId", created_at` GROUP BY "userId", created_at`
) )
} }
if (meFull.noteCowboyHat) {
queries.push(
`SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at <= $2`
)
}
} }
// we do all this crazy subquery stuff to make 'reward' islands // we do all this crazy subquery stuff to make 'reward' islands
@ -227,6 +236,17 @@ export default {
JobChanged: { JobChanged: {
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
}, },
Streak: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "endedAt" - "startedAt" AS days
FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
`
return res.length ? res[0].days : null
}
},
Earn: { Earn: {
sources: async (n, args, { me, models }) => { sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRaw(` const [sources] = await models.$queryRaw(`

View File

@ -304,6 +304,21 @@ export default {
} }
} }
if (user.noteCowboyHat) {
const streak = await models.streak.findFirst({
where: {
userId: me.id,
updatedAt: {
gt: lastChecked
}
}
})
if (streak) {
return true
}
}
return false return false
}, },
searchUsers: async (parent, { q, limit, similarity }, { models }) => { searchUsers: async (parent, { q, limit, similarity }, { models }) => {
@ -475,6 +490,15 @@ export default {
} }
}) })
}, },
streak: async (user, args, { models }) => {
const res = await models.$queryRaw`
SELECT (now_utc() at time zone 'America/Chicago')::date - "startedAt" AS days
FROM "Streak"
WHERE "userId" = ${user.id} AND "endedAt" IS NULL
`
return res.length ? res[0].days : null
},
stacked: async (user, { when }, { models }) => { stacked: async (user, { when }, { models }) => {
if (user.stacked) { if (user.stacked) {
return user.stacked return user.stacked

View File

@ -38,6 +38,12 @@ export default gql`
tips: Int! tips: Int!
} }
type Streak {
sortTime: String!
days: Int
id: ID!
}
type Earn { type Earn {
earnedSats: Int! earnedSats: Int!
sortTime: String! sortTime: String!
@ -56,6 +62,7 @@ export default gql`
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | Referral
| Streak
type Notifications { type Notifications {
lastChecked: String lastChecked: String

View File

@ -21,7 +21,7 @@ export default gql`
setName(name: String!): Boolean setName(name: String!): Boolean
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!, setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!, noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!,
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User
setPhoto(photoId: ID!): Int! setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User! upsertBio(bio: String!): User!
@ -58,6 +58,7 @@ export default gql`
bio: Item bio: Item
bioId: Int bioId: Int
photoId: Int photoId: Int
streak: Int
sats: Int! sats: Int!
upvotePopover: Boolean! upvotePopover: Boolean!
tipPopover: Boolean! tipPopover: Boolean!
@ -68,6 +69,7 @@ export default gql`
noteDeposits: Boolean! noteDeposits: Boolean!
noteInvites: Boolean! noteInvites: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteCowboyHat: Boolean!
hideInvoiceDesc: Boolean! hideInvoiceDesc: Boolean!
hideFromTopUsers: Boolean! hideFromTopUsers: Boolean!
wildWestMode: Boolean! wildWestMode: Boolean!

View File

@ -23,6 +23,7 @@ import { Badge } from 'react-bootstrap'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import Share from './share' import Share from './share'
import { DeleteDropdown } from './delete' import { DeleteDropdown } from './delete'
import CowboyHat from './cowboy-hat'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const ParentFrag = () => ( const ParentFrag = () => (
@ -135,7 +136,10 @@ export default function Comment ({
</Link> </Link>
<span> \ </span> <span> \ </span>
<Link href={`/${item.user.name}`} passHref> <Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}<span className='text-boost font-weight-bold'>{op && ' OP'}</span></a> <a className='d-inline-flex align-items-center'>
@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} />
{op && <span className='text-boost font-weight-bold ml-1'>OP</span>}
</a>
</Link> </Link>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>

36
components/cowboy-hat.js Normal file
View File

@ -0,0 +1,36 @@
import { Badge, OverlayTrigger, Tooltip } from 'react-bootstrap'
import CowboyHatIcon from '../svgs/cowboy.svg'
export default function CowboyHat ({ streak, badge, className = 'ml-1', height = 16, width = 16 }) {
if (!streak) {
return null
}
return (
<HatTooltip overlayText={`${streak} days`}>
{badge
? (
<Badge variant='grey-medium' className='ml-2 d-inline-flex align-items-center'>
<CowboyHatIcon className={className} height={height} width={width} />
<span className='ml-1'>{streak}</span>
</Badge>)
: <CowboyHatIcon className={className} height={height} width={width} />}
</HatTooltip>
)
}
function HatTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText || '1 sat'}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -15,6 +15,7 @@ import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg' import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg' import LightningIcon from '../svgs/bolt.svg'
import CowboyHat from './cowboy-hat'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -72,7 +73,9 @@ export default function Header ({ sub }) {
<NavDropdown <NavDropdown
className={styles.dropdown} title={ className={styles.dropdown} title={
<Link href={`/${me?.name}`} passHref> <Link href={`/${me?.name}`} passHref>
<Nav.Link eventKey={me?.name} as='div' className='p-0' onClick={e => e.preventDefault()}>{`@${me?.name}`}</Nav.Link> <Nav.Link eventKey={me?.name} as='div' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
{`@${me?.name}`}<CowboyHat streak={me.streak} />
</Nav.Link>
</Link> </Link>
} alignRight } alignRight
> >

View File

@ -7,6 +7,7 @@ import Link from 'next/link'
import { timeSince } from '../lib/time' import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg' import EmailIcon from '../svgs/mail-open-line.svg'
import Share from './share' import Share from './share'
import CowboyHat from './cowboy-hat'
export default function ItemJob ({ item, toc, rank, children }) { export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url) const isEmail = Yup.string().email().isValidSync(item.url)
@ -52,7 +53,9 @@ export default function ItemJob ({ item, toc, rank, children }) {
<span> \ </span> <span> \ </span>
<span> <span>
<Link href={`/${item.user.name}`} passHref> <Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a> <a className='d-inline-flex align-items-center'>
@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} />
</a>
</Link> </Link>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>

View File

@ -19,6 +19,7 @@ import Flag from '../svgs/flag-fill.svg'
import Share from './share' import Share from './share'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import { DeleteDropdown } from './delete' import { DeleteDropdown } from './delete'
import CowboyHat from './cowboy-hat'
export function SearchTitle ({ title }) { export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@ -115,7 +116,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
<span> \ </span> <span> \ </span>
<span> <span>
<Link href={`/${item.user.name}`} passHref> <Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a> <a className='d-inline-flex align-items-center'>@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} /></a>
</Link> </Link>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>

View File

@ -12,6 +12,8 @@ import Link from 'next/link'
import Check from '../svgs/check-double-line.svg' import Check from '../svgs/check-double-line.svg'
import HandCoin from '../svgs/hand-coin-fill.svg' import HandCoin from '../svgs/hand-coin-fill.svg'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg'
// TODO: oh man, this is a mess ... each notification type should just be a component ... // TODO: oh man, this is a mess ... each notification type should just be a component ...
function Notification ({ n }) { function Notification ({ n }) {
@ -20,7 +22,7 @@ function Notification ({ n }) {
<div <div
className='clickToContext' className='clickToContext'
onClick={e => { onClick={e => {
if (n.__typename === 'Earn' || n.__typename === 'Referral') { if (n.__typename === 'Earn' || n.__typename === 'Referral' || n.__typename === 'Streak') {
return return
} }
@ -103,35 +105,76 @@ function Notification ({ n }) {
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account <Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>) </div>)
: ( : n.__typename === 'Streak'
<> ? <Streak n={n} />
{n.__typename === 'Votification' && : (
<small className='font-weight-bold text-success ml-2'> <>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} {n.__typename === 'Votification' &&
</small>} <small className='font-weight-bold text-success ml-2'>
{n.__typename === 'Mention' && your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
<small className='font-weight-bold text-info ml-2'> </small>}
you were mentioned in {n.__typename === 'Mention' &&
</small>} <small className='font-weight-bold text-info ml-2'>
{n.__typename === 'JobChanged' && you were mentioned in
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}> </small>}
{n.item.status === 'ACTIVE' {n.__typename === 'JobChanged' &&
? 'your job is active again' <small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
: (n.item.status === 'NOSATS' {n.item.status === 'ACTIVE'
? 'your job promotion ran out of sats' ? 'your job is active again'
: 'your job has been stopped')} : (n.item.status === 'NOSATS'
</small>} ? 'your job promotion ran out of sats'
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}> : 'your job has been stopped')}
{n.item.isJob </small>}
? <ItemJob item={n.item} /> <div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
: n.item.title {n.item.isJob
? <Item item={n.item} /> ? <ItemJob item={n.item} />
: ( : n.item.title
<div className='pb-2'> ? <Item item={n.item} />
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext /> : (
</div>)} <div className='pb-2'>
</div> <Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
</>)} </div>)}
</div>
</>)}
</div>
)
}
function Streak ({ n }) {
function blurb (n) {
const index = Number(n.id) % 6
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.'
]
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.'
]
if (n.days) {
return `After ${n.days} days, ` + LOST_BLURBS[index]
}
return FOUND_BLURBS[index]
}
return (
<div className='d-flex font-weight-bold ml-2 py-1'>
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
<div className='ml-1 p-1'>
you {n.days ? 'lost your' : 'found a'} cowboy hat
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div> </div>
) )
} }

View File

@ -14,6 +14,7 @@ import LightningIcon from '../svgs/bolt.svg'
import ModalButton from './modal-button' import ModalButton from './modal-button'
import { encodeLNUrl } from '../lib/lnurl' import { encodeLNUrl } from '../lib/lnurl'
import Avatar from './avatar' import Avatar from './avatar'
import CowboyHat from './cowboy-hat'
export default function UserHeader ({ user }) { export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false) const [editting, setEditting] = useState(false)
@ -132,7 +133,7 @@ export default function UserHeader ({ user }) {
) )
: ( : (
<div className='d-flex align-items-center mb-2'> <div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}</div> <div className={styles.username}>@{user.name}<CowboyHat className='' streak={user.streak} badge /></div>
{isMe && {isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>} <Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
</div> </div>

View File

@ -1,6 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import { Image } from 'react-bootstrap' import { Image } from 'react-bootstrap'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import CowboyHat from './cowboy-hat'
import styles from './item.module.css' import styles from './item.module.css'
import userStyles from './user-header.module.css' import userStyles from './user-header.module.css'
@ -18,8 +19,8 @@ export default function UserList ({ users }) {
</Link> </Link>
<div className={styles.hunk}> <div className={styles.hunk}>
<Link href={`/${user.name}`} passHref> <Link href={`/${user.name}`} passHref>
<a className={`${styles.title} text-reset`}> <a className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name} @{user.name}<CowboyHat className='ml-1 fill-grey' height={14} width={14} streak={user.streak} />
</a> </a>
</Link> </Link>
<div className={styles.other}> <div className={styles.other}>

View File

@ -9,6 +9,7 @@ export const COMMENT_FIELDS = gql`
text text
user { user {
name name
streak
id id
} }
sats sats
@ -29,6 +30,7 @@ export const COMMENT_FIELDS = gql`
bountyPaidTo bountyPaidTo
user { user {
name name
streak
id id
} }
} }

View File

@ -13,6 +13,7 @@ export const INVITE_FIELDS = gql`
revoked revoked
user { user {
name name
streak
id id
} }
poor poor

View File

@ -11,10 +11,12 @@ export const ITEM_FIELDS = gql`
url url
user { user {
name name
streak
id id
} }
fwdUser { fwdUser {
name name
streak
id id
} }
sats sats
@ -51,6 +53,7 @@ export const ITEM_FIELDS = gql`
} }
user { user {
name name
streak
id id
} }
} }

View File

@ -28,6 +28,11 @@ export const NOTIFICATIONS = gql`
text text
} }
} }
... on Streak {
id
sortTime
days
}
... on Earn { ... on Earn {
sortTime sortTime
earnedSats earnedSats

View File

@ -7,6 +7,7 @@ export const ME = gql`
me { me {
id id
name name
streak
sats sats
stacked stacked
freePosts freePosts
@ -24,6 +25,7 @@ export const ME = gql`
noteDeposits noteDeposits
noteInvites noteInvites
noteJobIndicator noteJobIndicator
noteCowboyHat
hideInvoiceDesc hideInvoiceDesc
hideFromTopUsers hideFromTopUsers
wildWestMode wildWestMode
@ -44,6 +46,7 @@ export const SETTINGS_FIELDS = gql`
noteDeposits noteDeposits
noteInvites noteInvites
noteJobIndicator noteJobIndicator
noteCowboyHat
hideInvoiceDesc hideInvoiceDesc
hideFromTopUsers hideFromTopUsers
nostrPubkey nostrPubkey
@ -72,12 +75,12 @@ gql`
${SETTINGS_FIELDS} ${SETTINGS_FIELDS}
mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!, mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!,
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!, $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) { $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) {
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency, setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers, noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers,
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) { wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) {
...SettingsFields ...SettingsFields
} }
@ -103,6 +106,7 @@ gql`
query searchUsers($q: String!, $limit: Int, $similarity: Float) { query searchUsers($q: String!, $limit: Int, $similarity: Float) {
searchUsers(q: $q, limit: $limit, similarity: $similarity) { searchUsers(q: $q, limit: $limit, similarity: $similarity) {
name name
streak
photoId photoId
stacked stacked
spent spent
@ -117,6 +121,7 @@ export const USER_FIELDS = gql`
id id
createdAt createdAt
name name
streak
nitems nitems
ncomments ncomments
stacked stacked
@ -133,6 +138,7 @@ export const TOP_USERS = gql`
topUsers(cursor: $cursor, when: $when, sort: $sort) { topUsers(cursor: $cursor, when: $when, sort: $sort) {
users { users {
name name
streak
photoId photoId
stacked(when: $when) stacked(when: $when)
spent(when: $when) spent(when: $when)

View File

@ -102,6 +102,7 @@ export default function Settings ({ data: { settings } }) {
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator, noteJobIndicator: settings?.noteJobIndicator,
noteCowboyHat: settings?.noteCowboyHat,
hideInvoiceDesc: settings?.hideInvoiceDesc, hideInvoiceDesc: settings?.hideInvoiceDesc,
hideFromTopUsers: settings?.hideFromTopUsers, hideFromTopUsers: settings?.hideFromTopUsers,
wildWestMode: settings?.wildWestMode, wildWestMode: settings?.wildWestMode,
@ -216,6 +217,11 @@ export default function Settings ({ data: { settings } }) {
<Checkbox <Checkbox
label='there is a new job' label='there is a new job'
name='noteJobIndicator' name='noteJobIndicator'
groupClassName='mb-0'
/>
<Checkbox
label='I find or lose a cowboy hat'
name='noteCowboyHat'
/> />
<div className='form-label'>privacy</div> <div className='form-label'>privacy</div>
<Checkbox <Checkbox

View File

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Streak" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"startedAt" DATE NOT NULL,
"endedAt" DATE,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Streak.userId_index" ON "Streak"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Streak.startedAt_userId_unique" ON "Streak"("startedAt", "userId");
-- AddForeignKey
ALTER TABLE "Streak" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "noteCowboyHat" BOOLEAN NOT NULL DEFAULT true;

View File

@ -70,6 +70,7 @@ model User {
noteDeposits Boolean @default(true) noteDeposits Boolean @default(true)
noteInvites Boolean @default(true) noteInvites Boolean @default(true)
noteJobIndicator Boolean @default(true) noteJobIndicator Boolean @default(true)
noteCowboyHat Boolean @default(true)
// privacy settings // privacy settings
hideInvoiceDesc Boolean @default(false) hideInvoiceDesc Boolean @default(false)
@ -84,12 +85,26 @@ model User {
PollVote PollVote[] PollVote PollVote[]
Donation Donation[] Donation Donation[]
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[]
@@index([createdAt]) @@index([createdAt])
@@index([inviteId]) @@index([inviteId])
@@map(name: "users") @@map(name: "users")
} }
model Streak {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
startedAt DateTime @db.Date
endedAt DateTime? @db.Date
userId Int
user User @relation(fields: [userId], references: [id])
@@unique([startedAt, userId])
@@index([userId])
}
model NostrRelay { model NostrRelay {
addr String @id addr String @id
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")

3
svgs/bald.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288.69 325.52">
<path class="cls-1" d="m275.55,123.09c-2.36-8.21-11.36-2.84-11.36-2.84,0,0-.16-.79-1.42-15.31-1.26-14.52-5.21-41.81-34.71-68.16-29.51-26.36-77.51-25.09-77.51-25.09h-12.28c-12.27-.02-38.34,4.73-59.96,16.56-21.61,11.84-33.29,34.56-39.6,51.13-6.31,16.57-8.52,47.18-8.52,47.18,0,0-6.31-3.32-8.84-2.05-2.52,1.26-6.94,3.15-2.52,21.46,4.42,18.3,15.15,30.92,18.14,36.76,3,5.84,8.52,4.42,9.63,3.47,1.1-.94,1.58-3.78,1.58-3.78,0,0,1.1,29.35,2.21,36.29,1.1,6.94,5.2,19.56,8.67,24.14,3.48,4.58,6.79,8.21,6.79,8.21l6.31-.32s-.32,3.47,0,6.15c.32,2.69,2.05,5.84,4.1,7.74,2.05,1.89,23.36,26.03,30.61,30.92,7.26,4.89,13.73,10.57,44,10.57s36.95-8.2,40.74-11.2,20.04-18.46,29.19-29.03c9.15-10.57,21.93-26.98,24.93-35.66s5.37-45.45,5.37-45.45c0,0,.63,1.27,2.68,1.11,2.05-.16,5.84-3.47,7.73-7.58,1.89-4.1,2.68-4.1,8.68-20.19,5.99-16.1,7.73-26.83,5.36-35.03Zm-200.2-.65c-1.08.06-1.56-.72-1.8-1.32-.24-.59.08-2.79.77-3.71s4.37-6.44,15.99-12.77c11.62-6.33,20.02-4.83,20.02-4.83,0,0,2.8.45,5.31,1.11,2.51.65,4.31,2.99,4.73,3.76.42.78.59,1.62,0,2.75-.6,1.14-1.92,1.26-8.37,1.44-6.46.18-11.9,1.49-17.7,3.65-5.8,2.15-10.58,5.91-13.69,7.95-3.11,2.03-4.19,1.91-5.26,1.97Zm27.73,47.96c-8.57,0-15.52-7.91-15.52-17.66s6.95-17.66,15.52-17.66,15.52,7.91,15.52,17.66-6.95,17.66-15.52,17.66Zm88.88,74.66c-3.22,2.29-7.61,2.6-18.21,0-10.59-2.6-33.26-1.24-36.17-1.05-2.91.18-9.47,1.18-12.07,1.79-2.6.62-4.96,1.51-8.92,1.1-3.97-.41-6.57-3.82-6.94-7.17-.37-3.34.62-9.84,5.58-17.96,4.95-8.11,13.76-18.58,37.32-18.58,0,0,11.05-1.11,22.5,4.71,11.46,5.82,18.71,13.81,20.38,20.69,1.67,6.87-.25,14.18-3.47,16.47Zm7.17-74.66c-8.57,0-15.52-7.91-15.52-17.66s6.95-17.66,15.52-17.66,15.52,7.91,15.52,17.66-6.95,17.66-15.52,17.66Zm28.96-49.28c-.24.6-.72,1.38-1.8,1.32-1.07-.06-2.15.06-5.26-1.97-3.11-2.04-7.89-5.8-13.69-7.95-5.8-2.16-11.24-3.47-17.7-3.65-6.45-.18-7.77-.3-8.37-1.44-.59-1.13-.41-1.97,0-2.75.42-.77,2.22-3.11,4.73-3.76,2.51-.66,5.31-1.11,5.31-1.11,0,0,8.4-1.5,20.02,4.83,11.62,6.33,15.3,11.85,15.99,12.77s1.01,3.12.77,3.71Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

21
svgs/cowboy.svg Normal file
View File

@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="80 200 450 400">
<path class="st0" d="M499.9,315.6c-17.2-10.5-28.7,0.6-36.6,7.9c-7.9,7.2-26.6,30.8-36.6,38.1c-10,7.2-27.5,13.6-53.9,16.7
c-26.3,3.1-50.5,1.4-47.8,1c2.7-0.4,10.6-2.2,12.4-3c1.8-0.9,11.3-4.3,33.7-11.9c22.4-7.6,36.5-17,39.4-18.7
c2.9-1.7,5.9-3.9,5.4-5.2s-2.2-8.1-5.1-15.8c-2.9-7.7-8.5-29.4-10.1-34.2c-1.6-4.8-3-13.1-5.9-19.5s-4.4-21.3-11.3-38.5
c-6.9-17.2-11.8-19.5-31.7-27.7c-19.8-8.2-57.8-6.9-57.8-6.9s-41.1,1.3-59.5,9.2c-18.4,7.9-22.8,19-23.4,20.6
c-0.6,1.6-12.9,44.2-12.9,44.2l-27.4,93.1c0,0-6-5.6-10.7-10.8c-4.5-5-29.9-36.9-44-41.1c-14.1-4.2-20.4,0.8-26.2,5.5
c-5.8,4.7-11.1,18.3-8.5,32.7c2.6,14.4,29.8,45.3,51,59.7c21.2,14.4,40.8,21.5,43.6,23.1c2.8,1.6,6.5,2.8,16,15.2
s32.5,32.3,45.8,41.6c13.4,9.4,34.8,22.1,44.6,26.8c9.8,4.7,14.9,3.6,14.9,3.6c8.8,0,36.1-15.4,50.6-25.1
c14.4-9.7,32.3-25.1,34.1-26.6c1.6-1.3,3.2-3.4,3.7-3.9c0.7-0.8,6-8.1,15.3-17s14.6-12.4,14.6-12.4l0.2-1.8c0,0,27-9.2,53.4-30.3
c26.5-21.1,39.1-43.5,41.4-48.5C512.8,350.8,517.1,326,499.9,315.6L499.9,315.6z M366.5,413.2c-17,7.4-32,14.7-53.7,17.6
c-21.7,2.9-39.3,0.3-57.4-5.8c-18.1-6.1-38.3-16.6-49.2-23.6c-10.9-7-22.7-16.6-22.7-16.6s74.7,8.7,110.6,8.4
c36-0.3,71.5-2.9,83.3-4.2c11.8-1.3,32.8-4.2,32.8-4.2S383.5,405.7,366.5,413.2L366.5,413.2z"/>
<path class="st0" d="M415.7,449.1c0,0.1-0.2,1.2-6,4.5c-6.3,3.6-21.6,22.6-25.8,27.2c-4.2,4.7-27.5,22.3-27.5,22.3
S310.7,535,295.9,535s-43.1-21.3-58.4-31.3c-15.3-10-26.9-21.4-31.2-25.9c-4.2-4.5-13.8-15-20.5-21.7c-6.7-6.7-8.7-7-8.7-7
s-0.4,2.3,0,5.1c0.4,2.8,3.5,16.6,5.1,20.1s4.4,8.3,4.4,8.3s13.1,49.1,15.1,53.5s11.9,16.9,11.9,16.9l6,0.2l0.7,9
c0,0,0.7,0.9,2.5,2.5c1.8,1.6,18.6,20.4,22.3,23.6c3.6,3.2,15.6,15.6,28.3,17.8c12.7,2.2,23.3,2,40.8,0.9
c17.5-1.2,36.7-18.8,54.6-39.9c17.9-21.1,24.3-31.8,26.4-40.5s3.1-40.5,3.2-41.4c0.1-0.9,1.9,1.3,4.7,1.5c2.8,0.1,6.9-7.9,10.4-19.4
C416.6,456,415.8,449.5,415.7,449.1L415.7,449.1z M318.4,556.4c-6.8,7-16.8,12-20.7,12s-11.4-2.5-19.9-10.2
c-8.5-7.7-11.9-13-11.9-13s15.9,1.8,31,1.8s31-1.8,31-1.8S325.2,549.4,318.4,556.4L318.4,556.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -10,6 +10,7 @@ const { earn } = require('./earn')
const { ApolloClient, HttpLink, InMemoryCache } = require('@apollo/client') const { ApolloClient, HttpLink, InMemoryCache } = require('@apollo/client')
const { indexItem, indexAllItems } = require('./search') const { indexItem, indexAllItems } = require('./search')
const { timestampItem } = require('./ots') const { timestampItem } = require('./ots')
const { computeStreaks } = require('./streak')
const fetch = require('cross-fetch') const fetch = require('cross-fetch')
async function work () { async function work () {
@ -47,6 +48,7 @@ async function work () {
await boss.work('indexAllItems', indexAllItems(args)) await boss.work('indexAllItems', indexAllItems(args))
await boss.work('auction', auction(args)) await boss.work('auction', auction(args))
await boss.work('earn', earn(args)) await boss.work('earn', earn(args))
await boss.work('streak', computeStreaks(args))
console.log('working jobs') console.log('working jobs')
} }

51
worker/streak.js Normal file
View File

@ -0,0 +1,51 @@
function computeStreaks ({ models }) {
return async function () {
console.log('computing streaks')
// 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
await models.$executeRaw(
`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
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
)) spending
GROUP BY "userId"
HAVING sum(sats_spent) >= 100
), existing_streaks (id) AS (
SELECT "userId"
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
), 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
)
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"`)
console.log('done computing streaks')
}
}
module.exports = { computeStreaks }