From 084df2b59d44c07187132083d84e002c45ff3304 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Feb 2025 13:51:21 -0600 Subject: [PATCH] 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: {