From 084df2b59d44c07187132083d84e002c45ff3304 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Feb 2025 13:51:21 -0600 Subject: [PATCH 1/4] Streaks API --- src/db/DittoTables.ts | 2 ++ src/db/migrations/045_streaks.ts | 17 +++++++++++++++ src/entities/MastodonAccount.ts | 5 +++++ src/interfaces/DittoEvent.ts | 2 ++ src/storages/hydrate.ts | 26 ++++++++++++++++------ src/utils/stats.ts | 37 ++++++++++++++++++++++++++++---- src/views/mastodon/accounts.ts | 14 ++++++++++++ 7 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 src/db/migrations/045_streaks.ts diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 6ffed988..7baaa42c 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -17,6 +17,8 @@ interface AuthorStatsRow { following_count: number; notes_count: number; search: string; + streak_start: number | null; + streak_end: number | null; } interface EventStatsRow { diff --git a/src/db/migrations/045_streaks.ts b/src/db/migrations/045_streaks.ts new file mode 100644 index 00000000..553ef96a --- /dev/null +++ b/src/db/migrations/045_streaks.ts @@ -0,0 +1,17 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('author_stats') + .addColumn('streak_start', 'integer') + .addColumn('streak_end', 'integer') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('author_stats') + .dropColumn('streak_start') + .dropColumn('streak_end') + .execute(); +} diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts index 99409c6a..eedaaa29 100644 --- a/src/entities/MastodonAccount.ts +++ b/src/entities/MastodonAccount.ts @@ -45,6 +45,11 @@ export interface MastodonAccount { ditto: { accepts_zaps: boolean; external_url: string; + streak: { + days: number; + start: string | null; + end: string | null; + }; }; domain?: string; pleroma: { diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index cca7c0ca..293a7ab4 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -6,6 +6,8 @@ export interface AuthorStats { followers_count: number; following_count: number; notes_count: number; + streak_start?: number; + streak_end?: number; } /** Ditto internal stats for the event. */ diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 28dcea47..160dd1cc 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -89,10 +89,22 @@ export function assembleEvents( ): DittoEvent[] { const admin = Conf.pubkey; - const eventStats = stats.events.map((stat) => ({ - ...stat, - reactions: JSON.parse(stat.reactions), - })); + const authorStats = stats.authors.reduce((result, { pubkey, ...stat }) => { + result[pubkey] = { + ...stat, + streak_start: stat.streak_start ?? undefined, + streak_end: stat.streak_end ?? undefined, + }; + return result; + }, {} as Record); + + const eventStats = stats.events.reduce((result, { event_id, ...stat }) => { + result[event_id] = { + ...stat, + reactions: JSON.parse(stat.reactions), + }; + return result; + }, {} as Record); for (const event of a) { event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); @@ -161,8 +173,8 @@ export function assembleEvents( event.zap_message = zapRequest?.content ?? ''; } - event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey); - event.event_stats = eventStats.find((stats) => stats.event_id === event.id); + event.author_stats = authorStats[event.pubkey]; + event.event_stats = eventStats[event.id]; } return a; @@ -383,6 +395,8 @@ async function gatherAuthorStats( following_count: Math.max(0, row.following_count), notes_count: Math.max(0, row.notes_count), search: row.search, + streak_start: row.streak_start, + streak_end: row.streak_end, })); } diff --git a/src/utils/stats.ts b/src/utils/stats.ts index e2fab440..64aaa66c 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -1,5 +1,5 @@ import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; -import { Kysely, UpdateObject } from 'kysely'; +import { Insertable, Kysely, UpdateObject } from 'kysely'; import { SetRequired } from 'type-fest'; import { z } from 'zod'; @@ -18,6 +18,8 @@ interface UpdateStatsOpts { export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise { switch (event.kind) { case 1: + case 20: + case 1111: return handleEvent1(kysely, event, x); case 3: return handleEvent3(kysely, event, x, store); @@ -34,7 +36,32 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp /** Update stats for kind 1 event. */ async function handleEvent1(kysely: Kysely, event: NostrEvent, x: number): Promise { - await updateAuthorStats(kysely, event.pubkey, ({ notes_count }) => ({ notes_count: Math.max(0, notes_count + x) })); + await updateAuthorStats(kysely, event.pubkey, (prev) => { + let start = prev.streak_start; + let end = prev.streak_end; + + if (start && end) { // Streak exists. + if (event.created_at <= end) { + // Streak cannot go backwards in time. Skip it. + } else if (end - start > 86400) { + // Streak is broken. Start a new streak. + start = event.created_at; + end = event.created_at; + } else { + // Extend the streak. + end = event.created_at; + } + } else { // New streak. + start = event.created_at; + end = event.created_at; + } + + return { + notes_count: Math.max(0, prev.notes_count + x), + streak_start: start || null, + streak_end: end || null, + }; + }); const replyId = findReplyTag(event.tags)?.[1]; const quoteId = findQuoteTag(event.tags)?.[1]; @@ -187,9 +214,9 @@ export function getAuthorStats( export async function updateAuthorStats( kysely: Kysely, pubkey: string, - fn: (prev: DittoTables['author_stats']) => UpdateObject, + fn: (prev: Insertable) => UpdateObject, ): Promise { - const empty: DittoTables['author_stats'] = { + const empty: Insertable = { pubkey, followers_count: 0, following_count: 0, @@ -290,6 +317,8 @@ export async function countAuthorStats( following_count: getTagSet(followList?.tags ?? [], 'p').size, notes_count, search, + streak_start: null, + streak_end: null, }; } diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 025737c3..7a252158 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -69,6 +69,15 @@ async function renderAccount( verified_at: null, })) ?? []; + let streakDays = 0; + const streakStart = event.author_stats?.streak_start; + const streakEnd = event.author_stats?.streak_end; + + if (streakStart && streakEnd) { + const delta = streakEnd - streakStart; + streakDays = Math.ceil(delta / 86400); + } + return { id: pubkey, acct, @@ -113,6 +122,11 @@ async function renderAccount( ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), external_url: Conf.external(nprofile), + streak: { + days: streakDays, + start: streakStart ? nostrDate(streakStart).toISOString() : null, + end: streakEnd ? nostrDate(streakEnd).toISOString() : null, + }, }, domain: parsed05?.domain, pleroma: { From abea4f17b31f8d0b6626ddc1c86a72d4978392a7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Feb 2025 14:44:01 -0600 Subject: [PATCH 2/4] Streak: report a 1 day streak after the first post --- src/views/mastodon/accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 7a252158..c456b18c 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -75,7 +75,7 @@ async function renderAccount( if (streakStart && streakEnd) { const delta = streakEnd - streakStart; - streakDays = Math.ceil(delta / 86400); + streakDays = Math.max(Math.ceil(delta / 86400), 1); } return { From 080c34d13fc2f32b46da70d10205293abcae24c1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Feb 2025 14:53:42 -0600 Subject: [PATCH 3/4] Fix streak broken logic --- src/utils/stats.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 64aaa66c..341174c5 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -37,23 +37,25 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp /** Update stats for kind 1 event. */ async function handleEvent1(kysely: Kysely, event: NostrEvent, x: number): Promise { await updateAuthorStats(kysely, event.pubkey, (prev) => { + const now = event.created_at; + let start = prev.streak_start; let end = prev.streak_end; if (start && end) { // Streak exists. - if (event.created_at <= end) { + if (now <= end) { // Streak cannot go backwards in time. Skip it. - } else if (end - start > 86400) { + } else if (now - end > 86400) { // Streak is broken. Start a new streak. - start = event.created_at; - end = event.created_at; + start = now; + end = now; } else { // Extend the streak. - end = event.created_at; + end = now; } } else { // New streak. - start = event.created_at; - end = event.created_at; + start = now; + end = now; } return { From b480947c4d8f7d06b87db28423940b1c38006efc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Feb 2025 15:56:49 -0600 Subject: [PATCH 4/4] Add a script to recompute the streak of all authors --- deno.json | 1 + scripts/db-streak-recompute.ts | 48 ++++++++++++++++++++++++++++++++++ src/utils/stats.ts | 1 + 3 files changed, 50 insertions(+) create mode 100644 scripts/db-streak-recompute.ts diff --git a/deno.json b/deno.json index 7501c3f6..562aab51 100644 --- a/deno.json +++ b/deno.json @@ -23,6 +23,7 @@ "clean:deps": "deno cache --reload src/app.ts", "db:populate-search": "deno run -A --env-file --deny-read=.env scripts/db-populate-search.ts", "db:populate-extensions": "deno run -A --env-file --deny-read=.env scripts/db-populate-extensions.ts", + "db:streak:recompute": "deno run -A --env-file --deny-read=.env scripts/db-streak-recompute.ts", "vapid": "deno run scripts/vapid.ts" }, "unstable": [ diff --git a/scripts/db-streak-recompute.ts b/scripts/db-streak-recompute.ts new file mode 100644 index 00000000..262f0427 --- /dev/null +++ b/scripts/db-streak-recompute.ts @@ -0,0 +1,48 @@ +import { Storages } from '@/storages.ts'; + +const kysely = await Storages.kysely(); +const statsQuery = kysely.selectFrom('author_stats').select('pubkey'); + +for await (const { pubkey } of statsQuery.stream(10)) { + const eventsQuery = kysely + .selectFrom('nostr_events') + .select('created_at') + .where('pubkey', '=', pubkey) + .where('kind', 'in', [1, 20, 1111, 30023]) + .orderBy('nostr_events.created_at', 'desc') + .orderBy('nostr_events.id', 'asc'); + + let end: number | null = null; + let start: number | null = null; + + for await (const { created_at } of eventsQuery.stream(20)) { + const createdAt = Number(created_at); + + if (!end) { + const now = Math.floor(Date.now() / 1000); + + if (now - createdAt > 86400) { + break; // streak broken + } + + end = createdAt; + } + + if (start && (start - createdAt > 86400)) { + break; // streak broken + } + + start = createdAt; + } + + await kysely + .updateTable('author_stats') + .set({ + streak_end: end, + streak_start: start, + }) + .where('pubkey', '=', pubkey) + .execute(); +} + +Deno.exit(); diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 341174c5..64e7986d 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -20,6 +20,7 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp case 1: case 20: case 1111: + case 30023: return handleEvent1(kysely, event, x); case 3: return handleEvent3(kysely, event, x, store);