diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts index 00a233d7..b43833f5 100644 --- a/packages/skin-database/app/(modern)/scroll/Events.ts +++ b/packages/skin-database/app/(modern)/scroll/Events.ts @@ -27,7 +27,7 @@ export async function logUserEvent(sessionId: string, event: UserEvent) { } } -type UserEvent = +export type UserEvent = | { type: "session_start"; } @@ -35,6 +35,13 @@ type UserEvent = type: "session_end"; reason: "unmount" | "before_unload"; } + /** + * @deprecated + */ + | { + type: "skin_view"; + skinMd5: string; + } | { type: "skin_view_start"; skinMd5: string; diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx index 4e5bf1d3..ac7382f0 100644 --- a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx +++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx @@ -138,9 +138,16 @@ export default function SkinScroller({ ref={setContainerRef} style={{ height: "100vh", + width: "100vw", + maxWidth: "56.25vh", // 9:16 aspect ratio (100vh * 9/16) + margin: "0 auto", overflowY: "scroll", scrollSnapType: "y mandatory", + scrollbarWidth: "none", // Firefox + msOverflowStyle: "none", // IE and Edge + WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS }} + className="hide-scrollbar" > {skins.map((skin, i) => { return ( diff --git a/packages/skin-database/app/(modern)/scroll/scroll.css b/packages/skin-database/app/(modern)/scroll/scroll.css index ae1b15a6..5ed3b090 100644 --- a/packages/skin-database/app/(modern)/scroll/scroll.css +++ b/packages/skin-database/app/(modern)/scroll/scroll.css @@ -1,14 +1,16 @@ body { margin: 0; /* Remove default margin */ height: 100vh; /* Set body height to viewport height */ - background-color: rgb(0, 0, 0); + background-color: #1a1a1a; /* Dark charcoal instead of pure black */ } -.scroller::-webkit-scrollbar { +.scroller::-webkit-scrollbar, +.hide-scrollbar::-webkit-scrollbar { display: none; /* Chrome, Safari, Edge */ } -.scroller { +.scroller, +.hide-scrollbar { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } diff --git a/packages/skin-database/cli.ts b/packages/skin-database/cli.ts index 1c6b96a1..796687e2 100755 --- a/packages/skin-database/cli.ts +++ b/packages/skin-database/cli.ts @@ -36,6 +36,7 @@ import * as S3 from "./s3"; import { generateDescription } from "./services/openAi"; import KeyValue from "./data/KeyValue"; import { postToBluesky } from "./tasks/bluesky"; +import { computeSkinRankings } from "./tasks/computeScrollRanking"; async function withHandler( cb: (handler: DiscordEventHandler) => Promise @@ -308,6 +309,14 @@ program console.table([await Skins.getStats()]); }); +program + .command("compute-scroll-ranking") + .description("Analyze user event data and compute skin ranking scores.") + .action(async () => { + const rankings = await computeSkinRankings(); + console.log(JSON.stringify(rankings, null, 2)); + }); + program .command("process-uploads") .description("Process any unprocessed user uploads.") diff --git a/packages/skin-database/tasks/computeScrollRanking.ts b/packages/skin-database/tasks/computeScrollRanking.ts new file mode 100644 index 00000000..fc0147e8 --- /dev/null +++ b/packages/skin-database/tasks/computeScrollRanking.ts @@ -0,0 +1,181 @@ +import { knex } from "../db"; +import type { UserEvent } from "../app/(modern)/scroll/Events"; + +const WEIGHTS = { + viewDurationPerSecond: 0.1, + like: 10, + download: 5, + share: 15, + readmeExpand: 2, +}; + +interface SessionAggregate { + skinViewDurations: Map; + skinsLiked: Set; + skinsDownloaded: Set; + readmesExpanded: Set; + sharesSucceeded: Set; +} + +interface SkinRanking { + skinMd5: string; + totalViewDurationMs: number; + viewCount: number; + averageViewDurationMs: number; + likeCount: number; + downloadCount: number; + shareCount: number; + readmeExpandCount: number; + rankingScore: number; +} + +async function main() { + try { + const rankings = await computeSkinRankings(); + console.log(JSON.stringify(rankings, null, 2)); + } catch (error) { + console.error("Error during aggregation:", error); + throw error; + } finally { + await knex.destroy(); + } +} + +if (require.main === module) { + main(); +} + +export async function computeSkinRankings(): Promise { + const sessionMap = await buildSessionAggregates(); + + const skinDataMap = new Map< + string, + { + viewDurations: number[]; + likes: number; + downloads: number; + shares: number; + readmeExpands: number; + } + >(); + + function getSkinData(skinMd5: string) { + if (!skinDataMap.has(skinMd5)) { + skinDataMap.set(skinMd5, { + viewDurations: [], + likes: 0, + downloads: 0, + shares: 0, + readmeExpands: 0, + }); + } + return skinDataMap.get(skinMd5)!; + } + + for (const session of sessionMap.values()) { + for (const [skinMd5, duration] of session.skinViewDurations) { + getSkinData(skinMd5).viewDurations.push(duration); + } + for (const skinMd5 of session.skinsLiked) { + getSkinData(skinMd5).likes++; + } + for (const skinMd5 of session.skinsDownloaded) { + getSkinData(skinMd5).downloads++; + } + for (const skinMd5 of session.sharesSucceeded) { + getSkinData(skinMd5).shares++; + } + for (const skinMd5 of session.readmesExpanded) { + getSkinData(skinMd5).readmeExpands++; + } + } + + const rankings: SkinRanking[] = []; + + for (const [skinMd5, data] of skinDataMap) { + const totalViewDurationMs = data.viewDurations.reduce( + (sum, duration) => sum + duration, + 0 + ); + const viewCount = data.viewDurations.length; + const averageViewDurationMs = + viewCount > 0 ? totalViewDurationMs / viewCount : 0; + + const rankingScore = + (averageViewDurationMs / 1000) * WEIGHTS.viewDurationPerSecond + + data.likes * WEIGHTS.like + + data.downloads * WEIGHTS.download + + data.shares * WEIGHTS.share + + data.readmeExpands * WEIGHTS.readmeExpand; + + rankings.push({ + skinMd5, + totalViewDurationMs, + viewCount, + averageViewDurationMs, + likeCount: data.likes, + downloadCount: data.downloads, + shareCount: data.shares, + readmeExpandCount: data.readmeExpands, + rankingScore, + }); + } + + rankings.sort((a, b) => b.rankingScore - a.rankingScore); + return rankings; +} + +async function buildSessionAggregates(): Promise< + Map +> { + const events = await knex("user_log_events") + .select("session_id", "timestamp", "metadata") + .orderBy("timestamp", "asc"); + + const sessionMap = new Map(); + + function getSession(sessionId: string): SessionAggregate { + if (!sessionMap.has(sessionId)) { + sessionMap.set(sessionId, { + skinViewDurations: new Map(), + skinsLiked: new Set(), + skinsDownloaded: new Set(), + readmesExpanded: new Set(), + sharesSucceeded: new Set(), + }); + } + return sessionMap.get(sessionId)!; + } + + for (const row of events) { + const event: UserEvent = JSON.parse(row.metadata); + const session = getSession(row.session_id); + + switch (event.type) { + case "skin_view_end": + session.skinViewDurations.set(event.skinMd5, event.durationMs); + break; + case "readme_expand": + session.readmesExpanded.add(event.skinMd5); + break; + case "skin_download": + session.skinsDownloaded.add(event.skinMd5); + break; + case "skin_like": + if (event.liked) { + session.skinsLiked.add(event.skinMd5); + } else { + session.skinsLiked.delete(event.skinMd5); + } + break; + case "share_success": + session.sharesSucceeded.add(event.skinMd5); + break; + } + } + + return sessionMap; +} + +export { buildSessionAggregates }; +export type { SessionAggregate };