diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index d9405b99..5a113809 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -181,6 +181,15 @@ export default {
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
@@ -227,6 +236,17 @@ export default {
JobChanged: {
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: {
sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRaw(`
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 4f92d4b7..941a789b 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -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
},
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 }) => {
if (user.stacked) {
return user.stacked
diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js
index 9d4d5c12..68b90926 100644
--- a/api/typeDefs/notifications.js
+++ b/api/typeDefs/notifications.js
@@ -38,6 +38,12 @@ export default gql`
tips: Int!
}
+ type Streak {
+ sortTime: String!
+ days: Int
+ id: ID!
+ }
+
type Earn {
earnedSats: Int!
sortTime: String!
@@ -56,6 +62,7 @@ export default gql`
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | Referral
+ | Streak
type Notifications {
lastChecked: String
diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js
index e0604234..9cb4cea6 100644
--- a/api/typeDefs/user.js
+++ b/api/typeDefs/user.js
@@ -21,7 +21,7 @@ export default gql`
setName(name: String!): Boolean
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: 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
setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User!
@@ -58,6 +58,7 @@ export default gql`
bio: Item
bioId: Int
photoId: Int
+ streak: Int
sats: Int!
upvotePopover: Boolean!
tipPopover: Boolean!
@@ -68,6 +69,7 @@ export default gql`
noteDeposits: Boolean!
noteInvites: Boolean!
noteJobIndicator: Boolean!
+ noteCowboyHat: Boolean!
hideInvoiceDesc: Boolean!
hideFromTopUsers: Boolean!
wildWestMode: Boolean!
diff --git a/components/comment.js b/components/comment.js
index 550c35a6..222c6993 100644
--- a/components/comment.js
+++ b/components/comment.js
@@ -23,6 +23,7 @@ import { Badge } from 'react-bootstrap'
import { abbrNum } from '../lib/format'
import Share from './share'
import { DeleteDropdown } from './delete'
+import CowboyHat from './cowboy-hat'
function Parent ({ item, rootText }) {
const ParentFrag = () => (
@@ -135,7 +136,10 @@ export default function Comment ({
\
- @{item.user.name}{op && ' OP'}
+
+ @{item.user.name}
+ {op && OP}
+
diff --git a/components/cowboy-hat.js b/components/cowboy-hat.js
new file mode 100644
index 00000000..d5ee37a1
--- /dev/null
+++ b/components/cowboy-hat.js
@@ -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 (
+
+ {badge
+ ? (
+
+
+ {streak}
+ )
+ : }
+
+ )
+}
+
+function HatTooltip ({ children, overlayText, placement }) {
+ return (
+
+ {overlayText || '1 sat'}
+
+ }
+ trigger={['hover', 'focus']}
+ >
+ {children}
+
+ )
+}
diff --git a/components/header.js b/components/header.js
index ff965727..56a96be8 100644
--- a/components/header.js
+++ b/components/header.js
@@ -15,6 +15,7 @@ import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
+import CowboyHat from './cowboy-hat'
function WalletSummary ({ me }) {
if (!me) return null
@@ -72,7 +73,9 @@ export default function Header ({ sub }) {
- e.preventDefault()}>{`@${me?.name}`}
+ e.preventDefault()}>
+ {`@${me?.name}`}
+
} alignRight
>
diff --git a/components/item-job.js b/components/item-job.js
index 837f9743..4b8614a2 100644
--- a/components/item-job.js
+++ b/components/item-job.js
@@ -7,6 +7,7 @@ import Link from 'next/link'
import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg'
import Share from './share'
+import CowboyHat from './cowboy-hat'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
@@ -52,7 +53,9 @@ export default function ItemJob ({ item, toc, rank, children }) {
\
- @{item.user.name}
+
+ @{item.user.name}
+
diff --git a/components/item.js b/components/item.js
index ffc30b76..d7a99f9b 100644
--- a/components/item.js
+++ b/components/item.js
@@ -19,6 +19,7 @@ import Flag from '../svgs/flag-fill.svg'
import Share from './share'
import { abbrNum } from '../lib/format'
import { DeleteDropdown } from './delete'
+import CowboyHat from './cowboy-hat'
export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@@ -115,7 +116,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
\
- @{item.user.name}
+ @{item.user.name}
diff --git a/components/notifications.js b/components/notifications.js
index 0b8e1df0..d600d74d 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -12,6 +12,8 @@ import Link from 'next/link'
import Check from '../svgs/check-double-line.svg'
import HandCoin from '../svgs/hand-coin-fill.svg'
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 ...
function Notification ({ n }) {
@@ -20,7 +22,7 @@ function Notification ({ n }) {
{
- if (n.__typename === 'Earn' || n.__typename === 'Referral') {
+ if (n.__typename === 'Earn' || n.__typename === 'Referral' || n.__typename === 'Streak') {
return
}
@@ -103,35 +105,76 @@ function Notification ({ n }) {
{n.earnedSats} sats were deposited in your account
{timeSince(new Date(n.sortTime))}
)
- : (
- <>
- {n.__typename === 'Votification' &&
-
- your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
- }
- {n.__typename === 'Mention' &&
-
- you were mentioned in
- }
- {n.__typename === 'JobChanged' &&
-
- {n.item.status === 'ACTIVE'
- ? 'your job is active again'
- : (n.item.status === 'NOSATS'
- ? 'your job promotion ran out of sats'
- : 'your job has been stopped')}
- }
-
- {n.item.isJob
- ?
- : n.item.title
- ?
- : (
-
-
-
)}
-
- >)}
+ : n.__typename === 'Streak'
+ ?
+ : (
+ <>
+ {n.__typename === 'Votification' &&
+
+ your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
+ }
+ {n.__typename === 'Mention' &&
+
+ you were mentioned in
+ }
+ {n.__typename === 'JobChanged' &&
+
+ {n.item.status === 'ACTIVE'
+ ? 'your job is active again'
+ : (n.item.status === 'NOSATS'
+ ? 'your job promotion ran out of sats'
+ : 'your job has been stopped')}
+ }
+
+ {n.item.isJob
+ ?
+ : n.item.title
+ ?
+ : (
+
+
+
)}
+
+ >)}
+
+ )
+}
+
+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 (
+
+
{n.days ? : }
+
+ you {n.days ? 'lost your' : 'found a'} cowboy hat
+
{blurb(n)}
+
)
}
diff --git a/components/user-header.js b/components/user-header.js
index 8e2d6885..0d5eb728 100644
--- a/components/user-header.js
+++ b/components/user-header.js
@@ -14,6 +14,7 @@ import LightningIcon from '../svgs/bolt.svg'
import ModalButton from './modal-button'
import { encodeLNUrl } from '../lib/lnurl'
import Avatar from './avatar'
+import CowboyHat from './cowboy-hat'
export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false)
@@ -132,7 +133,7 @@ export default function UserHeader ({ user }) {
)
: (
-
@{user.name}
+
@{user.name}
{isMe &&
}
diff --git a/components/user-list.js b/components/user-list.js
index 39301ca3..971f4ceb 100644
--- a/components/user-list.js
+++ b/components/user-list.js
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { Image } from 'react-bootstrap'
import { abbrNum } from '../lib/format'
+import CowboyHat from './cowboy-hat'
import styles from './item.module.css'
import userStyles from './user-header.module.css'
@@ -18,8 +19,8 @@ export default function UserList ({ users }) {
+
+
\ No newline at end of file
diff --git a/svgs/cowboy.svg b/svgs/cowboy.svg
new file mode 100644
index 00000000..a4b2da86
--- /dev/null
+++ b/svgs/cowboy.svg
@@ -0,0 +1,21 @@
+
diff --git a/worker/index.js b/worker/index.js
index bed58ff2..73322379 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -10,6 +10,7 @@ const { earn } = require('./earn')
const { ApolloClient, HttpLink, InMemoryCache } = require('@apollo/client')
const { indexItem, indexAllItems } = require('./search')
const { timestampItem } = require('./ots')
+const { computeStreaks } = require('./streak')
const fetch = require('cross-fetch')
async function work () {
@@ -47,6 +48,7 @@ async function work () {
await boss.work('indexAllItems', indexAllItems(args))
await boss.work('auction', auction(args))
await boss.work('earn', earn(args))
+ await boss.work('streak', computeStreaks(args))
console.log('working jobs')
}
diff --git a/worker/streak.js b/worker/streak.js
new file mode 100644
index 00000000..9c4aebf4
--- /dev/null
+++ b/worker/streak.js
@@ -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 }