From bea08e3b3c0374e025ffcd819486f704b6036e4c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:00:54 -0400 Subject: [PATCH] Sort watch history by most recent view --- js/historyView.js | 32 +++++++------------------ js/watchHistorySort.js | 45 ++++++++++++++++++++++++++++++++++++ tests/watch-history.test.mjs | 37 +++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 js/watchHistorySort.js diff --git a/js/historyView.js b/js/historyView.js index 3c5a2b2a..940bdad7 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -5,6 +5,11 @@ import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; import { subscriptions } from "./subscriptions.js"; import { accessControl } from "./accessControl.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; +import { + toNumber, + getWatchedAtScore, + compareByWatchedAtDesc, +} from "./watchHistorySort.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; @@ -15,11 +20,6 @@ export const WATCH_HISTORY_EMPTY_COPY = const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…"; const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet."; -function toNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - function getVideoAuthorIdentifiers(video) { if (!video || typeof video !== "object") { return { pubkey: "", npub: "" }; @@ -119,24 +119,6 @@ function filterAccessibleVideos(videos) { }); } -function getWatchedAtScore(video) { - if (!video || typeof video !== "object") { - return Number.NEGATIVE_INFINITY; - } - - const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); - if (Number.isFinite(watchedAt)) { - return watchedAt; - } - - const createdAtSeconds = toNumber(video?.created_at, Number.NaN); - if (Number.isFinite(createdAtSeconds)) { - return createdAtSeconds * 1000; - } - - return Number.NEGATIVE_INFINITY; -} - function getAbsoluteShareUrl(nevent) { if (!nevent) { return ""; @@ -822,7 +804,9 @@ export function createWatchHistoryRenderer(config = {}) { state.resolvedVideos.forEach((video) => ingest(dedupeMap, video)); nextVideos.forEach((video) => ingest(dedupeMap, video)); - state.resolvedVideos = Array.from(dedupeMap.values()); + state.resolvedVideos = Array.from(dedupeMap.values()).sort( + compareByWatchedAtDesc + ); applyAccessFilters(); }; diff --git a/js/watchHistorySort.js b/js/watchHistorySort.js new file mode 100644 index 00000000..3d9dcefa --- /dev/null +++ b/js/watchHistorySort.js @@ -0,0 +1,45 @@ +// js/watchHistorySort.js + +export function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function getWatchedAtScore(video) { + if (!video || typeof video !== "object") { + return Number.NEGATIVE_INFINITY; + } + + const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); + if (Number.isFinite(watchedAt)) { + return watchedAt; + } + + const createdAtSeconds = toNumber(video?.created_at, Number.NaN); + if (Number.isFinite(createdAtSeconds)) { + return createdAtSeconds * 1000; + } + + return Number.NEGATIVE_INFINITY; +} + +export function compareByWatchedAtDesc(a, b) { + const aScore = getWatchedAtScore(a); + const bScore = getWatchedAtScore(b); + + if (aScore !== bScore) { + return bScore - aScore; + } + + const aCreated = toNumber(a?.created_at, Number.NEGATIVE_INFINITY); + const bCreated = toNumber(b?.created_at, Number.NEGATIVE_INFINITY); + + if (aCreated !== bCreated) { + return bCreated - aCreated; + } + + const aId = typeof a?.id === "string" ? a.id : ""; + const bId = typeof b?.id === "string" ? b.id : ""; + + return aId.localeCompare(bId); +} diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2740d781..f9e4f5d9 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -12,6 +12,9 @@ const { buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } = await import("../js/nostrEventSchemas.js"); +const { compareByWatchedAtDesc, getWatchedAtScore } = await import( + "../js/watchHistorySort.js" +); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -1063,4 +1066,38 @@ try { Date.now = originalDateNow; } +const unsortedVideos = [ + { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, + { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, + { id: "charlie", created_at: 1_699_999_000 }, +]; + +const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc); + +assert.deepEqual( + sortedVideos.map((video) => video.id), + ["bravo", "alpha", "charlie"], + "videos should be ordered by watchedAt descending with created_at fallback", +); + +const tieVideos = [ + { id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 }, + { id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, + { id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, +]; + +tieVideos.sort(compareByWatchedAtDesc); + +assert.equal( + getWatchedAtScore(tieVideos[0]), + getWatchedAtScore(tieVideos[1]), + "ties should preserve identical watchedAt scores", +); + +assert.deepEqual( + tieVideos.map((video) => video.id), + ["delta", "foxtrot", "echo"], + "created_at should break watchedAt ties with stable fallback", +); + console.log("watch history tests passed");