From a990b866819996c6e1620337c1bee92ef9f556e4 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 14:19:10 +0100 Subject: [PATCH 01/46] Implement scroll tracking and restore functionality in SingleContent component --- app/src/pages/SingleContent/SingleContent.vue | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index db408b5646..0784e71323 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -38,6 +38,9 @@ import { queryParams, addToMediaQueue, cmsUrl, + setReadingProgress, + getReadingProgress, + removeReadingProgress, } from "@/globalConfig"; import { useNotificationStore } from "@/stores/notification"; import NotFoundPage from "@/pages/NotFoundPage.vue"; @@ -468,8 +471,109 @@ watch( const openedFromExternalLink = ref(false); +const scrollPosition = ref(0); +const maxScrollPosition = ref(0); +const scrollContainer = ref(window); +let ticking = false; + +const storedScrollKey = computed(() => `scrollPosition-${content.value?._id}`); + +const restoreScrollPosition = () => { + if (!content.value) return; + const percent = getReadingProgress(content.value._id); + if (percent && percent < 100) { + setTimeout(() => { + const maxScroll = + scrollContainer.value === window + ? document.documentElement.scrollHeight - window.innerHeight + : (scrollContainer.value as HTMLElement).scrollHeight - + (scrollContainer.value as HTMLElement).clientHeight; + + const targetY = Math.round((percent / 100) * maxScroll); + + if (scrollContainer.value === window) { + window.scrollTo({ top: targetY }); + } else { + (scrollContainer.value as HTMLElement).scrollTo({ top: targetY }); + } + + maxScrollPosition.value = targetY; + }, 300); + } +}; + +const updateScrollPosition = () => { + const scrollY = + scrollContainer.value === window + ? window.scrollY + : (scrollContainer.value as HTMLElement).scrollTop; + + scrollPosition.value = scrollY; + + if (scrollY > maxScrollPosition.value) { + maxScrollPosition.value = scrollY; + } + + if (!ticking) { + window.requestAnimationFrame(() => { + if (content.value && content.value._id) { + const maxScroll = + scrollContainer.value === window + ? document.documentElement.scrollHeight - window.innerHeight + : (scrollContainer.value as HTMLElement).scrollHeight - + (scrollContainer.value as HTMLElement).clientHeight; + + const percentScrolled = + maxScroll > 0 ? (maxScrollPosition.value / maxScroll) * 100 : 0; + const rounded = Math.round(percentScrolled); + + if (rounded < 100) { + setReadingProgress(content.value._id, rounded); + localStorage.setItem(storedScrollKey.value, maxScrollPosition.value.toString()); + } else { + removeReadingProgress(content.value._id); + localStorage.removeItem(storedScrollKey.value); + } + } + ticking = false; + }); + ticking = true; + } +}; + +const setScrollContainer = () => { + const basePage = document.querySelector("main, .scroll-container, [data-scroll-container]"); + scrollContainer.value = + basePage && basePage.scrollHeight > basePage.clientHeight + ? (basePage as HTMLElement) + : window; +}; + +const addScrollListener = () => { + scrollContainer.value.addEventListener("scroll", updateScrollPosition, { passive: true }); +}; + +const removeScrollListener = () => { + scrollContainer.value.removeEventListener("scroll", updateScrollPosition); +}; + onMounted(() => { openedFromExternalLink.value = isExternalNavigation(); + setScrollContainer(); + addScrollListener(); + restoreScrollPosition(); + + watch(content, () => { + removeScrollListener(); + setScrollContainer(); + addScrollListener(); + maxScrollPosition.value = 0; + restoreScrollPosition(); + }); +}); + +onUnmounted(() => { + removeScrollListener(); }); // Track whether the user explicitly switched language via the quick selector From 8d19befe1575f70cdaa255e98442022e34de4583 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 14:19:21 +0100 Subject: [PATCH 02/46] Add test for restoring reading progress in SingleContent component --- .../__tests__/SingleContent.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts index b1f26688c9..a3768968fd 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts @@ -24,7 +24,9 @@ import waitForExpect from "wait-for-expect"; import { appLanguageIdsAsRef, appName, + getReadingProgress, initLanguage, + setReadingProgress, userPreferencesAsRef, cmsUrl, } from "@/globalConfig"; @@ -679,4 +681,20 @@ describe("SingleContent", () => { expect(wrapper.text()).toContain(`${expectedReadingTime} min`); }); }); + + it("restores reading progress", async () => { + setReadingProgress(mockEnglishContentDto._id, 60); + + const wrapper = mount(SingleContent, { + props: { + slug: mockEnglishContentDto.slug, + }, + }); + + await waitForExpect(() => { + expect(getReadingProgress(mockEnglishContentDto._id)).toBeGreaterThanOrEqual(60); + }); + + wrapper.unmount(); + }); }); From 05d8795a2aec79c2593d6a2f88a314927c3b9697 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 14:19:36 +0100 Subject: [PATCH 03/46] Add reading progress tracking functionality with localStorage support --- app/src/globalConfig.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/app/src/globalConfig.ts b/app/src/globalConfig.ts index 37e17f69c8..830bc3e38a 100644 --- a/app/src/globalConfig.ts +++ b/app/src/globalConfig.ts @@ -456,3 +456,63 @@ export const fallbackImageUrls = loadFallbackImageUrls(); * True while the app's Startup function is still running. Used to display the loading splash screen. */ export const isAppLoading = ref(!new URLSearchParams(window.location.search).has("nosplash")); + +export type ReadingProgress = { + contentId: Uuid; + progress: number; // Progress in percentage (0–100) +}; + +export const readingProgressAsRef = ref([]); + +const _readingProgress = JSON.parse( + localStorage.getItem("readingProgress") || "[]", +) as ReadingProgress[]; + +/** + * Get the reading progress of a content item. + * @param contentId - The content document ID. + * @returns - Reading progress in percentage (0–100) + */ +export const getReadingProgress = (contentId: Uuid): number => { + const entry = _readingProgress.find((p) => p.contentId === contentId); + return entry ? entry.progress : 0; +}; + +/** + * Set the reading progress of a content item. + * If it already exists, update it; otherwise, insert it. + * @param contentId - The content document ID. + * @param progress - Progress percentage (0–100) + */ +export const setReadingProgress = (contentId: Uuid, progress: number) => { + const clampedProgress = Math.min(Math.max(progress, 0), 100); + const index = _readingProgress.findIndex((p) => p.contentId === contentId); + + if (index !== -1) { + _readingProgress[index].progress = clampedProgress; + } else { + _readingProgress.push({ contentId, progress: clampedProgress }); + } + + readingProgressAsRef.value = [..._readingProgress]; + + // Persist to localStorage + localStorage.setItem("readingProgress", JSON.stringify(_readingProgress)); +}; + +/** + * Remove reading progress for a given content. + */ +export const removeReadingProgress = (contentId: Uuid) => { + // Remove from _readingProgress + const index = _readingProgress.findIndex((p) => p.contentId === contentId); + if (index !== -1) { + _readingProgress.splice(index, 1); + } + + // Remove from reactive ref + readingProgressAsRef.value = _readingProgress; + + // Sync localStorage + localStorage.setItem("readingProgress", JSON.stringify(_readingProgress)); +}; From e3d55be1635deab2e16b68b094c6e4f2cd28d4cc Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 14:19:44 +0100 Subject: [PATCH 04/46] Add tests for reading progress functionality in globalConfig --- app/src/globalConfig.spec.ts | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/src/globalConfig.spec.ts b/app/src/globalConfig.spec.ts index c09d783628..ad8e57dbd4 100644 --- a/app/src/globalConfig.spec.ts +++ b/app/src/globalConfig.spec.ts @@ -14,6 +14,10 @@ import { clearMediaQueue, nextInMediaQueue, isInstalledStandalone, + getReadingProgress, + readingProgressAsRef, + removeReadingProgress, + setReadingProgress, } from "@/globalConfig"; import { mockEnglishContentDto, @@ -229,4 +233,39 @@ describe("globalConfig.ts", () => { expect(isInstalledStandalone()).toBe(true); }); }); + + describe("Reading Progress", () => { + const testContentId = "test-content-id"; + + afterEach(() => { + removeReadingProgress(testContentId); + localStorage.removeItem("readingProgress"); + }); + + it("sets and gets reading progress correctly", () => { + setReadingProgress(testContentId, 45); + expect(getReadingProgress(testContentId)).toBe(45); + }); + + it("clamps progress to 100 max", () => { + setReadingProgress(testContentId, 120); + expect(getReadingProgress(testContentId)).toBe(100); + }); + + it("clamps progress to 0 min", () => { + setReadingProgress(testContentId, -10); + expect(getReadingProgress(testContentId)).toBe(0); + }); + + it("removes reading progress correctly", () => { + setReadingProgress(testContentId, 50); + expect(getReadingProgress(testContentId)).toBe(50); + + removeReadingProgress(testContentId); + expect(getReadingProgress(testContentId)).toBe(0); + expect( + readingProgressAsRef.value.find((p) => p.contentId === testContentId), + ).toBeUndefined(); + }); + }); }); From 54c04939aa9a9285a692f92566c2ac601258f161 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 15:07:07 +0100 Subject: [PATCH 05/46] Add ContinueReading component to display watched media with progress tracking --- .../components/HomePage/ContinueReading.vue | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/src/components/HomePage/ContinueReading.vue diff --git a/app/src/components/HomePage/ContinueReading.vue b/app/src/components/HomePage/ContinueReading.vue new file mode 100644 index 0000000000..ddd3e30c55 --- /dev/null +++ b/app/src/components/HomePage/ContinueReading.vue @@ -0,0 +1,92 @@ + + + From 0b656e0b8e903f95343aad147331d9b7843df4bb Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 15:07:45 +0100 Subject: [PATCH 06/46] Update ContinueWatching component title and adjust layout classes --- app/src/components/HomePage/ContinueWatching.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/components/HomePage/ContinueWatching.vue b/app/src/components/HomePage/ContinueWatching.vue index 1a9eece07c..77104e0d49 100644 --- a/app/src/components/HomePage/ContinueWatching.vue +++ b/app/src/components/HomePage/ContinueWatching.vue @@ -90,9 +90,8 @@ const watchedContent = computed(() => { From 2bab6e3952e65adbacbbe24b845c2197a99b3244 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 15:07:57 +0100 Subject: [PATCH 07/46] Add ContinueProgress component to track and display media progress --- .../components/HomePage/ContinueProgress.vue | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 app/src/components/HomePage/ContinueProgress.vue diff --git a/app/src/components/HomePage/ContinueProgress.vue b/app/src/components/HomePage/ContinueProgress.vue new file mode 100644 index 0000000000..62036b7a61 --- /dev/null +++ b/app/src/components/HomePage/ContinueProgress.vue @@ -0,0 +1,86 @@ + + + From e2de778235a782d4da1d394842c0b12c764ea3e2 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 9 Jun 2025 15:08:28 +0100 Subject: [PATCH 08/46] Add ContinueReading component to HomePage for displaying readed content --- app/src/pages/HomePage.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/pages/HomePage.vue b/app/src/pages/HomePage.vue index afaf3459fa..15ae874fcd 100644 --- a/app/src/pages/HomePage.vue +++ b/app/src/pages/HomePage.vue @@ -5,6 +5,7 @@ import HomePageNewest from "@/components/HomePage/HomePageNewest.vue"; import BasePage from "@/components/BasePage.vue"; import ContinueWatching from "@/components/HomePage/ContinueWatching.vue"; import ContinueListening from "@/components/HomePage/ContinueListening.vue"; +import ContinueReading from "@/components/HomePage/ContinueReading.vue"; import HomePageSearch from "@/components/HomePage/HomePageSearch.vue"; import { isMdScreen } from "@/globalConfig"; import { nextTick, onActivated, ref } from "vue"; @@ -36,6 +37,7 @@ onActivated(checkReady); + From 3d079e380608b91b5ae8d8131150e21dcbe73a96 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 10:32:09 +0100 Subject: [PATCH 09/46] Refactor ContinueProgress and ContinueReading components to utilize mangoToDexie for content retrieval and improve filtering logic --- .../components/HomePage/ContinueProgress.vue | 40 ++++++++++++------ .../components/HomePage/ContinueReading.vue | 42 ++++++++++++------- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/app/src/components/HomePage/ContinueProgress.vue b/app/src/components/HomePage/ContinueProgress.vue index 62036b7a61..b72ae7cdd0 100644 --- a/app/src/components/HomePage/ContinueProgress.vue +++ b/app/src/components/HomePage/ContinueProgress.vue @@ -1,9 +1,17 @@ - - From 1ed5b99d4a69ff4c1c6ac5982006fc143ed79ce5 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:27 +0100 Subject: [PATCH 11/46] Add reading time helpers for dwell-based progress tracking. Shared WPM resolution, word counting, and per-block dwell bounds keep SingleContent estimates and the progress tracker on the same math. --- app/src/util/readingTime.spec.ts | 56 ++++++++++++++++++++++++++++++++ app/src/util/readingTime.ts | 41 +++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 app/src/util/readingTime.spec.ts create mode 100644 app/src/util/readingTime.ts diff --git a/app/src/util/readingTime.spec.ts b/app/src/util/readingTime.spec.ts new file mode 100644 index 0000000000..62706f7c5f --- /dev/null +++ b/app/src/util/readingTime.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_READING_SPEED_WPM, + READING_MAX_DWELL_MS, + READING_MIN_DWELL_MS, + computeBlockDwellMs, + computeEstimatedReadingMinutes, + countWords, + resolveReadingSpeedWpm, +} from "./readingTime"; + +describe("resolveReadingSpeedWpm", () => { + it("returns the language value when valid", () => { + expect(resolveReadingSpeedWpm(250)).toBe(250); + }); + + it("falls back for missing or invalid values", () => { + expect(resolveReadingSpeedWpm()).toBe(DEFAULT_READING_SPEED_WPM); + expect(resolveReadingSpeedWpm(0)).toBe(DEFAULT_READING_SPEED_WPM); + expect(resolveReadingSpeedWpm(-1)).toBe(DEFAULT_READING_SPEED_WPM); + expect(resolveReadingSpeedWpm(Number.NaN)).toBe(DEFAULT_READING_SPEED_WPM); + }); +}); + +describe("countWords", () => { + it("counts whitespace-separated tokens", () => { + expect(countWords("Block 1")).toBe(2); + expect(countWords(" one two three ")).toBe(3); + expect(countWords("")).toBe(0); + }); +}); + +describe("computeEstimatedReadingMinutes", () => { + it("rounds up article reading time from word count and WPM", () => { + expect(computeEstimatedReadingMinutes(400, 200)).toBe(2); + expect(computeEstimatedReadingMinutes(401, 200)).toBe(3); + expect(computeEstimatedReadingMinutes(0, 200)).toBe(0); + }); +}); + +describe("computeBlockDwellMs", () => { + it("scales dwell from word count and language WPM", () => { + expect(computeBlockDwellMs(2, 200)).toBe(600); + expect(computeBlockDwellMs(10, 200)).toBe(3000); + }); + + it("clamps to min and max dwell bounds", () => { + expect(computeBlockDwellMs(1, 200)).toBe(READING_MIN_DWELL_MS); + expect(computeBlockDwellMs(40, 200)).toBe(READING_MAX_DWELL_MS); + }); + + it("falls back when WPM is missing or invalid", () => { + expect(computeBlockDwellMs(2, 0)).toBe(600); + expect(computeBlockDwellMs(2)).toBe(600); + }); +}); diff --git a/app/src/util/readingTime.ts b/app/src/util/readingTime.ts new file mode 100644 index 0000000000..b9f28d9973 --- /dev/null +++ b/app/src/util/readingTime.ts @@ -0,0 +1,41 @@ +/** Default words-per-minute when a language has no averageReadingSpeed. */ +export const DEFAULT_READING_SPEED_WPM = 200; + +/** Shortest block dwell — quick glance on a tiny block should not count. */ +export const READING_MIN_DWELL_MS = 500; + +/** Longest block dwell — caps wait time for very long blocks. */ +export const READING_MAX_DWELL_MS = 8000; + +export function resolveReadingSpeedWpm(wordsPerMinute?: number | null): number { + if (wordsPerMinute == null || wordsPerMinute <= 0 || Number.isNaN(wordsPerMinute)) { + return DEFAULT_READING_SPEED_WPM; + } + return wordsPerMinute; +} + +export function countWords(text: string): number { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).length; +} + +/** Article-level estimate shown in the UI (minutes). */ +export function computeEstimatedReadingMinutes( + wordCount: number, + wordsPerMinute?: number | null, +): number { + if (!wordCount) return 0; + return Math.ceil(wordCount / resolveReadingSpeedWpm(wordsPerMinute)); +} + +/** Per-block dwell for progress tracking (milliseconds). */ +export function computeBlockDwellMs( + wordCount: number, + wordsPerMinute?: number | null, +): number { + if (wordCount <= 0) return READING_MIN_DWELL_MS; + const wpm = resolveReadingSpeedWpm(wordsPerMinute); + const ms = Math.round((wordCount / wpm) * 60_000); + return Math.min(READING_MAX_DWELL_MS, Math.max(READING_MIN_DWELL_MS, ms)); +} From 8a72ec6684a90c307067aa6195e4564874ed4ab6 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:31 +0100 Subject: [PATCH 12/46] Persist in-progress article reading percentage in localStorage. Expose get/set/remove helpers and a reactive ref so Continue Reading can subscribe to progress changes across tabs. --- app/src/globalConfig.spec.ts | 33 ++++++++++++++++++++++++++ app/src/globalConfig.ts | 46 +++++++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/src/globalConfig.spec.ts b/app/src/globalConfig.spec.ts index ad8e57dbd4..67e94ffe1e 100644 --- a/app/src/globalConfig.spec.ts +++ b/app/src/globalConfig.spec.ts @@ -18,6 +18,8 @@ import { readingProgressAsRef, removeReadingProgress, setReadingProgress, + syncReadingProgressFromStorage, + watchReadingProgressStorage, } from "@/globalConfig"; import { mockEnglishContentDto, @@ -267,5 +269,36 @@ describe("globalConfig.ts", () => { readingProgressAsRef.value.find((p) => p.contentId === testContentId), ).toBeUndefined(); }); + + it("syncReadingProgressFromStorage reloads from localStorage", () => { + localStorage.setItem( + "readingProgress", + JSON.stringify([{ contentId: testContentId, progress: 33 }]), + ); + + syncReadingProgressFromStorage(); + + expect(getReadingProgress(testContentId)).toBe(33); + expect(readingProgressAsRef.value).toEqual([ + { contentId: testContentId, progress: 33 }, + ]); + }); + + it("watchReadingProgressStorage reacts to storage events", () => { + const stop = watchReadingProgressStorage(); + + localStorage.setItem( + "readingProgress", + JSON.stringify([{ contentId: testContentId, progress: 72 }]), + ); + window.dispatchEvent(new StorageEvent("storage", { key: "readingProgress" })); + + expect(getReadingProgress(testContentId)).toBe(72); + expect(readingProgressAsRef.value).toEqual([ + { contentId: testContentId, progress: 72 }, + ]); + + stop(); + }); }); }); diff --git a/app/src/globalConfig.ts b/app/src/globalConfig.ts index 830bc3e38a..b2a2ea530b 100644 --- a/app/src/globalConfig.ts +++ b/app/src/globalConfig.ts @@ -464,9 +464,41 @@ export type ReadingProgress = { export const readingProgressAsRef = ref([]); -const _readingProgress = JSON.parse( - localStorage.getItem("readingProgress") || "[]", -) as ReadingProgress[]; +function readReadingProgressFromStorage(): ReadingProgress[] { + try { + const list = JSON.parse(localStorage.getItem("readingProgress") || "[]"); + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +function applyReadingProgressList(list: ReadingProgress[]) { + _readingProgress.length = 0; + _readingProgress.push(...list); + readingProgressAsRef.value = [..._readingProgress]; +} + +const _readingProgress: ReadingProgress[] = readReadingProgressFromStorage(); + +readingProgressAsRef.value = [..._readingProgress]; + +/** Reload reading progress from localStorage into the reactive ref (e.g. after cross-tab updates). */ +export function syncReadingProgressFromStorage(): void { + applyReadingProgressList(readReadingProgressFromStorage()); +} + +/** Listen for `storage` events on the readingProgress key. Returns a cleanup function. */ +export function watchReadingProgressStorage(): () => void { + const onStorage = (e: StorageEvent) => { + if (e.key === "readingProgress" || e.key === null) { + syncReadingProgressFromStorage(); + } + }; + + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); +} /** * Get the reading progress of a content item. @@ -494,9 +526,8 @@ export const setReadingProgress = (contentId: Uuid, progress: number) => { _readingProgress.push({ contentId, progress: clampedProgress }); } - readingProgressAsRef.value = [..._readingProgress]; + applyReadingProgressList([..._readingProgress]); - // Persist to localStorage localStorage.setItem("readingProgress", JSON.stringify(_readingProgress)); }; @@ -504,15 +535,12 @@ export const setReadingProgress = (contentId: Uuid, progress: number) => { * Remove reading progress for a given content. */ export const removeReadingProgress = (contentId: Uuid) => { - // Remove from _readingProgress const index = _readingProgress.findIndex((p) => p.contentId === contentId); if (index !== -1) { _readingProgress.splice(index, 1); } - // Remove from reactive ref - readingProgressAsRef.value = _readingProgress; + applyReadingProgressList([..._readingProgress]); - // Sync localStorage localStorage.setItem("readingProgress", JSON.stringify(_readingProgress)); }; From 13f0cc0c44289026758409b6117f9998027775d6 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:39 +0100 Subject: [PATCH 13/46] Track article reading progress with visibility and scroll velocity gates. Dwell accumulates per prose block only while the user is not skimming, so fast scrolls do not count as read progress. --- .../useReadingProgressTracker.spec.ts | 386 +++++++++++++++ .../composables/useReadingProgressTracker.ts | 456 ++++++++++++++++++ app/vitest.setup.ts | 17 + 3 files changed, 859 insertions(+) create mode 100644 app/src/composables/useReadingProgressTracker.spec.ts create mode 100644 app/src/composables/useReadingProgressTracker.ts diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts new file mode 100644 index 0000000000..6898a88974 --- /dev/null +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -0,0 +1,386 @@ +import { defineComponent, nextTick, ref, watch, watchEffect } from "vue"; +import { mount, flushPromises } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + READING_INTERSECTION_RATIO, + READING_MAX_SCROLL_VELOCITY_PX_S, + READING_RESTORE_GUARD_MS, + applyScrollVelocitySample, + computeScrollVelocity, + useReadingProgressTracker, +} from "./useReadingProgressTracker"; +import { + DEFAULT_READING_SPEED_WPM, + computeBlockDwellMs, + countWords, +} from "@/util/readingTime"; +import { getReadingProgress, removeReadingProgress, setReadingProgress } from "@/globalConfig"; + +const TEST_CONTENT_ID = "test-reading-content-id"; + +type MockObserver = { + callback: IntersectionObserverCallback; + elements: Element[]; + observe: (el: Element) => void; + unobserve: (el: Element) => void; + disconnect: () => void; + trigger: (el: Element, isIntersecting: boolean, intersectionRatio?: number) => void; +}; + +const observerInstances = vi.hoisted(() => [] as MockObserver[]); + +vi.stubGlobal( + "IntersectionObserver", + vi.fn().mockImplementation((callback: IntersectionObserverCallback) => { + const instance: MockObserver = { + callback, + elements: [], + observe(el: Element) { + instance.elements.push(el); + }, + unobserve(el: Element) { + instance.elements = instance.elements.filter((e) => e !== el); + }, + disconnect() { + instance.elements = []; + }, + trigger(el: Element, isIntersecting: boolean, intersectionRatio = READING_INTERSECTION_RATIO) { + callback( + [ + { + target: el, + isIntersecting, + intersectionRatio, + } as IntersectionObserverEntry, + ], + instance as unknown as IntersectionObserver, + ); + }, + }; + observerInstances.push(instance); + return instance; + }), +); + +function mountTracker( + blockCount = 2, + scrollable = false, + averageReadingSpeed = DEFAULT_READING_SPEED_WPM, + blockTexts?: string[], +) { + const texts = + blockTexts ?? + Array.from({ length: blockCount }, (_, i) => `Block ${i + 1}`); + + const TestComponent = defineComponent({ + setup() { + const articleRoot = ref(null); + const scrollContainerEl = ref(null); + const scrollContainer = ref(window); + const contentId = ref(TEST_CONTENT_ID); + const enabled = ref(true); + const readingSpeed = ref(averageReadingSpeed); + + watchEffect(() => { + if (scrollable && scrollContainerEl.value) { + scrollContainer.value = scrollContainerEl.value; + } + }); + + useReadingProgressTracker({ + contentId, + articleRoot, + scrollContainer, + enabled, + averageReadingSpeed: readingSpeed, + }); + + return { articleRoot, scrollContainerEl, scrollContainer }; + }, + template: scrollable + ? ` +
+
+ ${texts.map((t) => `

${t}

`).join("")} +
+
+ ` + : ` +
+ ${texts.map((t) => `

${t}

`).join("")} +
+ `, + }); + + return mount(TestComponent); +} + +function latestObserver() { + return observerInstances[observerInstances.length - 1]; +} + +let rafTime = 0; +let rafId = 0; +const pendingRafCallbacks = new Map(); + +function flushRafFrame(deltaMs = 16) { + rafTime += deltaMs; + const callbacks = [...pendingRafCallbacks.values()]; + pendingRafCallbacks.clear(); + for (const cb of callbacks) { + cb(rafTime); + } +} + +/** Advance fake time and drive the dwell rAF loop. */ +function advanceDwellMs(ms: number, frameMs = 16, { complete = true }: { complete?: boolean } = {}) { + const dwellFrames = complete ? Math.ceil(ms / frameMs) : Math.floor(ms / frameMs); + const rafFrames = 1 + dwellFrames; + for (let i = 0; i < rafFrames; i++) { + vi.advanceTimersByTime(frameMs); + flushRafFrame(frameMs); + } +} + +async function readyScrollableTracker(wrapper: ReturnType) { + await flushPromises(); + await nextTick(); + await nextTick(); + const scrollEl = wrapper.get('[data-test="scroll-container"]').element as HTMLElement; + const vm = wrapper.vm as { scrollContainer: HTMLElement | Window }; + vm.scrollContainer = scrollEl; + await nextTick(); + return wrapper; +} + + +const BLOCK_ONE_DWELL_MS = computeBlockDwellMs(countWords("Block 1"), DEFAULT_READING_SPEED_WPM); + +describe("computeScrollVelocity", () => { + it("returns px/s from delta and elapsed time", () => { + expect(computeScrollVelocity(120, 100)).toBe(1200); + expect(computeScrollVelocity(-120, 100)).toBe(1200); + }); + + it("returns 0 when sample window is too short (jitter)", () => { + expect(computeScrollVelocity(100, 49)).toBe(0); + }); +}); + +describe("applyScrollVelocitySample", () => { + it("detects fast scroll once batched samples exceed the jitter window", () => { + let state = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: false, + }; + + state = applyScrollVelocitySample(state, 80, 30).state; + expect(state.wasScrollingFast).toBe(false); + + const result = applyScrollVelocitySample(state, 80, 30); + expect(result.isFast).toBe(true); + expect(result.state.wasScrollingFast).toBe(true); + }); + + it("reports when scrolling slows after a fast burst", () => { + let state = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: true, + }; + + const result = applyScrollVelocitySample(state, 10, 100); + expect(result.isFast).toBe(false); + expect(result.justSlowedDown).toBe(true); + expect(result.state.wasScrollingFast).toBe(false); + }); +}); + +describe("useReadingProgressTracker", () => { + beforeEach(() => { + observerInstances.length = 0; + localStorage.removeItem("readingProgress"); + vi.useFakeTimers({ shouldAdvanceTime: true }); + rafTime = 0; + rafId = 0; + pendingRafCallbacks.clear(); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + const id = ++rafId; + pendingRafCallbacks.set(id, cb); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + pendingRafCallbacks.delete(id); + }); + }); + + afterEach(() => { + removeReadingProgress(TEST_CONTENT_ID); + localStorage.removeItem("readingProgress"); + vi.useRealTimers(); + }); + + it("does not save progress when a block is visible for less than the dwell time", async () => { + const wrapper = mountTracker(); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + const block = observer.elements[0]; + observer.trigger(block, true); + + advanceDwellMs(BLOCK_ONE_DWELL_MS - 1, 16, { complete: false }); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + wrapper.unmount(); + }); + + it("saves progress when a block stays visible for the dwell duration", async () => { + const wrapper = mountTracker(2); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + const block = observer.elements[0]; + observer.trigger(block, true); + + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("cancels dwell timer when a block leaves the viewport before dwell completes", async () => { + const wrapper = mountTracker(); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + const block = observer.elements[0]; + observer.trigger(block, true); + + advanceDwellMs(BLOCK_ONE_DWELL_MS / 2); + observer.trigger(block, false, 0); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + wrapper.unmount(); + }); + + it("reflects confirmed blocks only in progress percentage", async () => { + const wrapper = mountTracker(4); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + + observer.trigger(observer.elements[0], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + observer.trigger(observer.elements[1], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("uses a longer dwell for wordier blocks at the same reading speed", async () => { + const longText = + "This paragraph has many more words than the default test blocks so the dwell timer should take noticeably longer before progress is saved."; + const longDwell = computeBlockDwellMs(countWords(longText), DEFAULT_READING_SPEED_WPM); + expect(longDwell).toBeGreaterThan(BLOCK_ONE_DWELL_MS); + + const wrapper = mountTracker(2, false, DEFAULT_READING_SPEED_WPM, [longText, "Block 2"]); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[0], true); + + advanceDwellMs(BLOCK_ONE_DWELL_MS); + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + + advanceDwellMs(longDwell - BLOCK_ONE_DWELL_MS); + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("identifies fast scroll speeds for velocity gating", () => { + expect(computeScrollVelocity(5000, 100)).toBeGreaterThan(READING_MAX_SCROLL_VELOCITY_PX_S); + expect(computeScrollVelocity(100, 100)).toBeLessThan(READING_MAX_SCROLL_VELOCITY_PX_S); + }); + + it("saves progress after velocity drops and dwell completes at low speed", async () => { + const wrapper = await readyScrollableTracker(mountTracker(2, true)); + + const observer = latestObserver(); + const block = observer.elements[0]; + + observer.trigger(block, true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("does not overwrite saved progress during the restore scroll guard window", async () => { + setReadingProgress(TEST_CONTENT_ID, 60); + + const wrapper = await readyScrollableTracker(mountTracker(2, true)); + + const observer = latestObserver(); + const block = observer.elements[0]; + + vi.advanceTimersByTime(350); + observer.trigger(block, true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(60); + wrapper.unmount(); + }); + + it("does not decrease saved progress when tracker re-initializes", async () => { + setReadingProgress(TEST_CONTENT_ID, 50); + + const wrapper = mountTracker(4); + await flushPromises(); + await nextTick(); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("seeds confirmed blocks using the same rounding as saved progress", async () => { + setReadingProgress(TEST_CONTENT_ID, 33); + + const wrapper = mountTracker(3); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[1], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(67); + wrapper.unmount(); + }); + + it("allows dwell to start after the restore guard window ends", async () => { + const wrapper = await readyScrollableTracker(mountTracker(2, true)); + + const observer = latestObserver(); + const block = observer.elements[0]; + + vi.advanceTimersByTime(300 + READING_RESTORE_GUARD_MS + 50); + observer.trigger(block, true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); +}); diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts new file mode 100644 index 0000000000..bab48cab9c --- /dev/null +++ b/app/src/composables/useReadingProgressTracker.ts @@ -0,0 +1,456 @@ +import { computed, nextTick, onUnmounted, ref, watch, type Ref } from "vue"; +import { useEventListener, useIntersectionObserver, type MaybeElement } from "@vueuse/core"; +import { + getReadingProgress, + removeReadingProgress, + setReadingProgress, +} from "@/globalConfig"; +import { computeBlockDwellMs, countWords } from "@/util/readingTime"; +import type { Uuid } from "luminary-shared"; + +/** + * Tracks article reading progress for the "Continue reading" homepage row. + * + * Uses per-block dwell time (scaled by word count and language reading speed), + * plus scroll velocity gating so users who skim through an article are not + * recorded as having read it. + */ + +const BLOCK_SELECTOR = "p, h1, h2, h3, h4, li, blockquote, pre"; + +/** Block must be at least this fraction visible within the scroll root. */ +export const READING_INTERSECTION_RATIO = 0.5; + +/** Above this scroll speed (px/s), dwell timers are paused — user is skimming. */ +export const READING_MAX_SCROLL_VELOCITY_PX_S = 1200; + +/** Ignore velocity samples shorter than this (trackpad / layout jitter). */ +export const READING_MIN_SCROLL_SAMPLE_MS = 50; + +/** Suppress velocity sampling and dwell while restoreScrollPosition runs. */ +export const READING_RESTORE_GUARD_MS = 400; + +export function computeScrollVelocity(deltaY: number, deltaMs: number): number { + if (deltaMs < READING_MIN_SCROLL_SAMPLE_MS) return 0; + return Math.abs(deltaY) / (deltaMs / 1000); +} + +export type ScrollVelocityState = { + pendingScrollDeltaY: number; + pendingScrollDeltaMs: number; + wasScrollingFast: boolean; +}; + +/** Batch short scroll samples, then decide whether the user is skimming. */ +export function applyScrollVelocitySample( + state: ScrollVelocityState, + deltaY: number, + deltaMs: number, +): { isFast: boolean; justSlowedDown: boolean; state: ScrollVelocityState } { + const next: ScrollVelocityState = { + pendingScrollDeltaY: state.pendingScrollDeltaY + deltaY, + pendingScrollDeltaMs: state.pendingScrollDeltaMs + deltaMs, + wasScrollingFast: state.wasScrollingFast, + }; + + if (next.pendingScrollDeltaMs < READING_MIN_SCROLL_SAMPLE_MS) { + return { isFast: next.wasScrollingFast, justSlowedDown: false, state: next }; + } + + const velocity = computeScrollVelocity( + next.pendingScrollDeltaY, + next.pendingScrollDeltaMs, + ); + next.pendingScrollDeltaY = 0; + next.pendingScrollDeltaMs = 0; + + if (velocity > READING_MAX_SCROLL_VELOCITY_PX_S) { + next.wasScrollingFast = true; + return { isFast: true, justSlowedDown: false, state: next }; + } + + const justSlowedDown = next.wasScrollingFast; + next.wasScrollingFast = false; + return { isFast: false, justSlowedDown, state: next }; +} + +/** Prefer BasePage `
` — it scrolls, not the window. */ +export function resolveArticleScrollContainer(): HTMLElement | Window { + const main = document.querySelector("main"); + if (main instanceof HTMLElement) { + const { overflowY } = getComputedStyle(main); + if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") { + return main; + } + } + const fallback = document.querySelector(".scroll-container, [data-scroll-container]"); + if (fallback instanceof HTMLElement) return fallback; + return window; +} + +function getScrollTop(container: HTMLElement | Window): number { + return container === window ? window.scrollY : (container as HTMLElement).scrollTop; +} + +function isElementVisibleInRoot( + el: Element, + root: HTMLElement | Window, + intersectionRatio = READING_INTERSECTION_RATIO, +): boolean { + const elRect = el.getBoundingClientRect(); + if (elRect.height <= 0) return false; + + const rootRect = + root === window + ? { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth } + : (root as HTMLElement).getBoundingClientRect(); + + const visibleHeight = + Math.min(elRect.bottom, rootRect.bottom) - Math.max(elRect.top, rootRect.top); + return visibleHeight / elRect.height >= intersectionRatio; +} + +export function useReadingProgressTracker(options: { + contentId: Ref; + articleRoot: Ref; + scrollContainer: Ref; + enabled: Ref; + /** Language averageReadingSpeed (words per minute); defaults to 200 when unset. */ + averageReadingSpeed: Ref; +}) { + const blocks = ref([]); + const confirmedBlocks = new Set(); + const visibleBlocks = new Set(); + const dwellAccumulatedMs = new Map(); + let lastSavedProgress = -1; + let lastScrollY = 0; + let lastScrollTime = 0; + let scrollVelocityState: ScrollVelocityState = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: false, + }; + let scrollRafPending = false; + let dwellRafId: number | null = null; + let lastDwellFrameTime = 0; + let trackedContentId: Uuid | undefined; + + const isRestoring = ref(false); + + const observerRoot = computed(() => + options.scrollContainer.value === window + ? null + : (options.scrollContainer.value as HTMLElement), + ); + + function stopDwellLoop() { + if (dwellRafId != null) { + cancelAnimationFrame(dwellRafId); + dwellRafId = null; + } + lastDwellFrameTime = 0; + } + + function clearDwellAccumulation() { + dwellAccumulatedMs.clear(); + } + + function resetTrackingState() { + stopDwellLoop(); + clearDwellAccumulation(); + confirmedBlocks.clear(); + visibleBlocks.clear(); + lastSavedProgress = -1; + lastScrollY = 0; + lastScrollTime = 0; + scrollVelocityState = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: false, + }; + scrollRafPending = false; + isRestoring.value = false; + } + + function collectBlocks() { + if (!options.articleRoot.value) { + blocks.value = []; + return; + } + + blocks.value = Array.from( + options.articleRoot.value.querySelectorAll(BLOCK_SELECTOR), + ).filter((el) => el.textContent?.trim()) as MaybeElement[]; + } + + /** Restore in-memory confirmed set from saved % so progress never drops on re-setup. */ + function seedConfirmedFromSavedProgress() { + const id = options.contentId.value; + if (!id || blocks.value.length === 0) return; + + const saved = getReadingProgress(id); + if (saved <= 0) return; + + if (saved >= 100) { + for (const el of blocks.value) { + if (el instanceof Element) confirmedBlocks.add(el); + } + lastSavedProgress = 100; + return; + } + + const count = Math.round((saved / 100) * blocks.value.length); + for (let i = 0; i < count && i < blocks.value.length; i++) { + const el = blocks.value[i]; + if (el instanceof Element) confirmedBlocks.add(el); + } + lastSavedProgress = saved; + } + + function persistProgress() { + const id = options.contentId.value; + if (!id || blocks.value.length === 0) return; + + const computedProgress = Math.round((confirmedBlocks.size / blocks.value.length) * 100); + const existing = getReadingProgress(id); + const progress = Math.max(existing, computedProgress); + + if (progress === lastSavedProgress) return; + lastSavedProgress = progress; + + if (progress >= 100) { + removeReadingProgress(id); + } else if (progress > 0) { + setReadingProgress(id, progress); + } + } + + function markBlockRead(el: MaybeElement) { + if (!el || confirmedBlocks.has(el)) return; + confirmedBlocks.add(el); + persistProgress(); + } + + function blockDwellMs(el: MaybeElement): number { + if (!(el instanceof Element)) return computeBlockDwellMs(0); + const words = countWords(el.textContent ?? ""); + return computeBlockDwellMs(words, options.averageReadingSpeed.value); + } + + function cancelDwell(el: MaybeElement) { + dwellAccumulatedMs.delete(el); + } + + function tickDwell(timestamp: number) { + dwellRafId = requestAnimationFrame(tickDwell); + + if (!options.enabled.value || isRestoring.value || scrollVelocityState.wasScrollingFast) { + lastDwellFrameTime = timestamp; + return; + } + + if (lastDwellFrameTime === 0) { + lastDwellFrameTime = timestamp; + return; + } + + const elapsed = timestamp - lastDwellFrameTime; + lastDwellFrameTime = timestamp; + if (elapsed <= 0) return; + + for (const el of visibleBlocks) { + if (!el || confirmedBlocks.has(el)) continue; + + const requiredMs = blockDwellMs(el); + const accumulated = (dwellAccumulatedMs.get(el) ?? 0) + elapsed; + + if (accumulated >= requiredMs) { + dwellAccumulatedMs.delete(el); + markBlockRead(el); + } else { + dwellAccumulatedMs.set(el, accumulated); + } + } + } + + function ensureDwellLoop() { + if (dwellRafId == null && options.enabled.value) { + if (lastDwellFrameTime === 0) { + lastDwellFrameTime = performance.now(); + } + dwellRafId = requestAnimationFrame(tickDwell); + } + } + + function startDwellIfEligible(el: MaybeElement) { + if (!el || confirmedBlocks.has(el)) return; + if (!visibleBlocks.has(el) || isRestoring.value || scrollVelocityState.wasScrollingFast) return; + ensureDwellLoop(); + } + + function restartDwellForVisibleBlocksAfterSpeedChange() { + clearDwellAccumulation(); + ensureDwellLoop(); + } + + function cancelDwellForVisibleBlocks() { + for (const el of visibleBlocks) { + cancelDwell(el); + } + } + + function onFastScrollDetected() { + clearDwellAccumulation(); + scrollVelocityState.wasScrollingFast = true; + } + + function onScroll() { + if (!options.enabled.value || isRestoring.value) return; + + const container = options.scrollContainer.value; + const scrollY = getScrollTop(container); + const now = performance.now(); + + if (lastScrollTime > 0) { + const { isFast, justSlowedDown, state } = applyScrollVelocitySample( + scrollVelocityState, + scrollY - lastScrollY, + now - lastScrollTime, + ); + scrollVelocityState = state; + + if (isFast) { + onFastScrollDetected(); + } else if (justSlowedDown) { + if (!scrollRafPending) { + scrollRafPending = true; + requestAnimationFrame(() => { + scrollRafPending = false; + ensureDwellLoop(); + }); + } + } + } + + lastScrollY = scrollY; + lastScrollTime = now; + } + + function handleIntersection(entries: IntersectionObserverEntry[]) { + for (const entry of entries) { + const el = entry.target as MaybeElement; + const visible = + entry.isIntersecting && entry.intersectionRatio >= READING_INTERSECTION_RATIO; + + if (visible) { + visibleBlocks.add(el); + startDwellIfEligible(el); + } else { + visibleBlocks.delete(el); + cancelDwell(el); + } + } + } + + const { stop: stopObserver } = useIntersectionObserver(blocks, handleIntersection, { + root: observerRoot, + threshold: [0, READING_INTERSECTION_RATIO, 1], + }); + + useEventListener(options.scrollContainer, "scroll", onScroll, { passive: true }); + + function restoreScrollPosition() { + const id = options.contentId.value; + if (!id) return; + + const percent = getReadingProgress(id); + if (!percent || percent >= 100) return; + + setTimeout(() => { + const container = options.scrollContainer.value; + const maxScroll = + container === window + ? document.documentElement.scrollHeight - window.innerHeight + : (container as HTMLElement).scrollHeight - + (container as HTMLElement).clientHeight; + + if (maxScroll <= 0) return; + + const targetY = Math.round((percent / 100) * maxScroll); + + isRestoring.value = true; + cancelDwellForVisibleBlocks(); + + if (container === window) { + window.scrollTo({ top: targetY }); + } else { + const el = container as HTMLElement; + if (typeof el.scrollTo === "function") { + el.scrollTo({ top: targetY }); + } else { + el.scrollTop = targetY; + } + } + + setTimeout(() => { + isRestoring.value = false; + lastScrollTime = 0; + scrollVelocityState = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: false, + }; + lastScrollY = getScrollTop(container); + }, READING_RESTORE_GUARD_MS); + }, 300); + } + + function setup(contentChanged: boolean) { + collectBlocks(); + + if (!options.enabled.value || blocks.value.length === 0) return; + + if (contentChanged) { + seedConfirmedFromSavedProgress(); + restoreScrollPosition(); + } + } + + watch(options.averageReadingSpeed, () => { + if (!options.enabled.value) return; + restartDwellForVisibleBlocksAfterSpeedChange(); + }); + + watch( + [options.enabled, options.contentId, options.articleRoot, options.scrollContainer], + ([enabled, id], oldValues) => { + if (!enabled) { + resetTrackingState(); + blocks.value = []; + trackedContentId = undefined; + return; + } + + const prevId = oldValues?.[1] as Uuid | undefined; + const contentChanged = + oldValues !== undefined && prevId !== undefined && id !== prevId; + + if (contentChanged || trackedContentId !== id) { + resetTrackingState(); + trackedContentId = id; + nextTick(() => setup(true)); + return; + } + + nextTick(() => setup(false)); + }, + { flush: "post", immediate: true }, + ); + + onUnmounted(() => { + resetTrackingState(); + stopObserver(); + }); + + return { blocks, restoreScrollPosition, setup }; +} diff --git a/app/vitest.setup.ts b/app/vitest.setup.ts index 2150b760cd..1dcba57292 100644 --- a/app/vitest.setup.ts +++ b/app/vitest.setup.ts @@ -114,6 +114,23 @@ class MockResizeObserver { } window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; +class MockIntersectionObserver { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds: number[] = []; + + constructor(_callback: IntersectionObserverCallback) {} + + observe() {} + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} +window.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver; + window.matchMedia = vi.fn().mockImplementation((query) => { return { matches: false, From 954b48629071b4ff35bed2b80e278e56a699960a Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:41 +0100 Subject: [PATCH 14/46] Wire reading progress tracking into SingleContent text articles. Replace scroll-depth tracking with the dwell-based composable on the prose body and restore saved scroll position on return visits. --- app/src/pages/SingleContent/SingleContent.vue | 139 +++++------------- .../__tests__/SingleContent.spec.ts | 12 +- 2 files changed, 45 insertions(+), 106 deletions(-) diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index 0784e71323..dd2ef2a0c8 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -38,9 +38,6 @@ import { queryParams, addToMediaQueue, cmsUrl, - setReadingProgress, - getReadingProgress, - removeReadingProgress, } from "@/globalConfig"; import { useNotificationStore } from "@/stores/notification"; import NotFoundPage from "@/pages/NotFoundPage.vue"; @@ -66,6 +63,14 @@ import VideoPlayer from "@/components/content/VideoPlayer.vue"; import LHighlightable from "@/components/common/LHighlightable.vue"; import DropdownMenu from "@/components/common/DropdownMenu.vue"; import { markPageReady } from "@/util/renderState"; +import { + computeEstimatedReadingMinutes, + resolveReadingSpeedWpm, +} from "@/util/readingTime"; +import { + resolveArticleScrollContainer, + useReadingProgressTracker, +} from "@/composables/useReadingProgressTracker"; const router = useRouter(); @@ -470,110 +475,44 @@ watch( ); const openedFromExternalLink = ref(false); - -const scrollPosition = ref(0); -const maxScrollPosition = ref(0); +const articleProseRef = ref(null); const scrollContainer = ref(window); -let ticking = false; - -const storedScrollKey = computed(() => `scrollPosition-${content.value?._id}`); -const restoreScrollPosition = () => { - if (!content.value) return; - const percent = getReadingProgress(content.value._id); - if (percent && percent < 100) { - setTimeout(() => { - const maxScroll = - scrollContainer.value === window - ? document.documentElement.scrollHeight - window.innerHeight - : (scrollContainer.value as HTMLElement).scrollHeight - - (scrollContainer.value as HTMLElement).clientHeight; - - const targetY = Math.round((percent / 100) * maxScroll); - - if (scrollContainer.value === window) { - window.scrollTo({ top: targetY }); - } else { - (scrollContainer.value as HTMLElement).scrollTo({ top: targetY }); - } - - maxScrollPosition.value = targetY; - }, 300); - } -}; - -const updateScrollPosition = () => { - const scrollY = - scrollContainer.value === window - ? window.scrollY - : (scrollContainer.value as HTMLElement).scrollTop; +const readingTrackerEnabled = computed( + () => !!content.value?._id && !content.value?.video && !!content.value?.text, +); - scrollPosition.value = scrollY; +const contentId = computed(() => content.value?._id); - if (scrollY > maxScrollPosition.value) { - maxScrollPosition.value = scrollY; - } - - if (!ticking) { - window.requestAnimationFrame(() => { - if (content.value && content.value._id) { - const maxScroll = - scrollContainer.value === window - ? document.documentElement.scrollHeight - window.innerHeight - : (scrollContainer.value as HTMLElement).scrollHeight - - (scrollContainer.value as HTMLElement).clientHeight; - - const percentScrolled = - maxScroll > 0 ? (maxScrollPosition.value / maxScroll) * 100 : 0; - const rounded = Math.round(percentScrolled); - - if (rounded < 100) { - setReadingProgress(content.value._id, rounded); - localStorage.setItem(storedScrollKey.value, maxScrollPosition.value.toString()); - } else { - removeReadingProgress(content.value._id); - localStorage.removeItem(storedScrollKey.value); - } - } - ticking = false; - }); - ticking = true; - } -}; +const contentLanguage = computed(() => + languages.value.find((l) => l._id === content.value?.language), +); -const setScrollContainer = () => { - const basePage = document.querySelector("main, .scroll-container, [data-scroll-container]"); - scrollContainer.value = - basePage && basePage.scrollHeight > basePage.clientHeight - ? (basePage as HTMLElement) - : window; -}; +const averageReadingSpeed = computed(() => + resolveReadingSpeedWpm(contentLanguage.value?.averageReadingSpeed), +); -const addScrollListener = () => { - scrollContainer.value.addEventListener("scroll", updateScrollPosition, { passive: true }); -}; +function setScrollContainer() { + scrollContainer.value = resolveArticleScrollContainer(); +} -const removeScrollListener = () => { - scrollContainer.value.removeEventListener("scroll", updateScrollPosition); -}; +useReadingProgressTracker({ + contentId, + articleRoot: articleProseRef, + scrollContainer, + enabled: readingTrackerEnabled, + averageReadingSpeed, +}); onMounted(() => { openedFromExternalLink.value = isExternalNavigation(); setScrollContainer(); - addScrollListener(); - restoreScrollPosition(); - - watch(content, () => { - removeScrollListener(); - setScrollContainer(); - addScrollListener(); - maxScrollPosition.value = 0; - restoreScrollPosition(); - }); }); -onUnmounted(() => { - removeScrollListener(); +watch([isLoading, text], () => { + if (!isLoading.value && text.value) { + nextTick(setScrollContainer); + } }); // Track whether the user explicitly switched language via the quick selector @@ -682,14 +621,9 @@ const playAudio = () => { } }; -const readingTime = computed(() => { - if (!content.value) return 0; - const wordCount = content.value.wordCount!; - const currentLanguage = languages.value.find((l) => l._id === content.value?.language); - const readingSpeed = currentLanguage?.averageReadingSpeed || 200; - - return Math.ceil(wordCount / readingSpeed); -}); +const readingTime = computed(() => + computeEstimatedReadingMinutes(content.value?.wordCount ?? 0, averageReadingSpeed.value), +); watch([isLoading, content, is404], async () => { if (is404.value) { @@ -984,6 +918,7 @@ watch([isLoading, content, is404], async () => { :content-id="content._id" >
{ it("can calculate the estimated reading time using a custom language reading speed", async () => { const wordCount = 400; - const readingSpeed = 100; - const expectedReadingTime = Math.ceil(wordCount / readingSpeed); + const readingSpeed = 200; + const expectedReadingTime = computeEstimatedReadingMinutes(wordCount, readingSpeed); isConnected.value = false; @@ -682,7 +683,7 @@ describe("SingleContent", () => { }); }); - it("restores reading progress", async () => { + it("restores reading progress without overwriting saved progress on mount", async () => { setReadingProgress(mockEnglishContentDto._id, 60); const wrapper = mount(SingleContent, { @@ -692,9 +693,12 @@ describe("SingleContent", () => { }); await waitForExpect(() => { - expect(getReadingProgress(mockEnglishContentDto._id)).toBeGreaterThanOrEqual(60); + expect(wrapper.text()).toContain(mockEnglishContentDto.title); }); + // Dwell-based tracking should not reset pre-existing progress before the user reads + expect(getReadingProgress(mockEnglishContentDto._id)).toBe(60); + wrapper.unmount(); }); }); From f52779a83d0678a91963834844feccead4dd79bb Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:51 +0100 Subject: [PATCH 15/46] Read Continue Reading rows from saved article progress. Show in-progress text articles from localStorage instead of recomputing eligibility from scroll position alone. --- .../components/HomePage/ContinueReading.vue | 71 +++++-------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/app/src/components/HomePage/ContinueReading.vue b/app/src/components/HomePage/ContinueReading.vue index e4824e2769..012b68623a 100644 --- a/app/src/components/HomePage/ContinueReading.vue +++ b/app/src/components/HomePage/ContinueReading.vue @@ -1,5 +1,4 @@ From 28c91b8f25a4ca1d99004abf1dffcdfa3d796124 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 13:22:51 +0100 Subject: [PATCH 16/46] Document dwell-based reading progress tracking. Add a written guide and diagram for how visibility, scroll velocity, and per-block dwell combine to power the Continue Reading row. --- docs/reading-progress-tracker.drawio.svg | 1 + docs/reading-progress-tracker.md | 133 +++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 docs/reading-progress-tracker.drawio.svg create mode 100644 docs/reading-progress-tracker.md diff --git a/docs/reading-progress-tracker.drawio.svg b/docs/reading-progress-tracker.drawio.svg new file mode 100644 index 0000000000..26350c7fd0 --- /dev/null +++ b/docs/reading-progress-tracker.drawio.svg @@ -0,0 +1 @@ +
When does a paragraph count as "read"?
When does a paragraph count as "read"?
Both gates must pass. Fast scroll pauses dwell and clears partial progress for all visible blocks.
Both gates must pass. Fast scroll pauses dwell and clears partial progress for all visible blocks.
One paragraph block
(p, heading, li, …)
One paragraph block...
GATE 1 — Visibility (IntersectionObserver)
GATE 1 — Visibility (IntersectionObserver)
Is ≥ 50% of the
paragraph visible
in the scroll area?
Is ≥ 50% of the...
YES → block enters visible set;
dwell accumulates via rAF if scroll is slow
YES → block enters visible set;...
NO → discard partial dwell
(scrolled past or not yet seen)
NO → discard partial dwell...
GATE 2 — Scroll velocity (scroll listener)
GATE 2 — Scroll velocity (scroll listener)
Is scroll speed
≤ 1200 px/s ?

batch samples ≥ 50 ms
velocity = |ΣΔy| / ΣΔt
Is scroll speed...
YES → dwell ms added each frame
(until block threshold reached)
YES → dwell ms added each frame...
NO → pause dwell + clear accumulation
(user is flinging / skimming)
NO → pause dwell + clear accumulation...
AND
Both gates OK
for full dwell
AND...
✓ Block marked READ
progress % updated in localStorage
✓ Block marked READ...
Skimming → NOT read
Skimming → NOT read
• Fast fling (velocity > 1200 px/s)
• Block leaves view before dwell completes
• Partial dwell cleared → block not confirmed
• Fast fling (velocity > 1200 px/s)...
Reading → counts
Reading → counts
• Slow scroll, block stays ≥ 50% visible
• rAF loop adds ms until block threshold
• Block added to confirmed set → % saved
• Slow scroll, block stays ≥ 50% visible...
On return visit: saved scroll position is restored with a 400 ms guard — programmatic scroll does not count as reading.
On return visit: saved scroll position is restored with a 400 ms guard — programmatic...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/reading-progress-tracker.md b/docs/reading-progress-tracker.md new file mode 100644 index 0000000000..6c2ca6c304 --- /dev/null +++ b/docs/reading-progress-tracker.md @@ -0,0 +1,133 @@ +# Continue Reading — reading progress tracker + +The app saves **in-progress text articles** so the homepage can show a **Continue Reading** row. Progress must reflect articles the user is actually reading, not articles they fling past. + +Visual overview: open [`reading-progress-tracker.drawio`](reading-progress-tracker.drawio) in [draw.io](https://app.diagrams.net/) (three tabs: Overview, Two gates, Components). + +## Problem we solved + +**Old approach:** save scroll depth (how far down the page the user scrolled). + +That breaks when someone scrolls quickly to the bottom — the article looks 100% read even though they never read it. + +**Current approach:** count individual **content blocks** as read only when visibility and scroll-speed gates both pass. + +## Where tracking starts + +Tracking runs only on **SingleContent** text articles (no video, must have `content.text`). + +The tracker watches `articleProseRef` — the `
` that renders the CMS article body. + +**Included in progress** + +- Paragraphs, headings, list items, blockquotes, and code blocks inside the article HTML + (`p`, `h1`–`h4`, `li`, `blockquote`, `pre`) + +**Not included** + +- Page title (`content.title`) +- Hero image +- Summary, author, reading time, publish date +- Category tags +- Copyright footer + +So progress begins at the **first block inside the article body**, not at the page title. If the CMS HTML starts with an `

`, that heading is the first tracked block. + +## End-to-end flow + +1. User opens a text article on **SingleContent**. +2. `useReadingProgressTracker` collects blocks from the prose root and observes them. +3. When a block passes both gates, it is added to a **confirmed** set. +4. Progress `%` = `confirmed blocks ÷ total blocks`, rounded. +5. `%` is saved to `localStorage` via **globalConfig** (`readingProgress` key). +6. **ContinueReading** on the homepage reads that list and shows matching published articles. + +At **100%**, the entry is **removed** from storage (article is considered finished, not “in progress”). + +## The two gates + +A block is marked read only when **both** conditions are met at the same time. + +### Gate 1 — Visibility (IntersectionObserver) + +- At least **50%** of the block must be visible inside the scroll container (`READING_INTERSECTION_RATIO = 0.5`). +- Scroll root is `
` when it scrolls (see `resolveArticleScrollContainer()`), not the window. +- Block leaves the viewport → partial dwell for that block is discarded. + +### Gate 2 — Scroll velocity (scroll listener) + +- Dwell time **only accumulates while scroll speed is ≤ 1200 px/s** (`READING_MAX_SCROLL_VELOCITY_PX_S`). +- Above that threshold the user is treated as skimming: + - dwell **stops accumulating** + - **all partial dwell** for visible blocks is **cleared** +- When scrolling slows again, dwell starts fresh from zero for still-visible blocks. + +Velocity is computed from **batched** scroll samples: individual events shorter than **50 ms** are combined before measuring speed (`READING_MIN_SCROLL_SAMPLE_MS`). That avoids missing fast trackpad flings between high-frequency events. + +## How dwell time works + +Dwell is **not** a wall-clock `setTimeout`. It is **accumulated milliseconds** added on each animation frame while both gates pass. + +Per-block threshold from `computeBlockDwellMs()` in `app/src/util/readingTime.ts`: + +``` +dwellMs = (blockWordCount ÷ languageWPM) × 60 000 +clamped to 500 ms … 8 000 ms +``` + +- **WPM** comes from the content language’s `averageReadingSpeed`, default **200** when unset. +- Short blocks still need at least **500 ms** of effective dwell. +- Very long blocks cap at **8 s**. + +When accumulated dwell for a block reaches its threshold, the block is confirmed and progress is persisted (if the percentage increased). + +## Progress persistence rules + +- Stored shape: `[{ contentId, progress }, …]` in `localStorage.readingProgress`. +- Progress **never decreases** for a given article (`Math.max(existing, computed)`). +- On re-open, saved `%` **seeds** the confirmed set (same `Math.round` as when saving) so progress does not drop if the tracker re-initializes. +- Saved `%` is also used to **scroll** the user back near where they left off. + +## Return visit: scroll restore + +When the user reopens an in-progress article: + +1. After a **300 ms** delay, the scroll container jumps to `progress%` of max scroll. +2. For **400 ms** after that (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the programmatic scroll does not count as reading. + +## When tracking is disabled + +The composable is **off** when: + +- Content has a **video** (video posts use a different UX) +- Content has **no text** +- Content id is missing + +## Key files + +| File | Role | +|------|------| +| `app/src/pages/SingleContent/SingleContent.vue` | Wires tracker to prose root, scroll container, language WPM | +| `app/src/composables/useReadingProgressTracker.ts` | Visibility + velocity gates, dwell loop, restore | +| `app/src/util/readingTime.ts` | WPM resolution, word count, dwell math, UI reading-time estimate | +| `app/src/globalConfig.ts` | Read/write `localStorage.readingProgress` | +| `app/src/components/HomePage/ContinueReading.vue` | Homepage row from saved progress | + +## Constants (quick reference) + +| Constant | Value | Meaning | +|----------|-------|---------| +| `READING_INTERSECTION_RATIO` | `0.5` | Block must be half visible | +| `READING_MAX_SCROLL_VELOCITY_PX_S` | `1200` | Above this = skimming | +| `READING_MIN_SCROLL_SAMPLE_MS` | `50` | Batch scroll events before velocity check | +| `READING_RESTORE_GUARD_MS` | `400` | Ignore tracking after programmatic restore | +| `READING_MIN_DWELL_MS` | `500` | Minimum dwell per block | +| `READING_MAX_DWELL_MS` | `8000` | Maximum dwell per block | +| `DEFAULT_READING_SPEED_WPM` | `200` | Fallback when language has no WPM | + +## Tests + +Unit tests live in: + +- `app/src/composables/useReadingProgressTracker.spec.ts` +- `app/src/util/readingTime.spec.ts` From 89b2963c37d747bbe5f320d92b6bc1f7f5a64329 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 18:40:38 +0100 Subject: [PATCH 17/46] Refactor MockIntersectionObserver constructor and clean up imports in reading progress tracker files. Update state declaration to use const for better clarity. --- .../useReadingProgressTracker.spec.ts | 4 ++-- .../composables/useReadingProgressTracker.ts | 20 +------------------ app/vitest.setup.ts | 2 +- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index 6898a88974..f6b6f47cfe 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick, ref, watch, watchEffect } from "vue"; +import { defineComponent, nextTick, ref, watchEffect } from "vue"; import { mount, flushPromises } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -188,7 +188,7 @@ describe("applyScrollVelocitySample", () => { }); it("reports when scrolling slows after a fast burst", () => { - let state = { + const state = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, wasScrollingFast: true, diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts index bab48cab9c..1a740821ef 100644 --- a/app/src/composables/useReadingProgressTracker.ts +++ b/app/src/composables/useReadingProgressTracker.ts @@ -1,4 +1,4 @@ -import { computed, nextTick, onUnmounted, ref, watch, type Ref } from "vue"; +import { computed, nextTick, onUnmounted, ref, type Ref } from "vue"; import { useEventListener, useIntersectionObserver, type MaybeElement } from "@vueuse/core"; import { getReadingProgress, @@ -92,24 +92,6 @@ function getScrollTop(container: HTMLElement | Window): number { return container === window ? window.scrollY : (container as HTMLElement).scrollTop; } -function isElementVisibleInRoot( - el: Element, - root: HTMLElement | Window, - intersectionRatio = READING_INTERSECTION_RATIO, -): boolean { - const elRect = el.getBoundingClientRect(); - if (elRect.height <= 0) return false; - - const rootRect = - root === window - ? { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth } - : (root as HTMLElement).getBoundingClientRect(); - - const visibleHeight = - Math.min(elRect.bottom, rootRect.bottom) - Math.max(elRect.top, rootRect.top); - return visibleHeight / elRect.height >= intersectionRatio; -} - export function useReadingProgressTracker(options: { contentId: Ref; articleRoot: Ref; diff --git a/app/vitest.setup.ts b/app/vitest.setup.ts index 1dcba57292..ac0e694628 100644 --- a/app/vitest.setup.ts +++ b/app/vitest.setup.ts @@ -119,7 +119,7 @@ class MockIntersectionObserver { readonly rootMargin = ""; readonly thresholds: number[] = []; - constructor(_callback: IntersectionObserverCallback) {} + constructor() {} observe() {} unobserve() {} From 5d0bd5e4a3e90c8c5cbff3811bc8d1ad6dace5fd Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 8 Jun 2026 18:53:37 +0100 Subject: [PATCH 18/46] Enhance reading progress tracker by adding a watch function for improved reactivity. Clean up code formatting for better readability. --- app/src/composables/useReadingProgressTracker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts index 1a740821ef..377472fa93 100644 --- a/app/src/composables/useReadingProgressTracker.ts +++ b/app/src/composables/useReadingProgressTracker.ts @@ -1,4 +1,4 @@ -import { computed, nextTick, onUnmounted, ref, type Ref } from "vue"; +import { computed, nextTick, onUnmounted, ref, watch, type Ref } from "vue"; import { useEventListener, useIntersectionObserver, type MaybeElement } from "@vueuse/core"; import { getReadingProgress, @@ -47,6 +47,7 @@ export function applyScrollVelocitySample( deltaY: number, deltaMs: number, ): { isFast: boolean; justSlowedDown: boolean; state: ScrollVelocityState } { + const next: ScrollVelocityState = { pendingScrollDeltaY: state.pendingScrollDeltaY + deltaY, pendingScrollDeltaMs: state.pendingScrollDeltaMs + deltaMs, From b1a605e695c4d4ea973956224377093af0df8213 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Tue, 9 Jun 2026 19:01:22 +0100 Subject: [PATCH 19/46] Add WPM-scaled skim cap and idle timeout to reading time helpers. Scale the skim velocity threshold with language WPM and centralize idle and scroll-sample constants used by the tracker. --- app/src/util/readingTime.spec.ts | 14 ++++++++++++++ app/src/util/readingTime.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/src/util/readingTime.spec.ts b/app/src/util/readingTime.spec.ts index 62706f7c5f..978b536a14 100644 --- a/app/src/util/readingTime.spec.ts +++ b/app/src/util/readingTime.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_READING_SPEED_WPM, + READING_BASE_MAX_SCROLL_VELOCITY_PX_S, READING_MAX_DWELL_MS, READING_MIN_DWELL_MS, computeBlockDwellMs, computeEstimatedReadingMinutes, + computeMaxScrollVelocityPxS, countWords, resolveReadingSpeedWpm, } from "./readingTime"; @@ -54,3 +56,15 @@ describe("computeBlockDwellMs", () => { expect(computeBlockDwellMs(2)).toBe(600); }); }); + +describe("computeMaxScrollVelocityPxS", () => { + it("returns the base cap at default WPM", () => { + expect(computeMaxScrollVelocityPxS(200)).toBe(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); + expect(computeMaxScrollVelocityPxS()).toBe(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); + }); + + it("scales the skim cap with language reading speed", () => { + expect(computeMaxScrollVelocityPxS(300)).toBe(1800); + expect(computeMaxScrollVelocityPxS(100)).toBe(600); + }); +}); diff --git a/app/src/util/readingTime.ts b/app/src/util/readingTime.ts index b9f28d9973..8d11d196d2 100644 --- a/app/src/util/readingTime.ts +++ b/app/src/util/readingTime.ts @@ -7,6 +7,15 @@ export const READING_MIN_DWELL_MS = 500; /** Longest block dwell — caps wait time for very long blocks. */ export const READING_MAX_DWELL_MS = 8000; +/** Base skim threshold (px/s) at {@link DEFAULT_READING_SPEED_WPM}. */ +export const READING_BASE_MAX_SCROLL_VELOCITY_PX_S = 1200; + +/** Pause dwell when the user has not scrolled or changed visibility for this long. */ +export const READING_IDLE_MS = 45_000; + +/** Ignore velocity samples shorter than this (trackpad / layout jitter). */ +export const READING_MIN_SCROLL_SAMPLE_MS = 50; + export function resolveReadingSpeedWpm(wordsPerMinute?: number | null): number { if (wordsPerMinute == null || wordsPerMinute <= 0 || Number.isNaN(wordsPerMinute)) { return DEFAULT_READING_SPEED_WPM; @@ -39,3 +48,9 @@ export function computeBlockDwellMs( const ms = Math.round((wordCount / wpm) * 60_000); return Math.min(READING_MAX_DWELL_MS, Math.max(READING_MIN_DWELL_MS, ms)); } + +/** Scale skim scroll cap with language reading speed (200 WPM → 1200 px/s). */ +export function computeMaxScrollVelocityPxS(wordsPerMinute?: number | null): number { + const wpm = resolveReadingSpeedWpm(wordsPerMinute); + return Math.round(READING_BASE_MAX_SCROLL_VELOCITY_PX_S * (wpm / DEFAULT_READING_SPEED_WPM)); +} From 5ee36b67ab282b65d22d77d2ddbf49e21bfabbd8 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Tue, 9 Jun 2026 19:01:25 +0100 Subject: [PATCH 20/46] Tighten reading progress tracker with block-end, WPM skim, and idle gates. Require block bottom in viewport, scale skim detection from language WPM, pause dwell after inactivity, and auto-restore scroll on article reopen. --- .../composables/useReadingProgressTracker.ts | 116 ++++++++++++++++-- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts index 377472fa93..fb70df2980 100644 --- a/app/src/composables/useReadingProgressTracker.ts +++ b/app/src/composables/useReadingProgressTracker.ts @@ -5,7 +5,14 @@ import { removeReadingProgress, setReadingProgress, } from "@/globalConfig"; -import { computeBlockDwellMs, countWords } from "@/util/readingTime"; +import { + READING_BASE_MAX_SCROLL_VELOCITY_PX_S, + READING_IDLE_MS, + READING_MIN_SCROLL_SAMPLE_MS, + computeBlockDwellMs, + computeMaxScrollVelocityPxS, + countWords, +} from "@/util/readingTime"; import type { Uuid } from "luminary-shared"; /** @@ -21,15 +28,54 @@ const BLOCK_SELECTOR = "p, h1, h2, h3, h4, li, blockquote, pre"; /** Block must be at least this fraction visible within the scroll root. */ export const READING_INTERSECTION_RATIO = 0.5; -/** Above this scroll speed (px/s), dwell timers are paused — user is skimming. */ -export const READING_MAX_SCROLL_VELOCITY_PX_S = 1200; +/** @deprecated Use {@link READING_BASE_MAX_SCROLL_VELOCITY_PX_S} or computeMaxScrollVelocityPxS. */ +export const READING_MAX_SCROLL_VELOCITY_PX_S = READING_BASE_MAX_SCROLL_VELOCITY_PX_S; /** Ignore velocity samples shorter than this (trackpad / layout jitter). */ -export const READING_MIN_SCROLL_SAMPLE_MS = 50; +export { READING_MIN_SCROLL_SAMPLE_MS }; /** Suppress velocity sampling and dwell while restoreScrollPosition runs. */ export const READING_RESTORE_GUARD_MS = 400; +/** Subpixel tolerance when comparing block bottom to viewport bottom. */ +export const READING_BLOCK_END_TOLERANCE_PX = 4; + +export type ViewportBounds = { top: number; bottom: number }; + +export function isBlockEndInViewport( + blockBottom: number, + viewport: ViewportBounds, + tolerancePx = READING_BLOCK_END_TOLERANCE_PX, +): boolean { + return ( + blockBottom <= viewport.bottom + tolerancePx && blockBottom >= viewport.top - tolerancePx + ); +} + +export function isBlockEndVisibleInEntry( + entry: Pick, +): boolean { + if (!entry.isIntersecting) return false; + + const rect = entry.boundingClientRect; + const root = entry.rootBounds; + const viewport: ViewportBounds = root + ? { top: root.top, bottom: root.bottom } + : { top: 0, bottom: window.innerHeight }; + + return isBlockEndInViewport(rect.bottom, viewport); +} + +export function isBlockEligibleForDwell( + entry: IntersectionObserverEntry, +): boolean { + return ( + entry.isIntersecting && + entry.intersectionRatio >= READING_INTERSECTION_RATIO && + isBlockEndVisibleInEntry(entry) + ); +} + export function computeScrollVelocity(deltaY: number, deltaMs: number): number { if (deltaMs < READING_MIN_SCROLL_SAMPLE_MS) return 0; return Math.abs(deltaY) / (deltaMs / 1000); @@ -46,8 +92,8 @@ export function applyScrollVelocitySample( state: ScrollVelocityState, deltaY: number, deltaMs: number, + maxVelocityPxS: number = READING_BASE_MAX_SCROLL_VELOCITY_PX_S, ): { isFast: boolean; justSlowedDown: boolean; state: ScrollVelocityState } { - const next: ScrollVelocityState = { pendingScrollDeltaY: state.pendingScrollDeltaY + deltaY, pendingScrollDeltaMs: state.pendingScrollDeltaMs + deltaMs, @@ -65,7 +111,7 @@ export function applyScrollVelocitySample( next.pendingScrollDeltaY = 0; next.pendingScrollDeltaMs = 0; - if (velocity > READING_MAX_SCROLL_VELOCITY_PX_S) { + if (velocity > maxVelocityPxS) { next.wasScrollingFast = true; return { isFast: true, justSlowedDown: false, state: next }; } @@ -108,6 +154,7 @@ export function useReadingProgressTracker(options: { let lastSavedProgress = -1; let lastScrollY = 0; let lastScrollTime = 0; + let lastActivityMs: number | null = null; let scrollVelocityState: ScrollVelocityState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, @@ -120,12 +167,31 @@ export function useReadingProgressTracker(options: { const isRestoring = ref(false); + const maxScrollVelocityPxS = computed(() => + computeMaxScrollVelocityPxS(options.averageReadingSpeed.value), + ); + + const savedProgressPercent = computed(() => { + const id = options.contentId.value; + if (!id) return 0; + return getReadingProgress(id); + }); + + const hasResumableProgress = computed(() => { + const p = savedProgressPercent.value; + return p > 0 && p < 100; + }); + const observerRoot = computed(() => options.scrollContainer.value === window ? null : (options.scrollContainer.value as HTMLElement), ); + function touchActivity(timestamp = performance.now()) { + lastActivityMs = timestamp; + } + function stopDwellLoop() { if (dwellRafId != null) { cancelAnimationFrame(dwellRafId); @@ -146,6 +212,7 @@ export function useReadingProgressTracker(options: { lastSavedProgress = -1; lastScrollY = 0; lastScrollTime = 0; + lastActivityMs = null; scrollVelocityState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, @@ -232,6 +299,13 @@ export function useReadingProgressTracker(options: { return; } + const now = performance.now(); + if (lastActivityMs !== null && now - lastActivityMs >= READING_IDLE_MS) { + clearDwellAccumulation(); + lastDwellFrameTime = timestamp; + return; + } + if (lastDwellFrameTime === 0) { lastDwellFrameTime = timestamp; return; @@ -261,6 +335,9 @@ export function useReadingProgressTracker(options: { if (lastDwellFrameTime === 0) { lastDwellFrameTime = performance.now(); } + if (lastActivityMs === null) { + touchActivity(); + } dwellRafId = requestAnimationFrame(tickDwell); } } @@ -293,12 +370,14 @@ export function useReadingProgressTracker(options: { const container = options.scrollContainer.value; const scrollY = getScrollTop(container); const now = performance.now(); + touchActivity(now); if (lastScrollTime > 0) { const { isFast, justSlowedDown, state } = applyScrollVelocitySample( scrollVelocityState, scrollY - lastScrollY, now - lastScrollTime, + maxScrollVelocityPxS.value, ); scrollVelocityState = state; @@ -320,12 +399,12 @@ export function useReadingProgressTracker(options: { } function handleIntersection(entries: IntersectionObserverEntry[]) { + touchActivity(); for (const entry of entries) { const el = entry.target as MaybeElement; - const visible = - entry.isIntersecting && entry.intersectionRatio >= READING_INTERSECTION_RATIO; + const eligible = isBlockEligibleForDwell(entry); - if (visible) { + if (eligible) { visibleBlocks.add(el); startDwellIfEligible(el); } else { @@ -384,6 +463,7 @@ export function useReadingProgressTracker(options: { wasScrollingFast: false, }; lastScrollY = getScrollTop(container); + touchActivity(); }, READING_RESTORE_GUARD_MS); }, 300); } @@ -391,12 +471,17 @@ export function useReadingProgressTracker(options: { function setup(contentChanged: boolean) { collectBlocks(); - if (!options.enabled.value || blocks.value.length === 0) return; + if (!options.enabled.value) return; if (contentChanged) { - seedConfirmedFromSavedProgress(); + if (blocks.value.length > 0) { + seedConfirmedFromSavedProgress(); + } + touchActivity(); restoreScrollPosition(); } + + if (blocks.value.length === 0) return; } watch(options.averageReadingSpeed, () => { @@ -435,5 +520,12 @@ export function useReadingProgressTracker(options: { stopObserver(); }); - return { blocks, restoreScrollPosition, setup }; + return { + blocks, + isRestoring, + savedProgressPercent, + hasResumableProgress, + restoreScrollPosition, + setup, + }; } From 18365779116bf802725e20596f0ee26970062e93 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Tue, 9 Jun 2026 19:01:29 +0100 Subject: [PATCH 21/46] Extend reading progress tracker tests for new gates and auto-restore. Cover block-end eligibility, WPM-scaled velocity, idle pause, no-scroll progress, and scroll restore on mount. --- .../useReadingProgressTracker.spec.ts | 224 ++++++++++++++++-- .../__tests__/SingleContent.spec.ts | 4 +- 2 files changed, 199 insertions(+), 29 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index f6b6f47cfe..978a4b59a2 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -7,11 +7,16 @@ import { READING_RESTORE_GUARD_MS, applyScrollVelocitySample, computeScrollVelocity, + isBlockEndInViewport, + isBlockEligibleForDwell, useReadingProgressTracker, } from "./useReadingProgressTracker"; import { DEFAULT_READING_SPEED_WPM, + READING_BASE_MAX_SCROLL_VELOCITY_PX_S, + READING_IDLE_MS, computeBlockDwellMs, + computeMaxScrollVelocityPxS, countWords, } from "@/util/readingTime"; import { getReadingProgress, removeReadingProgress, setReadingProgress } from "@/globalConfig"; @@ -24,11 +29,33 @@ type MockObserver = { observe: (el: Element) => void; unobserve: (el: Element) => void; disconnect: () => void; - trigger: (el: Element, isIntersecting: boolean, intersectionRatio?: number) => void; + trigger: ( + el: Element, + isIntersecting: boolean, + intersectionRatio?: number, + options?: { blockEndVisible?: boolean }, + ) => void; }; const observerInstances = vi.hoisted(() => [] as MockObserver[]); +function makeRect( + top: number, + bottom: number, +): DOMRectReadOnly { + return { + top, + bottom, + left: 0, + right: 400, + width: 400, + height: bottom - top, + x: 0, + y: top, + toJSON: () => ({}), + } as DOMRectReadOnly; +} + vi.stubGlobal( "IntersectionObserver", vi.fn().mockImplementation((callback: IntersectionObserverCallback) => { @@ -44,13 +71,28 @@ vi.stubGlobal( disconnect() { instance.elements = []; }, - trigger(el: Element, isIntersecting: boolean, intersectionRatio = READING_INTERSECTION_RATIO) { + trigger( + el: Element, + isIntersecting: boolean, + intersectionRatio = READING_INTERSECTION_RATIO, + options?: { blockEndVisible?: boolean }, + ) { + const blockEndVisible = options?.blockEndVisible ?? isIntersecting; + const rootBounds = makeRect(0, 800); + const boundingClientRect = blockEndVisible + ? makeRect(100, 400) + : makeRect(100, 900); + callback( [ { target: el, isIntersecting, intersectionRatio, + boundingClientRect, + rootBounds, + intersectionRect: boundingClientRect, + time: 0, } as IntersectionObserverEntry, ], instance as unknown as IntersectionObserver, @@ -72,6 +114,8 @@ function mountTracker( blockTexts ?? Array.from({ length: blockCount }, (_, i) => `Block ${i + 1}`); + let trackerApi: ReturnType | undefined; + const TestComponent = defineComponent({ setup() { const articleRoot = ref(null); @@ -87,7 +131,7 @@ function mountTracker( } }); - useReadingProgressTracker({ + trackerApi = useReadingProgressTracker({ contentId, articleRoot, scrollContainer, @@ -95,7 +139,7 @@ function mountTracker( averageReadingSpeed: readingSpeed, }); - return { articleRoot, scrollContainerEl, scrollContainer }; + return { articleRoot, scrollContainerEl, scrollContainer, trackerApi }; }, template: scrollable ? ` @@ -116,7 +160,8 @@ function mountTracker( `, }); - return mount(TestComponent); + const wrapper = mount(TestComponent); + return { wrapper, trackerApi: () => trackerApi! }; } function latestObserver() { @@ -124,9 +169,16 @@ function latestObserver() { } let rafTime = 0; +let perfTime = 0; let rafId = 0; +let performanceNowSpy: ReturnType | undefined; const pendingRafCallbacks = new Map(); +function advancePerfTime(ms: number) { + vi.advanceTimersByTime(ms); + perfTime += ms; +} + function flushRafFrame(deltaMs = 16) { rafTime += deltaMs; const callbacks = [...pendingRafCallbacks.values()]; @@ -141,25 +193,54 @@ function advanceDwellMs(ms: number, frameMs = 16, { complete = true }: { complet const dwellFrames = complete ? Math.ceil(ms / frameMs) : Math.floor(ms / frameMs); const rafFrames = 1 + dwellFrames; for (let i = 0; i < rafFrames; i++) { - vi.advanceTimersByTime(frameMs); + advancePerfTime(frameMs); flushRafFrame(frameMs); } } -async function readyScrollableTracker(wrapper: ReturnType) { +async function readyScrollableTracker(mountResult: ReturnType) { await flushPromises(); await nextTick(); await nextTick(); - const scrollEl = wrapper.get('[data-test="scroll-container"]').element as HTMLElement; - const vm = wrapper.vm as { scrollContainer: HTMLElement | Window }; + const scrollEl = mountResult.wrapper.get('[data-test="scroll-container"]').element as HTMLElement; + const vm = mountResult.wrapper.vm as { scrollContainer: HTMLElement | Window }; vm.scrollContainer = scrollEl; await nextTick(); - return wrapper; + return mountResult; } - const BLOCK_ONE_DWELL_MS = computeBlockDwellMs(countWords("Block 1"), DEFAULT_READING_SPEED_WPM); +describe("isBlockEndInViewport", () => { + it("returns true when the block bottom is inside the viewport", () => { + expect(isBlockEndInViewport(400, { top: 0, bottom: 800 })).toBe(true); + }); + + it("returns false when the block bottom is below the viewport", () => { + expect(isBlockEndInViewport(900, { top: 0, bottom: 800 })).toBe(false); + }); +}); + +describe("isBlockEligibleForDwell", () => { + it("requires intersection ratio, visibility, and block end in viewport", () => { + const eligible = isBlockEligibleForDwell({ + isIntersecting: true, + intersectionRatio: READING_INTERSECTION_RATIO, + boundingClientRect: makeRect(100, 400), + rootBounds: makeRect(0, 800), + } as IntersectionObserverEntry); + expect(eligible).toBe(true); + + const endBelow = isBlockEligibleForDwell({ + isIntersecting: true, + intersectionRatio: READING_INTERSECTION_RATIO, + boundingClientRect: makeRect(100, 900), + rootBounds: makeRect(0, 800), + } as IntersectionObserverEntry); + expect(endBelow).toBe(false); + }); +}); + describe("computeScrollVelocity", () => { it("returns px/s from delta and elapsed time", () => { expect(computeScrollVelocity(120, 100)).toBe(1200); @@ -179,7 +260,7 @@ describe("applyScrollVelocitySample", () => { wasScrollingFast: false, }; - state = applyScrollVelocitySample(state, 80, 30).state; + state = applyScrollVelocitySample(state, 10, 30).state; expect(state.wasScrollingFast).toBe(false); const result = applyScrollVelocitySample(state, 80, 30); @@ -199,6 +280,20 @@ describe("applyScrollVelocitySample", () => { expect(result.justSlowedDown).toBe(true); expect(result.state.wasScrollingFast).toBe(false); }); + + it("uses a WPM-scaled velocity cap", () => { + const slowLanguageCap = computeMaxScrollVelocityPxS(100); + expect(slowLanguageCap).toBe(600); + + let state = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + wasScrollingFast: false, + }; + state = applyScrollVelocitySample(state, 40, 30, slowLanguageCap).state; + const result = applyScrollVelocitySample(state, 40, 30, slowLanguageCap); + expect(result.isFast).toBe(true); + }); }); describe("useReadingProgressTracker", () => { @@ -206,6 +301,8 @@ describe("useReadingProgressTracker", () => { observerInstances.length = 0; localStorage.removeItem("readingProgress"); vi.useFakeTimers({ shouldAdvanceTime: true }); + perfTime = 0; + performanceNowSpy = vi.spyOn(performance, "now").mockImplementation(() => perfTime); rafTime = 0; rafId = 0; pendingRafCallbacks.clear(); @@ -222,11 +319,13 @@ describe("useReadingProgressTracker", () => { afterEach(() => { removeReadingProgress(TEST_CONTENT_ID); localStorage.removeItem("readingProgress"); + performanceNowSpy?.mockRestore(); + performanceNowSpy = undefined; vi.useRealTimers(); }); it("does not save progress when a block is visible for less than the dwell time", async () => { - const wrapper = mountTracker(); + const { wrapper } = mountTracker(); await flushPromises(); await nextTick(); @@ -240,8 +339,23 @@ describe("useReadingProgressTracker", () => { wrapper.unmount(); }); - it("saves progress when a block stays visible for the dwell duration", async () => { - const wrapper = mountTracker(2); + it("does not save when the block end is below the viewport", async () => { + const { wrapper } = mountTracker(); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + const block = observer.elements[0]; + observer.trigger(block, true, READING_INTERSECTION_RATIO, { blockEndVisible: false }); + + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + wrapper.unmount(); + }); + + it("saves progress when a block end stays visible for the dwell duration", async () => { + const { wrapper } = mountTracker(2); await flushPromises(); await nextTick(); @@ -256,7 +370,7 @@ describe("useReadingProgressTracker", () => { }); it("cancels dwell timer when a block leaves the viewport before dwell completes", async () => { - const wrapper = mountTracker(); + const { wrapper } = mountTracker(); await flushPromises(); await nextTick(); @@ -273,7 +387,7 @@ describe("useReadingProgressTracker", () => { }); it("reflects confirmed blocks only in progress percentage", async () => { - const wrapper = mountTracker(4); + const { wrapper } = mountTracker(4); await flushPromises(); await nextTick(); @@ -295,7 +409,7 @@ describe("useReadingProgressTracker", () => { const longDwell = computeBlockDwellMs(countWords(longText), DEFAULT_READING_SPEED_WPM); expect(longDwell).toBeGreaterThan(BLOCK_ONE_DWELL_MS); - const wrapper = mountTracker(2, false, DEFAULT_READING_SPEED_WPM, [longText, "Block 2"]); + const { wrapper } = mountTracker(2, false, DEFAULT_READING_SPEED_WPM, [longText, "Block 2"]); await flushPromises(); await nextTick(); @@ -312,11 +426,11 @@ describe("useReadingProgressTracker", () => { it("identifies fast scroll speeds for velocity gating", () => { expect(computeScrollVelocity(5000, 100)).toBeGreaterThan(READING_MAX_SCROLL_VELOCITY_PX_S); - expect(computeScrollVelocity(100, 100)).toBeLessThan(READING_MAX_SCROLL_VELOCITY_PX_S); + expect(computeScrollVelocity(100, 100)).toBeLessThan(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); }); it("saves progress after velocity drops and dwell completes at low speed", async () => { - const wrapper = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper } = await readyScrollableTracker(mountTracker(2, true)); const observer = latestObserver(); const block = observer.elements[0]; @@ -328,15 +442,33 @@ describe("useReadingProgressTracker", () => { wrapper.unmount(); }); + it("auto-restores scroll on mount when saved progress exists", async () => { + setReadingProgress(TEST_CONTENT_ID, 60); + + const { wrapper } = await readyScrollableTracker(mountTracker(2, true)); + const scrollEl = wrapper.get('[data-test="scroll-container"]').element as HTMLElement; + Object.defineProperty(scrollEl, "scrollHeight", { value: 2000, configurable: true }); + Object.defineProperty(scrollEl, "clientHeight", { value: 200, configurable: true }); + const scrollToSpy = vi.fn(); + scrollEl.scrollTo = scrollToSpy; + + advancePerfTime(500); + + expect(scrollToSpy).toHaveBeenCalled(); + wrapper.unmount(); + }); + it("does not overwrite saved progress during the restore scroll guard window", async () => { setReadingProgress(TEST_CONTENT_ID, 60); - const wrapper = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper, trackerApi } = await readyScrollableTracker(mountTracker(2, true)); + + trackerApi().restoreScrollPosition(); const observer = latestObserver(); const block = observer.elements[0]; - vi.advanceTimersByTime(350); + advancePerfTime(350); observer.trigger(block, true); advanceDwellMs(BLOCK_ONE_DWELL_MS); @@ -347,7 +479,7 @@ describe("useReadingProgressTracker", () => { it("does not decrease saved progress when tracker re-initializes", async () => { setReadingProgress(TEST_CONTENT_ID, 50); - const wrapper = mountTracker(4); + const { wrapper } = mountTracker(4); await flushPromises(); await nextTick(); @@ -358,7 +490,7 @@ describe("useReadingProgressTracker", () => { it("seeds confirmed blocks using the same rounding as saved progress", async () => { setReadingProgress(TEST_CONTENT_ID, 33); - const wrapper = mountTracker(3); + const { wrapper } = mountTracker(3); await flushPromises(); await nextTick(); @@ -371,16 +503,56 @@ describe("useReadingProgressTracker", () => { }); it("allows dwell to start after the restore guard window ends", async () => { - const wrapper = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper, trackerApi } = await readyScrollableTracker(mountTracker(2, true)); + + trackerApi().restoreScrollPosition(); const observer = latestObserver(); const block = observer.elements[0]; - vi.advanceTimersByTime(300 + READING_RESTORE_GUARD_MS + 50); + advancePerfTime(300 + READING_RESTORE_GUARD_MS + 50); observer.trigger(block, true); advanceDwellMs(BLOCK_ONE_DWELL_MS); expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); wrapper.unmount(); }); + + it("pauses dwell accumulation after idle timeout", async () => { + const { wrapper } = mountTracker(); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[0], true); + + advanceDwellMs(200, 16, { complete: false }); + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + + advancePerfTime(READING_IDLE_MS + 2000); + flushRafFrame(16); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + + window.dispatchEvent(new Event("scroll")); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); + }); + + it("tracks progress without scrolling when blocks are visible on screen", async () => { + const { wrapper } = mountTracker(3); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[0], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + observer.trigger(observer.elements[1], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(67); + wrapper.unmount(); + }); }); diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts index b689f5fb29..fcf3a41cca 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts @@ -683,7 +683,7 @@ describe("SingleContent", () => { }); }); - it("restores reading progress without overwriting saved progress on mount", async () => { + it("preserves saved reading progress on mount", async () => { setReadingProgress(mockEnglishContentDto._id, 60); const wrapper = mount(SingleContent, { @@ -696,9 +696,7 @@ describe("SingleContent", () => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); }); - // Dwell-based tracking should not reset pre-existing progress before the user reads expect(getReadingProgress(mockEnglishContentDto._id)).toBe(60); - wrapper.unmount(); }); }); From b907a163fdef39ec6efef890588325b85114a62e Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Tue, 9 Jun 2026 19:01:32 +0100 Subject: [PATCH 22/46] Update reading progress tracker docs and diagram for new gates. Document block-end, WPM-scaled skim, idle pause, and auto-restore behavior across the markdown guide and draw.io diagram. --- docs/reading-progress-tracker.drawio.svg | 2 +- docs/reading-progress-tracker.md | 44 ++++++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/reading-progress-tracker.drawio.svg b/docs/reading-progress-tracker.drawio.svg index 26350c7fd0..22a49f6c54 100644 --- a/docs/reading-progress-tracker.drawio.svg +++ b/docs/reading-progress-tracker.drawio.svg @@ -1 +1 @@ -
When does a paragraph count as "read"?
When does a paragraph count as "read"?
Both gates must pass. Fast scroll pauses dwell and clears partial progress for all visible blocks.
Both gates must pass. Fast scroll pauses dwell and clears partial progress for all visible blocks.
One paragraph block
(p, heading, li, …)
One paragraph block...
GATE 1 — Visibility (IntersectionObserver)
GATE 1 — Visibility (IntersectionObserver)
Is ≥ 50% of the
paragraph visible
in the scroll area?
Is ≥ 50% of the...
YES → block enters visible set;
dwell accumulates via rAF if scroll is slow
YES → block enters visible set;...
NO → discard partial dwell
(scrolled past or not yet seen)
NO → discard partial dwell...
GATE 2 — Scroll velocity (scroll listener)
GATE 2 — Scroll velocity (scroll listener)
Is scroll speed
≤ 1200 px/s ?

batch samples ≥ 50 ms
velocity = |ΣΔy| / ΣΔt
Is scroll speed...
YES → dwell ms added each frame
(until block threshold reached)
YES → dwell ms added each frame...
NO → pause dwell + clear accumulation
(user is flinging / skimming)
NO → pause dwell + clear accumulation...
AND
Both gates OK
for full dwell
AND...
✓ Block marked READ
progress % updated in localStorage
✓ Block marked READ...
Skimming → NOT read
Skimming → NOT read
• Fast fling (velocity > 1200 px/s)
• Block leaves view before dwell completes
• Partial dwell cleared → block not confirmed
• Fast fling (velocity > 1200 px/s)...
Reading → counts
Reading → counts
• Slow scroll, block stays ≥ 50% visible
• rAF loop adds ms until block threshold
• Block added to confirmed set → % saved
• Slow scroll, block stays ≥ 50% visible...
On return visit: saved scroll position is restored with a 400 ms guard — programmatic scroll does not count as reading.
On return visit: saved scroll position is restored with a 400 ms guard — programmatic...
Text is not SVG - cannot display
\ No newline at end of file +
When does a paragraph count as "read"?
When does a paragraph count as "read"?
All gates must pass. Fast scroll pauses dwell and clears partial progress; idle (45 s) also pauses dwell.
All gates must pass. Fast scroll pauses dwell and clears partial progress; idle (45 s) also pauses dwell.
One paragraph block
(p, heading, li, …)
One paragraph block...
GATE 1 — Visibility (IntersectionObserver)
GATE 1 — Visibility (IntersectionObserver)
Is ≥ 50% visible AND
block bottom inside
the scroll area?
Is ≥ 50% visible AND block bottom...
YES → block eligible;
dwell accumulates via rAF when scroll is slow
YES → block eligible; dwell via rAF...
NO → discard partial dwell
(scrolled past or not yet seen)
NO → discard partial dwell...
GATE 2 — Scroll velocity (scroll listener)
GATE 2 — Scroll velocity (scroll listener)
Is scroll speed
≤ WPM-scaled cap?
(1200 px/s at 200 WPM)

batch samples ≥ 50 ms
velocity = |ΣΔy| / ΣΔt
Is scroll speed ≤ WPM-scaled cap?...
YES → dwell ms added each frame
(until block threshold reached)
YES → dwell ms added each frame...
NO → pause dwell + clear accumulation
(user is flinging / skimming)
NO → pause dwell + clear accumulation...
AND
All gates OK
for full dwell
AND — All gates OK...
✓ Block marked READ
progress % updated in localStorage
✓ Block marked READ...
Skimming → NOT read
Skimming → NOT read
• Fast fling (velocity > WPM-scaled cap)
• Block leaves view before dwell completes
• Idle 45 s → dwell paused, partial cleared
• Fast fling (WPM-scaled cap)...
Reading → counts
Reading → counts
• Block end visible, slow scroll
• rAF loop adds ms until block threshold
• Block added to confirmed set → % saved
• Block end visible, slow scroll...
On return visit: auto-restore scroll after 300 ms; 400 ms restore guard — programmatic scroll does not count as reading.
On return visit: auto-restore after 300 ms; 400 ms guard — programmatic...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/reading-progress-tracker.md b/docs/reading-progress-tracker.md index 6c2ca6c304..bc338e39e6 100644 --- a/docs/reading-progress-tracker.md +++ b/docs/reading-progress-tracker.md @@ -2,7 +2,7 @@ The app saves **in-progress text articles** so the homepage can show a **Continue Reading** row. Progress must reflect articles the user is actually reading, not articles they fling past. -Visual overview: open [`reading-progress-tracker.drawio`](reading-progress-tracker.drawio) in [draw.io](https://app.diagrams.net/) (three tabs: Overview, Two gates, Components). +Visual overview: open [`reading-progress-tracker.drawio.svg`](reading-progress-tracker.drawio.svg) in [draw.io](https://app.diagrams.net/) (three tabs: Overview, Reading gates, Components). ## Problem we solved @@ -10,7 +10,7 @@ Visual overview: open [`reading-progress-tracker.drawio`](reading-progress-track That breaks when someone scrolls quickly to the bottom — the article looks 100% read even though they never read it. -**Current approach:** count individual **content blocks** as read only when visibility and scroll-speed gates both pass. +**Current approach:** count individual **content blocks** as read only when visibility, block-end, scroll-speed, and dwell gates all pass. ## Where tracking starts @@ -37,36 +37,46 @@ So progress begins at the **first block inside the article body**, not at the pa 1. User opens a text article on **SingleContent**. 2. `useReadingProgressTracker` collects blocks from the prose root and observes them. -3. When a block passes both gates, it is added to a **confirmed** set. +3. When a block passes all gates, it is added to a **confirmed** set. 4. Progress `%` = `confirmed blocks ÷ total blocks`, rounded. 5. `%` is saved to `localStorage` via **globalConfig** (`readingProgress` key). 6. **ContinueReading** on the homepage reads that list and shows matching published articles. At **100%**, the entry is **removed** from storage (article is considered finished, not “in progress”). -## The two gates +## The gates -A block is marked read only when **both** conditions are met at the same time. +A block is marked read only when **all** conditions below are met. -### Gate 1 — Visibility (IntersectionObserver) +### Gate 1 — Block visibility (IntersectionObserver) -- At least **50%** of the block must be visible inside the scroll container (`READING_INTERSECTION_RATIO = 0.5`). +- At least **50%** of the block must intersect the scroll container (`READING_INTERSECTION_RATIO = 0.5`). - Scroll root is `
` when it scrolls (see `resolveArticleScrollContainer()`), not the window. - Block leaves the viewport → partial dwell for that block is discarded. +### Gate 1b — Block end in viewport + +- The **bottom edge** of the block must be inside the visible scroll area (not below the fold). +- This ensures the user has scrolled through the block, not merely glimpsed the top of a long paragraph. + ### Gate 2 — Scroll velocity (scroll listener) -- Dwell time **only accumulates while scroll speed is ≤ 1200 px/s** (`READING_MAX_SCROLL_VELOCITY_PX_S`). -- Above that threshold the user is treated as skimming: +- Dwell time **only accumulates while scroll speed is at or below** `computeMaxScrollVelocityPxS(languageWPM)`. +- At default **200 WPM**, the cap is **1200 px/s** (`READING_BASE_MAX_SCROLL_VELOCITY_PX_S`); faster languages scale proportionally (e.g. 300 WPM → 1800 px/s). +- Above the threshold the user is treated as skimming: - dwell **stops accumulating** - **all partial dwell** for visible blocks is **cleared** - When scrolling slows again, dwell starts fresh from zero for still-visible blocks. Velocity is computed from **batched** scroll samples: individual events shorter than **50 ms** are combined before measuring speed (`READING_MIN_SCROLL_SAMPLE_MS`). That avoids missing fast trackpad flings between high-frequency events. +### Gate 3 — Idle pause + +- If there is no scroll or intersection activity for **45 s** (`READING_IDLE_MS`), dwell stops accumulating until the user interacts again. + ## How dwell time works -Dwell is **not** a wall-clock `setTimeout`. It is **accumulated milliseconds** added on each animation frame while both gates pass. +Dwell is **not** a wall-clock `setTimeout`. It is **accumulated milliseconds** added on each animation frame while all gates pass. Per-block threshold from `computeBlockDwellMs()` in `app/src/util/readingTime.ts`: @@ -81,12 +91,13 @@ clamped to 500 ms … 8 000 ms When accumulated dwell for a block reaches its threshold, the block is confirmed and progress is persisted (if the percentage increased). +**No-scroll articles:** On desktop, if every block is already visible (short article), dwell still accumulates via the animation-frame loop without any scroll events. + ## Progress persistence rules - Stored shape: `[{ contentId, progress }, …]` in `localStorage.readingProgress`. - Progress **never decreases** for a given article (`Math.max(existing, computed)`). - On re-open, saved `%` **seeds** the confirmed set (same `Math.round` as when saving) so progress does not drop if the tracker re-initializes. -- Saved `%` is also used to **scroll** the user back near where they left off. ## Return visit: scroll restore @@ -95,6 +106,8 @@ When the user reopens an in-progress article: 1. After a **300 ms** delay, the scroll container jumps to `progress%` of max scroll. 2. For **400 ms** after that (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the programmatic scroll does not count as reading. +Opt-in restore UI (banner/prompt) is deferred pending further UX discussion. + ## When tracking is disabled The composable is **off** when: @@ -107,9 +120,9 @@ The composable is **off** when: | File | Role | |------|------| -| `app/src/pages/SingleContent/SingleContent.vue` | Wires tracker to prose root, scroll container, language WPM | -| `app/src/composables/useReadingProgressTracker.ts` | Visibility + velocity gates, dwell loop, restore | -| `app/src/util/readingTime.ts` | WPM resolution, word count, dwell math, UI reading-time estimate | +| `app/src/pages/SingleContent/SingleContent.vue` | Wires tracker on text articles | +| `app/src/composables/useReadingProgressTracker.ts` | Gates, dwell loop, auto restore on open | +| `app/src/util/readingTime.ts` | WPM resolution, dwell math, skim cap scaling | | `app/src/globalConfig.ts` | Read/write `localStorage.readingProgress` | | `app/src/components/HomePage/ContinueReading.vue` | Homepage row from saved progress | @@ -118,9 +131,10 @@ The composable is **off** when: | Constant | Value | Meaning | |----------|-------|---------| | `READING_INTERSECTION_RATIO` | `0.5` | Block must be half visible | -| `READING_MAX_SCROLL_VELOCITY_PX_S` | `1200` | Above this = skimming | +| `READING_BASE_MAX_SCROLL_VELOCITY_PX_S` | `1200` | Skim cap at 200 WPM | | `READING_MIN_SCROLL_SAMPLE_MS` | `50` | Batch scroll events before velocity check | | `READING_RESTORE_GUARD_MS` | `400` | Ignore tracking after programmatic restore | +| `READING_IDLE_MS` | `45000` | Pause dwell after inactivity | | `READING_MIN_DWELL_MS` | `500` | Minimum dwell per block | | `READING_MAX_DWELL_MS` | `8000` | Maximum dwell per block | | `DEFAULT_READING_SPEED_WPM` | `200` | Fallback when language has no WPM | From 7c64112fbb42c68800835a6305d08cbff4735e37 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Wed, 10 Jun 2026 08:17:02 +0100 Subject: [PATCH 23/46] Update performanceNowSpy type in reading progress tracker tests for improved type safety. --- app/src/composables/useReadingProgressTracker.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index 978a4b59a2..6dec750651 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -1,6 +1,6 @@ import { defineComponent, nextTick, ref, watchEffect } from "vue"; import { mount, flushPromises } from "@vue/test-utils"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import { READING_INTERSECTION_RATIO, READING_MAX_SCROLL_VELOCITY_PX_S, @@ -171,7 +171,7 @@ function latestObserver() { let rafTime = 0; let perfTime = 0; let rafId = 0; -let performanceNowSpy: ReturnType | undefined; +let performanceNowSpy: MockInstance<[], number> | undefined; const pendingRafCallbacks = new Map(); function advancePerfTime(ms: number) { From 88bede43f15480e3f5fd663d0707f767b6a72499 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Wed, 10 Jun 2026 12:56:18 +0100 Subject: [PATCH 24/46] tmp --- docs/reading-progress-tracker.drawio.svg | 352 ++++++++++++++++++++++- 1 file changed, 351 insertions(+), 1 deletion(-) diff --git a/docs/reading-progress-tracker.drawio.svg b/docs/reading-progress-tracker.drawio.svg index 22a49f6c54..00f2070a78 100644 --- a/docs/reading-progress-tracker.drawio.svg +++ b/docs/reading-progress-tracker.drawio.svg @@ -1 +1,351 @@ -
When does a paragraph count as "read"?
When does a paragraph count as "read"?
All gates must pass. Fast scroll pauses dwell and clears partial progress; idle (45 s) also pauses dwell.
All gates must pass. Fast scroll pauses dwell and clears partial progress; idle (45 s) also pauses dwell.
One paragraph block
(p, heading, li, …)
One paragraph block...
GATE 1 — Visibility (IntersectionObserver)
GATE 1 — Visibility (IntersectionObserver)
Is ≥ 50% visible AND
block bottom inside
the scroll area?
Is ≥ 50% visible AND block bottom...
YES → block eligible;
dwell accumulates via rAF when scroll is slow
YES → block eligible; dwell via rAF...
NO → discard partial dwell
(scrolled past or not yet seen)
NO → discard partial dwell...
GATE 2 — Scroll velocity (scroll listener)
GATE 2 — Scroll velocity (scroll listener)
Is scroll speed
≤ WPM-scaled cap?
(1200 px/s at 200 WPM)

batch samples ≥ 50 ms
velocity = |ΣΔy| / ΣΔt
Is scroll speed ≤ WPM-scaled cap?...
YES → dwell ms added each frame
(until block threshold reached)
YES → dwell ms added each frame...
NO → pause dwell + clear accumulation
(user is flinging / skimming)
NO → pause dwell + clear accumulation...
AND
All gates OK
for full dwell
AND — All gates OK...
✓ Block marked READ
progress % updated in localStorage
✓ Block marked READ...
Skimming → NOT read
Skimming → NOT read
• Fast fling (velocity > WPM-scaled cap)
• Block leaves view before dwell completes
• Idle 45 s → dwell paused, partial cleared
• Fast fling (WPM-scaled cap)...
Reading → counts
Reading → counts
• Block end visible, slow scroll
• rAF loop adds ms until block threshold
• Block added to confirmed set → % saved
• Block end visible, slow scroll...
On return visit: auto-restore scroll after 300 ms; 400 ms restore guard — programmatic scroll does not count as reading.
On return visit: auto-restore after 300 ms; 400 ms guard — programmatic...
Text is not SVG - cannot display
\ No newline at end of file + + + + + + + +
+
+
+ Code & data flow +
+
+
+
+ + Code & data flow + +
+
+ + + + + + User + + + + + + +
+
+
+ Reads article +
+ in SingleContent +
+
+
+
+ + Reads article... + +
+
+ + + + +
+
+
+ Returns to +
+ HomePage +
+
+
+
+ + Returns to... + +
+
+ + + + + + App (Vue) + + + + + + +
+
+
+ + SingleContent.vue + +
+
+ • articleProseRef = body HTML only +
+ (not page title / summary) +
+ • scrollContainer (<main> when scrollable) +
+ • averageReadingSpeed +
+ • enabled = text, no video +
+
+
+
+ + SingleContent.vue... + +
+
+ + + + +
+
+
+ + useReadingProgressTracker + + (composable) collectBlocks() IntersectionObserver (visibility + block end) scroll → applyScrollVelocitySample() rAF dwell accumulation (idle pause) restoreScrollPosition() on open +
+
+
+
+ + useReadingProgressTracker (composab... + +
+
+ + + + +
+
+
+ Intersection Observer ≥ 50% visible + block end +
+
+
+
+ + Intersection Observe... + +
+
+ + + + +
+
+
+ scroll listener ≤ WPM-scaled px/s +
+
+
+
+ + scroll listener ≤ WP... + +
+
+ + + + +
+
+
+ + ContinueReading.vue + +
+
+ Homepage row +
+ in-progress articles +
+
+
+
+ + ContinueReading.vue... + +
+
+ + + + +
+
+
+ Body blocks +
+ p, h1–h4, li, +
+ blockquote, pre +
+
+
+
+ + Body blocks... + +
+
+ + + + + + Browser storage + + + + + + +
+
+
+ + globalConfig.ts + +
+ localStorage.readingProgress +
+ [{ contentId, progress }, …] +
+
+ setReadingProgress / getReadingProgress +
+ removeReadingProgress at 100% +
+
+
+
+ + globalConfig.ts... + +
+
+ + + + + + + + + + + + + + + +
+
+
+ save % +
+
+
+
+ + save % + +
+
+ + + + + +
+
+
+ read on load +
+
+
+
+ + read on load + +
+
+ + + + + + + +
+
+
+ + Shared reading math + + (util/readingTime.ts) resolveReadingSpeedWpm · computeEstimatedReadingMinutes computeBlockDwellMs — (blockWords ÷ WPM) × 60 s, clamped 500 ms–8 s computeMaxScrollVelocityPxS — scales 1200 px/s with language WPM + + Tracker tuning + + (useReadingProgressTracker.ts) READING_INTERSECTION_RATIO = 0.5 READING_IDLE_MS = 45 000 · READING_RESTORE_GUARD_MS = 400 MIN_SCROLL_SAMPLE_MS = 50 (batch before velocity check) +
+
+
+
+ + Shared reading math (util/readingTime.ts) resolveReadingSpe... + +
+
+ + + + +
+
+
+ + Color key + +
+ Blue = article page +
+ Yellow = tracker logic +
+ Purple = browser APIs +
+ Green = storage / success +
+ Red = homepage / UI +
+
+
+
+ + Color key... + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file From 3691354063a653b5cf6a1cbba6fa6c9cdd630b61 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 07:59:39 +0100 Subject: [PATCH 25/46] Refactor reading progress tracker to enhance skim detection and block eligibility logic. Introduce new functions for estimating words per pixel and resolving active blocks. Update scroll velocity handling to use words per second instead of pixels per second, improving accuracy in tracking reading progress. --- .../composables/useReadingProgressTracker.ts | 186 +++++++++++------- 1 file changed, 118 insertions(+), 68 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts index fb70df2980..2e118298ba 100644 --- a/app/src/composables/useReadingProgressTracker.ts +++ b/app/src/composables/useReadingProgressTracker.ts @@ -6,21 +6,27 @@ import { setReadingProgress, } from "@/globalConfig"; import { - READING_BASE_MAX_SCROLL_VELOCITY_PX_S, READING_IDLE_MS, READING_MIN_SCROLL_SAMPLE_MS, computeBlockDwellMs, - computeMaxScrollVelocityPxS, + computeMaxScrollWordsPerSec, + computeScrollVelocityWordsPerSec, countWords, + estimateWordsPerPixel, } from "@/util/readingTime"; import type { Uuid } from "luminary-shared"; /** * Tracks article reading progress for the "Continue reading" homepage row. * - * Uses per-block dwell time (scaled by word count and language reading speed), - * plus scroll velocity gating so users who skim through an article are not - * recorded as having read it. + * A block is confirmed only when all gates pass: + * 1. ≥50% visible in the scroll container + * 1b. Block bottom edge inside the viewport + * 2. Scroll speed below skim cap (words/s, from rendered block density) + * 3. Dwell time reached (active block only — topmost unread eligible block) + * 4. User not idle (45 s without scroll or intersection activity) + * + * @see docs/reading-progress-tracker.md */ const BLOCK_SELECTOR = "p, h1, h2, h3, h4, li, blockquote, pre"; @@ -28,9 +34,6 @@ const BLOCK_SELECTOR = "p, h1, h2, h3, h4, li, blockquote, pre"; /** Block must be at least this fraction visible within the scroll root. */ export const READING_INTERSECTION_RATIO = 0.5; -/** @deprecated Use {@link READING_BASE_MAX_SCROLL_VELOCITY_PX_S} or computeMaxScrollVelocityPxS. */ -export const READING_MAX_SCROLL_VELOCITY_PX_S = READING_BASE_MAX_SCROLL_VELOCITY_PX_S; - /** Ignore velocity samples shorter than this (trackpad / layout jitter). */ export { READING_MIN_SCROLL_SAMPLE_MS }; @@ -76,49 +79,65 @@ export function isBlockEligibleForDwell( ); } -export function computeScrollVelocity(deltaY: number, deltaMs: number): number { - if (deltaMs < READING_MIN_SCROLL_SAMPLE_MS) return 0; - return Math.abs(deltaY) / (deltaMs / 1000); +/** Topmost unread block that is currently eligible for dwell (reading order). */ +export function resolveActiveBlock( + blocks: MaybeElement[], + visibleBlocks: Set, + confirmedBlocks: Set, +): MaybeElement | null { + for (const el of blocks) { + if (el && visibleBlocks.has(el) && !confirmedBlocks.has(el)) { + return el; + } + } + return null; } -export type ScrollVelocityState = { +/** Batched scroll samples for gate 2 (skim detection). */ +export type SkimScrollState = { pendingScrollDeltaY: number; pendingScrollDeltaMs: number; - wasScrollingFast: boolean; + /** True while the latest measured scroll speed exceeds the skim cap. */ + isSkimming: boolean; }; -/** Batch short scroll samples, then decide whether the user is skimming. */ +/** + * Batch short scroll samples, then compare words/s to the skim cap. + * Caller must pass wordsPerPixel from the active block (> 0). + */ export function applyScrollVelocitySample( - state: ScrollVelocityState, + state: SkimScrollState, deltaY: number, deltaMs: number, - maxVelocityPxS: number = READING_BASE_MAX_SCROLL_VELOCITY_PX_S, -): { isFast: boolean; justSlowedDown: boolean; state: ScrollVelocityState } { - const next: ScrollVelocityState = { + wordsPerPixel: number, + maxWordsPerSec: number, +): { isSkimming: boolean; justStoppedSkimming: boolean; state: SkimScrollState } { + const next: SkimScrollState = { pendingScrollDeltaY: state.pendingScrollDeltaY + deltaY, pendingScrollDeltaMs: state.pendingScrollDeltaMs + deltaMs, - wasScrollingFast: state.wasScrollingFast, + isSkimming: state.isSkimming, }; if (next.pendingScrollDeltaMs < READING_MIN_SCROLL_SAMPLE_MS) { - return { isFast: next.wasScrollingFast, justSlowedDown: false, state: next }; + return { isSkimming: next.isSkimming, justStoppedSkimming: false, state: next }; } - const velocity = computeScrollVelocity( + const wordsPerSecond = computeScrollVelocityWordsPerSec( next.pendingScrollDeltaY, next.pendingScrollDeltaMs, + wordsPerPixel, ); next.pendingScrollDeltaY = 0; next.pendingScrollDeltaMs = 0; - if (velocity > maxVelocityPxS) { - next.wasScrollingFast = true; - return { isFast: true, justSlowedDown: false, state: next }; + if (wordsPerPixel > 0 && wordsPerSecond > maxWordsPerSec) { + next.isSkimming = true; + return { isSkimming: true, justStoppedSkimming: false, state: next }; } - const justSlowedDown = next.wasScrollingFast; - next.wasScrollingFast = false; - return { isFast: false, justSlowedDown, state: next }; + const justStoppedSkimming = next.isSkimming; + next.isSkimming = false; + return { isSkimming: false, justStoppedSkimming, state: next }; } /** Prefer BasePage `
` — it scrolls, not the window. */ @@ -150,15 +169,16 @@ export function useReadingProgressTracker(options: { const blocks = ref([]); const confirmedBlocks = new Set(); const visibleBlocks = new Set(); + const blockWordsPerPixel = new WeakMap(); const dwellAccumulatedMs = new Map(); let lastSavedProgress = -1; let lastScrollY = 0; let lastScrollTime = 0; let lastActivityMs: number | null = null; - let scrollVelocityState: ScrollVelocityState = { + let skimScrollState: SkimScrollState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: false, + isSkimming: false, }; let scrollRafPending = false; let dwellRafId: number | null = null; @@ -167,8 +187,8 @@ export function useReadingProgressTracker(options: { const isRestoring = ref(false); - const maxScrollVelocityPxS = computed(() => - computeMaxScrollVelocityPxS(options.averageReadingSpeed.value), + const maxScrollWordsPerSec = computed(() => + computeMaxScrollWordsPerSec(options.averageReadingSpeed.value), ); const savedProgressPercent = computed(() => { @@ -213,10 +233,10 @@ export function useReadingProgressTracker(options: { lastScrollY = 0; lastScrollTime = 0; lastActivityMs = null; - scrollVelocityState = { + skimScrollState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: false, + isSkimming: false, }; scrollRafPending = false; isRestoring.value = false; @@ -233,6 +253,19 @@ export function useReadingProgressTracker(options: { ).filter((el) => el.textContent?.trim()) as MaybeElement[]; } + function cacheBlockWordsPerPixel(el: MaybeElement) { + if (!(el instanceof Element)) return; + const height = el.getBoundingClientRect().height; + const words = countWords(el.textContent ?? ""); + blockWordsPerPixel.set(el, estimateWordsPerPixel(words, height)); + } + + function activeBlockWordsPerPixel(): number { + const active = resolveActiveBlock(blocks.value, visibleBlocks, confirmedBlocks); + if (!(active instanceof Element)) return 0; + return blockWordsPerPixel.get(active) ?? 0; + } + /** Restore in-memory confirmed set from saved % so progress never drops on re-setup. */ function seedConfirmedFromSavedProgress() { const id = options.contentId.value; @@ -291,15 +324,27 @@ export function useReadingProgressTracker(options: { dwellAccumulatedMs.delete(el); } + /** Gate 2: stationary reading after a skim burst should resume dwell. */ + function clearSkimmingIfScrollStopped(now: number) { + if ( + skimScrollState.isSkimming && + lastScrollTime > 0 && + now - lastScrollTime >= READING_MIN_SCROLL_SAMPLE_MS + ) { + skimScrollState = { ...skimScrollState, isSkimming: false }; + } + } + function tickDwell(timestamp: number) { dwellRafId = requestAnimationFrame(tickDwell); - if (!options.enabled.value || isRestoring.value || scrollVelocityState.wasScrollingFast) { + const now = performance.now(); + clearSkimmingIfScrollStopped(now); + + if (!options.enabled.value || isRestoring.value || skimScrollState.isSkimming) { lastDwellFrameTime = timestamp; return; } - - const now = performance.now(); if (lastActivityMs !== null && now - lastActivityMs >= READING_IDLE_MS) { clearDwellAccumulation(); lastDwellFrameTime = timestamp; @@ -315,18 +360,17 @@ export function useReadingProgressTracker(options: { lastDwellFrameTime = timestamp; if (elapsed <= 0) return; - for (const el of visibleBlocks) { - if (!el || confirmedBlocks.has(el)) continue; + const activeBlock = resolveActiveBlock(blocks.value, visibleBlocks, confirmedBlocks); + if (!activeBlock || confirmedBlocks.has(activeBlock)) return; - const requiredMs = blockDwellMs(el); - const accumulated = (dwellAccumulatedMs.get(el) ?? 0) + elapsed; + const requiredMs = blockDwellMs(activeBlock); + const accumulated = (dwellAccumulatedMs.get(activeBlock) ?? 0) + elapsed; - if (accumulated >= requiredMs) { - dwellAccumulatedMs.delete(el); - markBlockRead(el); - } else { - dwellAccumulatedMs.set(el, accumulated); - } + if (accumulated >= requiredMs) { + dwellAccumulatedMs.delete(activeBlock); + markBlockRead(activeBlock); + } else { + dwellAccumulatedMs.set(activeBlock, accumulated); } } @@ -344,7 +388,7 @@ export function useReadingProgressTracker(options: { function startDwellIfEligible(el: MaybeElement) { if (!el || confirmedBlocks.has(el)) return; - if (!visibleBlocks.has(el) || isRestoring.value || scrollVelocityState.wasScrollingFast) return; + if (!visibleBlocks.has(el) || isRestoring.value || skimScrollState.isSkimming) return; ensureDwellLoop(); } @@ -359,9 +403,9 @@ export function useReadingProgressTracker(options: { } } - function onFastScrollDetected() { + function onSkimmingDetected() { clearDwellAccumulation(); - scrollVelocityState.wasScrollingFast = true; + skimScrollState = { ...skimScrollState, isSkimming: true }; } function onScroll() { @@ -373,23 +417,28 @@ export function useReadingProgressTracker(options: { touchActivity(now); if (lastScrollTime > 0) { - const { isFast, justSlowedDown, state } = applyScrollVelocitySample( - scrollVelocityState, - scrollY - lastScrollY, - now - lastScrollTime, - maxScrollVelocityPxS.value, - ); - scrollVelocityState = state; - - if (isFast) { - onFastScrollDetected(); - } else if (justSlowedDown) { - if (!scrollRafPending) { - scrollRafPending = true; - requestAnimationFrame(() => { - scrollRafPending = false; - ensureDwellLoop(); - }); + const wordsPerPixel = activeBlockWordsPerPixel(); + + if (wordsPerPixel > 0) { + const { isSkimming, justStoppedSkimming, state } = applyScrollVelocitySample( + skimScrollState, + scrollY - lastScrollY, + now - lastScrollTime, + wordsPerPixel, + maxScrollWordsPerSec.value, + ); + skimScrollState = state; + + if (isSkimming) { + onSkimmingDetected(); + } else if (justStoppedSkimming) { + if (!scrollRafPending) { + scrollRafPending = true; + requestAnimationFrame(() => { + scrollRafPending = false; + ensureDwellLoop(); + }); + } } } } @@ -405,6 +454,7 @@ export function useReadingProgressTracker(options: { const eligible = isBlockEligibleForDwell(entry); if (eligible) { + cacheBlockWordsPerPixel(el); visibleBlocks.add(el); startDwellIfEligible(el); } else { @@ -457,10 +507,10 @@ export function useReadingProgressTracker(options: { setTimeout(() => { isRestoring.value = false; lastScrollTime = 0; - scrollVelocityState = { + skimScrollState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: false, + isSkimming: false, }; lastScrollY = getScrollTop(container); touchActivity(); From f0c62fdf90bfd69b8dea18982359aaa4388d98ca Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 07:59:48 +0100 Subject: [PATCH 26/46] Enhance reading time utility with detailed comments and new constants for skim detection and block dwell logic. Introduce functions for estimating words per pixel and computing scroll velocity in words per second, improving clarity and accuracy in reading progress tracking. --- app/src/util/readingTime.ts | 65 ++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/app/src/util/readingTime.ts b/app/src/util/readingTime.ts index 8d11d196d2..b7ac45ecdf 100644 --- a/app/src/util/readingTime.ts +++ b/app/src/util/readingTime.ts @@ -1,20 +1,38 @@ +// --------------------------------------------------------------------------- +// Shared reading-speed helpers (UI estimates + progress-tracker gates) +// --------------------------------------------------------------------------- + /** Default words-per-minute when a language has no averageReadingSpeed. */ export const DEFAULT_READING_SPEED_WPM = 200; +// --- Gate 3: per-block dwell time ------------------------------------------------ + /** Shortest block dwell — quick glance on a tiny block should not count. */ export const READING_MIN_DWELL_MS = 500; /** Longest block dwell — caps wait time for very long blocks. */ export const READING_MAX_DWELL_MS = 8000; -/** Base skim threshold (px/s) at {@link DEFAULT_READING_SPEED_WPM}. */ -export const READING_BASE_MAX_SCROLL_VELOCITY_PX_S = 1200; +// --- Gate 2: skim detection (scroll speed in words per second) ------------------- + +/** + * Skim threshold multiplier. + * + * maxWordsPerSec = (languageWPM / 60) × READING_SKIM_WPM_MULTIPLIER + * + * Example at 200 WPM: reading ≈ 3.3 w/s, skim cap ≈ 10 w/s. + */ +export const READING_SKIM_WPM_MULTIPLIER = 3; + +/** Batch scroll events shorter than this before measuring words/s (trackpad jitter). */ +export const READING_MIN_SCROLL_SAMPLE_MS = 50; + +// --- Gate 4: idle pause ---------------------------------------------------------- /** Pause dwell when the user has not scrolled or changed visibility for this long. */ export const READING_IDLE_MS = 45_000; -/** Ignore velocity samples shorter than this (trackpad / layout jitter). */ -export const READING_MIN_SCROLL_SAMPLE_MS = 50; +// --- Language WPM ---------------------------------------------------------------- export function resolveReadingSpeedWpm(wordsPerMinute?: number | null): number { if (wordsPerMinute == null || wordsPerMinute <= 0 || Number.isNaN(wordsPerMinute)) { @@ -29,7 +47,9 @@ export function countWords(text: string): number { return trimmed.split(/\s+/).length; } -/** Article-level estimate shown in the UI (minutes). */ +// --- UI: estimated reading time badge -------------------------------------------- + +/** Article-level estimate shown in the UI (minutes). Not used by the progress tracker. */ export function computeEstimatedReadingMinutes( wordCount: number, wordsPerMinute?: number | null, @@ -38,7 +58,12 @@ export function computeEstimatedReadingMinutes( return Math.ceil(wordCount / resolveReadingSpeedWpm(wordsPerMinute)); } -/** Per-block dwell for progress tracking (milliseconds). */ +// --- Progress tracker: dwell per block ------------------------------------------- + +/** + * Milliseconds the active block must remain eligible before it is confirmed. + * Scales with word count and language WPM; clamped to min/max bounds. + */ export function computeBlockDwellMs( wordCount: number, wordsPerMinute?: number | null, @@ -49,8 +74,30 @@ export function computeBlockDwellMs( return Math.min(READING_MAX_DWELL_MS, Math.max(READING_MIN_DWELL_MS, ms)); } -/** Scale skim scroll cap with language reading speed (200 WPM → 1200 px/s). */ -export function computeMaxScrollVelocityPxS(wordsPerMinute?: number | null): number { +// --- Progress tracker: skim detection -------------------------------------------- + +/** Words per pixel of block height (from rendered DOM). Zero when inputs are invalid. */ +export function estimateWordsPerPixel(wordCount: number, blockHeightPx: number): number { + if (wordCount <= 0 || blockHeightPx <= 0) return 0; + return wordCount / blockHeightPx; +} + +/** Upper bound on scroll speed (words/s) before the user is treated as skimming. */ +export function computeMaxScrollWordsPerSec(wordsPerMinute?: number | null): number { const wpm = resolveReadingSpeedWpm(wordsPerMinute); - return Math.round(READING_BASE_MAX_SCROLL_VELOCITY_PX_S * (wpm / DEFAULT_READING_SPEED_WPM)); + return (wpm / 60) * READING_SKIM_WPM_MULTIPLIER; +} + +/** + * Scroll speed in words per second from a batched scroll sample. + * Returns 0 when the sample window is too short or word density is unknown. + */ +export function computeScrollVelocityWordsPerSec( + deltaY: number, + deltaMs: number, + wordsPerPixel: number, +): number { + if (deltaMs < READING_MIN_SCROLL_SAMPLE_MS || wordsPerPixel <= 0) return 0; + const wordsScrolled = Math.abs(deltaY) * wordsPerPixel; + return wordsScrolled / (deltaMs / 1000); } From 142b06f637b665a1e206adea84970943d8c63c13 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 08:00:01 +0100 Subject: [PATCH 27/46] Refactor reading progress tracker tests to incorporate new functions for resolving active blocks and computing scroll velocity in words per second. Enhance skim detection logic and update test cases to reflect changes in state management and velocity handling, ensuring accurate tracking of reading progress. --- .../useReadingProgressTracker.spec.ts | 101 ++++++++++++------ 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index 6dec750651..8ef4df6317 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -3,21 +3,22 @@ import { mount, flushPromises } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import { READING_INTERSECTION_RATIO, - READING_MAX_SCROLL_VELOCITY_PX_S, READING_RESTORE_GUARD_MS, applyScrollVelocitySample, - computeScrollVelocity, isBlockEndInViewport, isBlockEligibleForDwell, + resolveActiveBlock, useReadingProgressTracker, } from "./useReadingProgressTracker"; import { DEFAULT_READING_SPEED_WPM, - READING_BASE_MAX_SCROLL_VELOCITY_PX_S, READING_IDLE_MS, + READING_SKIM_WPM_MULTIPLIER, computeBlockDwellMs, - computeMaxScrollVelocityPxS, + computeMaxScrollWordsPerSec, + computeScrollVelocityWordsPerSec, countWords, + estimateWordsPerPixel, } from "@/util/readingTime"; import { getReadingProgress, removeReadingProgress, setReadingProgress } from "@/globalConfig"; @@ -241,58 +242,84 @@ describe("isBlockEligibleForDwell", () => { }); }); -describe("computeScrollVelocity", () => { - it("returns px/s from delta and elapsed time", () => { - expect(computeScrollVelocity(120, 100)).toBe(1200); - expect(computeScrollVelocity(-120, 100)).toBe(1200); - }); +describe("resolveActiveBlock", () => { + it("returns the topmost unread visible block in reading order", () => { + const blocks = [{ id: 0 }, { id: 1 }, { id: 2 }] as unknown as Element[]; + const visible = new Set([blocks[1], blocks[2]]); + const confirmed = new Set([blocks[0]]); - it("returns 0 when sample window is too short (jitter)", () => { - expect(computeScrollVelocity(100, 49)).toBe(0); + expect(resolveActiveBlock(blocks, visible, confirmed)).toBe(blocks[1]); }); }); describe("applyScrollVelocitySample", () => { + const defaultMaxWordsPerSec = computeMaxScrollWordsPerSec(DEFAULT_READING_SPEED_WPM); + const wordsPerPixel = estimateWordsPerPixel(100, 500); + it("detects fast scroll once batched samples exceed the jitter window", () => { let state = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: false, + isSkimming: false, }; - state = applyScrollVelocitySample(state, 10, 30).state; - expect(state.wasScrollingFast).toBe(false); + state = applyScrollVelocitySample(state, 10, 30, wordsPerPixel, defaultMaxWordsPerSec).state; + expect(state.isSkimming).toBe(false); - const result = applyScrollVelocitySample(state, 80, 30); - expect(result.isFast).toBe(true); - expect(result.state.wasScrollingFast).toBe(true); + const result = applyScrollVelocitySample(state, 80, 30, wordsPerPixel, defaultMaxWordsPerSec); + expect(result.isSkimming).toBe(true); + expect(result.state.isSkimming).toBe(true); }); it("reports when scrolling slows after a fast burst", () => { const state = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: true, + isSkimming: true, }; - const result = applyScrollVelocitySample(state, 10, 100); - expect(result.isFast).toBe(false); - expect(result.justSlowedDown).toBe(true); - expect(result.state.wasScrollingFast).toBe(false); + const result = applyScrollVelocitySample(state, 1, 100, wordsPerPixel, defaultMaxWordsPerSec); + expect(result.isSkimming).toBe(false); + expect(result.justStoppedSkimming).toBe(true); + expect(result.state.isSkimming).toBe(false); }); - it("uses a WPM-scaled velocity cap", () => { - const slowLanguageCap = computeMaxScrollVelocityPxS(100); - expect(slowLanguageCap).toBe(600); + it("uses a WPM-scaled words-per-second cap", () => { + const slowLanguageCap = computeMaxScrollWordsPerSec(100); + expect(slowLanguageCap).toBeCloseTo((100 / 60) * READING_SKIM_WPM_MULTIPLIER); let state = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, - wasScrollingFast: false, + isSkimming: false, }; - state = applyScrollVelocitySample(state, 40, 30, slowLanguageCap).state; - const result = applyScrollVelocitySample(state, 40, 30, slowLanguageCap); - expect(result.isFast).toBe(true); + state = applyScrollVelocitySample(state, 40, 30, wordsPerPixel, slowLanguageCap).state; + const result = applyScrollVelocitySample(state, 40, 30, wordsPerPixel, slowLanguageCap); + expect(result.isSkimming).toBe(true); + }); + + it("does not flag skimming when wordsPerPixel is zero", () => { + const state = { + pendingScrollDeltaY: 0, + pendingScrollDeltaMs: 0, + isSkimming: false, + }; + + const result = applyScrollVelocitySample(state, 5000, 100, 0, defaultMaxWordsPerSec); + expect(result.isSkimming).toBe(false); + }); + + it("flags the same px/s delta as skim on a dense block but not on a tall sparse block at slow scroll", () => { + const tallBlockDensity = estimateWordsPerPixel(100, 800); + const shortBlockDensity = estimateWordsPerPixel(100, 300); + const deltaY = 50; + const deltaMs = 1000; + + const tallVelocity = computeScrollVelocityWordsPerSec(deltaY, deltaMs, tallBlockDensity); + const shortVelocity = computeScrollVelocityWordsPerSec(deltaY, deltaMs, shortBlockDensity); + + expect(tallVelocity).toBeLessThan(defaultMaxWordsPerSec); + expect(shortVelocity).toBeGreaterThan(defaultMaxWordsPerSec); }); }); @@ -424,9 +451,19 @@ describe("useReadingProgressTracker", () => { wrapper.unmount(); }); - it("identifies fast scroll speeds for velocity gating", () => { - expect(computeScrollVelocity(5000, 100)).toBeGreaterThan(READING_MAX_SCROLL_VELOCITY_PX_S); - expect(computeScrollVelocity(100, 100)).toBeLessThan(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); + it("accumulates dwell only for the active block when multiple blocks are visible", async () => { + const { wrapper } = mountTracker(2); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[0], true); + observer.trigger(observer.elements[1], true); + + advanceDwellMs(BLOCK_ONE_DWELL_MS); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + wrapper.unmount(); }); it("saves progress after velocity drops and dwell completes at low speed", async () => { From 1cfcea711e703b317b33b47bbb34dbfe91bf4919 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 08:00:09 +0100 Subject: [PATCH 28/46] Update reading time tests to include new functions for estimating words per pixel and computing scroll velocity in words per second. Refactor existing tests to ensure accurate validation of skim detection and velocity calculations, enhancing overall test coverage and reliability. --- app/src/util/readingTime.spec.ts | 57 +++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/app/src/util/readingTime.spec.ts b/app/src/util/readingTime.spec.ts index 978b536a14..2020942a9f 100644 --- a/app/src/util/readingTime.spec.ts +++ b/app/src/util/readingTime.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_READING_SPEED_WPM, - READING_BASE_MAX_SCROLL_VELOCITY_PX_S, READING_MAX_DWELL_MS, READING_MIN_DWELL_MS, + READING_SKIM_WPM_MULTIPLIER, computeBlockDwellMs, computeEstimatedReadingMinutes, - computeMaxScrollVelocityPxS, + computeMaxScrollWordsPerSec, + computeScrollVelocityWordsPerSec, countWords, + estimateWordsPerPixel, resolveReadingSpeedWpm, } from "./readingTime"; @@ -57,14 +59,53 @@ describe("computeBlockDwellMs", () => { }); }); -describe("computeMaxScrollVelocityPxS", () => { - it("returns the base cap at default WPM", () => { - expect(computeMaxScrollVelocityPxS(200)).toBe(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); - expect(computeMaxScrollVelocityPxS()).toBe(READING_BASE_MAX_SCROLL_VELOCITY_PX_S); +describe("estimateWordsPerPixel", () => { + it("returns word density from count and rendered height", () => { + expect(estimateWordsPerPixel(100, 500)).toBe(0.2); + expect(estimateWordsPerPixel(50, 250)).toBe(0.2); + }); + + it("returns 0 for invalid inputs", () => { + expect(estimateWordsPerPixel(0, 100)).toBe(0); + expect(estimateWordsPerPixel(10, 0)).toBe(0); + }); +}); + +describe("computeMaxScrollWordsPerSec", () => { + it("returns WPM/60 × skim multiplier at default WPM", () => { + expect(computeMaxScrollWordsPerSec(200)).toBeCloseTo((200 / 60) * READING_SKIM_WPM_MULTIPLIER); + expect(computeMaxScrollWordsPerSec()).toBeCloseTo((200 / 60) * READING_SKIM_WPM_MULTIPLIER); }); it("scales the skim cap with language reading speed", () => { - expect(computeMaxScrollVelocityPxS(300)).toBe(1800); - expect(computeMaxScrollVelocityPxS(100)).toBe(600); + expect(computeMaxScrollWordsPerSec(300)).toBeCloseTo((300 / 60) * READING_SKIM_WPM_MULTIPLIER); + expect(computeMaxScrollWordsPerSec(100)).toBeCloseTo((100 / 60) * READING_SKIM_WPM_MULTIPLIER); + }); +}); + +describe("computeScrollVelocityWordsPerSec", () => { + it("converts scroll delta to words per second", () => { + // 100 px in 100 ms at 0.2 words/px → 20 words in 0.1 s → 200 w/s + expect(computeScrollVelocityWordsPerSec(100, 100, 0.2)).toBe(200); + expect(computeScrollVelocityWordsPerSec(-100, 100, 0.2)).toBe(200); + }); + + it("returns 0 when sample window is too short or density is zero", () => { + expect(computeScrollVelocityWordsPerSec(100, 49, 0.2)).toBe(0); + expect(computeScrollVelocityWordsPerSec(100, 100, 0)).toBe(0); + }); + + it("treats the same px/s differently by block density", () => { + const tallBlockDensity = 100 / 800; // phone-like tall block + const shortBlockDensity = 100 / 300; // desktop-like short block + const deltaY = 500; + const deltaMs = 500; + + const tallVelocity = computeScrollVelocityWordsPerSec(deltaY, deltaMs, tallBlockDensity); + const shortVelocity = computeScrollVelocityWordsPerSec(deltaY, deltaMs, shortBlockDensity); + + expect(shortVelocity).toBeGreaterThan(tallVelocity); + expect(tallVelocity).toBeCloseTo(125); + expect(shortVelocity).toBeCloseTo((deltaY * shortBlockDensity) / (deltaMs / 1000)); }); }); From 454dd4afefc6d4875b2ed332a910aa39a607a13b Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 08:04:48 +0100 Subject: [PATCH 29/46] Update reading progress tracker documentation and diagram to reflect changes in block eligibility and skim detection logic. Clarify the calculation of reading speed in words per second and enhance descriptions of tracking behavior, including idle pauses and scroll restoration on article reopen. --- docs/reading-progress-tracker.drawio.svg | 10 +- docs/reading-progress-tracker.md | 237 +++++++++++++++-------- 2 files changed, 162 insertions(+), 85 deletions(-) diff --git a/docs/reading-progress-tracker.drawio.svg b/docs/reading-progress-tracker.drawio.svg index 00f2070a78..126687a332 100644 --- a/docs/reading-progress-tracker.drawio.svg +++ b/docs/reading-progress-tracker.drawio.svg @@ -112,7 +112,7 @@ useReadingProgressTracker - (composable) collectBlocks() IntersectionObserver (visibility + block end) scroll → applyScrollVelocitySample() rAF dwell accumulation (idle pause) restoreScrollPosition() on open + (composable) collectBlocks() IntersectionObserver (visibility + block end) scroll → applyScrollVelocitySample() (words/s) rAF dwell on active block only (idle pause) restoreScrollPosition() on open

@@ -146,13 +146,13 @@
- scroll listener ≤ WPM-scaled px/s + scroll listener ≤ WPM-scaled words/s
- scroll listener ≤ WP... + scroll listener ≤ words/s @@ -296,11 +296,11 @@ Shared reading math - (util/readingTime.ts) resolveReadingSpeedWpm · computeEstimatedReadingMinutes computeBlockDwellMs — (blockWords ÷ WPM) × 60 s, clamped 500 ms–8 s computeMaxScrollVelocityPxS — scales 1200 px/s with language WPM + (util/readingTime.ts) resolveReadingSpeedWpm · computeEstimatedReadingMinutes computeBlockDwellMs — (blockWords ÷ WPM) × 60 s, clamped 500 ms–8 s estimateWordsPerPixel · computeMaxScrollWordsPerSec — (WPM ÷ 60) × 3 skim multiplier Tracker tuning - (useReadingProgressTracker.ts) READING_INTERSECTION_RATIO = 0.5 READING_IDLE_MS = 45 000 · READING_RESTORE_GUARD_MS = 400 MIN_SCROLL_SAMPLE_MS = 50 (batch before velocity check) + (useReadingProgressTracker.ts) READING_INTERSECTION_RATIO = 0.5 READING_SKIM_WPM_MULTIPLIER = 3 READING_IDLE_MS = 45 000 · READING_RESTORE_GUARD_MS = 400 MIN_SCROLL_SAMPLE_MS = 50 (batch before words/s check) · active block dwell only diff --git a/docs/reading-progress-tracker.md b/docs/reading-progress-tracker.md index bc338e39e6..40a7ebf8e4 100644 --- a/docs/reading-progress-tracker.md +++ b/docs/reading-progress-tracker.md @@ -1,147 +1,224 @@ -# Continue Reading — reading progress tracker +# Reading progress tracker -The app saves **in-progress text articles** so the homepage can show a **Continue Reading** row. Progress must reflect articles the user is actually reading, not articles they fling past. +The app tracks how far a user has read through a **text article** and saves that progress locally. The homepage **Continue Reading** row shows articles that are still in progress. -Visual overview: open [`reading-progress-tracker.drawio.svg`](reading-progress-tracker.drawio.svg) in [draw.io](https://app.diagrams.net/) (three tabs: Overview, Reading gates, Components). +Progress is measured **block by block**. A block is only counted as read when the user has actually spent time on it — not when they scroll past quickly. -## Problem we solved +Visual overview: [`reading-progress-tracker.drawio.svg`](reading-progress-tracker.drawio.svg) (open in [draw.io](https://app.diagrams.net/)). -**Old approach:** save scroll depth (how far down the page the user scrolled). +--- -That breaks when someone scrolls quickly to the bottom — the article looks 100% read even though they never read it. +## When tracking runs -**Current approach:** count individual **content blocks** as read only when visibility, block-end, scroll-speed, and dwell gates all pass. +Tracking is enabled on **SingleContent** when all of the following are true: -## Where tracking starts - -Tracking runs only on **SingleContent** text articles (no video, must have `content.text`). +- The content has **text** (`content.text`) +- The content has **no video** +- A content id is present The tracker watches `articleProseRef` — the `
` that renders the CMS article body. -**Included in progress** +| Included in progress | Not included | +|----------------------|--------------| +| `p`, `h1`–`h4`, `li`, `blockquote`, `pre` inside the article body | Page title, hero image, summary, author, reading time, publish date, tags, copyright footer | + +Progress starts at the **first block in the article body**. If the HTML begins with an `

`, that heading is block 1. -- Paragraphs, headings, list items, blockquotes, and code blocks inside the article HTML - (`p`, `h1`–`h4`, `li`, `blockquote`, `pre`) +--- -**Not included** +## How progress is calculated -- Page title (`content.title`) -- Hero image -- Summary, author, reading time, publish date -- Category tags -- Copyright footer +``` +progress % = round(confirmed blocks ÷ total blocks × 100) +``` -So progress begins at the **first block inside the article body**, not at the page title. If the CMS HTML starts with an `

`, that heading is the first tracked block. +1. On article open, the tracker collects all blocks from the prose root. +2. Each block must pass **four gates** before it is added to the `confirmed` set. +3. When the confirmed count changes, the percentage is saved to `localStorage` (`readingProgress` key). +4. At **100%**, the entry is **removed** — the article is finished, not “in progress”. +5. Progress **never decreases** for a given article (`Math.max(existing, computed)`). -## End-to-end flow +On re-open, the saved percentage **seeds** the confirmed set so progress does not drop if the tracker re-initializes. -1. User opens a text article on **SingleContent**. -2. `useReadingProgressTracker` collects blocks from the prose root and observes them. -3. When a block passes all gates, it is added to a **confirmed** set. -4. Progress `%` = `confirmed blocks ÷ total blocks`, rounded. -5. `%` is saved to `localStorage` via **globalConfig** (`readingProgress` key). -6. **ContinueReading** on the homepage reads that list and shows matching published articles. +--- -At **100%**, the entry is **removed** from storage (article is considered finished, not “in progress”). +## The four gates -## The gates +A block is confirmed only when **all** gates pass at the same time. -A block is marked read only when **all** conditions below are met. +```mermaid +flowchart TD + Start[Block becomes visible] --> G1[Gate 1: 50% visible] + G1 --> G1b[Gate 1b: Block end in viewport] + G1b --> G2[Gate 2: Scroll speed below skim cap] + G2 --> G3[Gate 3: Dwell time reached] + G3 --> Done[Block confirmed → save progress] + G2 -->|skimming| Reset[Clear partial dwell] + G1 -->|leaves viewport| Cancel[Discard partial dwell for that block] +``` -### Gate 1 — Block visibility (IntersectionObserver) +### Gate 1 — Visibility -- At least **50%** of the block must intersect the scroll container (`READING_INTERSECTION_RATIO = 0.5`). -- Scroll root is `
` when it scrolls (see `resolveArticleScrollContainer()`), not the window. -- Block leaves the viewport → partial dwell for that block is discarded. +- At least **50%** of the block must be visible in the scroll container (`READING_INTERSECTION_RATIO = 0.5`). +- The scroll root is `
` when it scrolls (`resolveArticleScrollContainer()`), not the window. +- When a block leaves the viewport, any partial dwell for that block is discarded. ### Gate 1b — Block end in viewport -- The **bottom edge** of the block must be inside the visible scroll area (not below the fold). -- This ensures the user has scrolled through the block, not merely glimpsed the top of a long paragraph. +- The **bottom edge** of the block must be inside the visible scroll area. +- This ensures the user has scrolled through the block, not just glimpsed the top. + +### Gate 2 — Scroll speed (skim detection) -### Gate 2 — Scroll velocity (scroll listener) +Dwell only accumulates while the user is scrolling slowly enough to be reading. Fast scrolling is treated as **skimming**. -- Dwell time **only accumulates while scroll speed is at or below** `computeMaxScrollVelocityPxS(languageWPM)`. -- At default **200 WPM**, the cap is **1200 px/s** (`READING_BASE_MAX_SCROLL_VELOCITY_PX_S`); faster languages scale proportionally (e.g. 300 WPM → 1800 px/s). -- Above the threshold the user is treated as skimming: - - dwell **stops accumulating** - - **all partial dwell** for visible blocks is **cleared** -- When scrolling slows again, dwell starts fresh from zero for still-visible blocks. +Scroll speed is measured in **words per second**, derived from the block’s **rendered layout** on the current device. This keeps skim detection consistent across phone, tablet, and desktop without a separate viewport lookup. -Velocity is computed from **batched** scroll samples: individual events shorter than **50 ms** are combined before measuring speed (`READING_MIN_SCROLL_SAMPLE_MS`). That avoids missing fast trackpad flings between high-frequency events. +#### Step 1 — Word density per block -### Gate 3 — Idle pause +When a block becomes eligible, cache its vertical word density (recomputed on each intersection update, including resize/rotate): -- If there is no scroll or intersection activity for **45 s** (`READING_IDLE_MS`), dwell stops accumulating until the user interacts again. +``` +wordCount = countWords(block.textContent) +blockHeight = block.getBoundingClientRect().height +wordsPerPixel = wordCount / blockHeight (0 when height is 0) +``` -## How dwell time works +Font size, zoom, and line wrapping are already reflected in `blockHeight`. -Dwell is **not** a wall-clock `setTimeout`. It is **accumulated milliseconds** added on each animation frame while all gates pass. +#### Step 2 — Words per second -Per-block threshold from `computeBlockDwellMs()` in `app/src/util/readingTime.ts`: +On each scroll event, use the **active block** — the topmost unread block that passes gates 1 and 1b: + +``` +wordsScrolled = abs(scrollDeltaY) × wordsPerPixel +wordsPerSecond = wordsScrolled / (deltaMs / 1000) +``` + +If no block is visible (`wordsPerPixel = 0`), scroll speed is not evaluated. + +Scroll samples shorter than **50 ms** are batched first (`READING_MIN_SCROLL_SAMPLE_MS`) so fast trackpad flings are not missed between events. + +#### Step 3 — Skim cap + +``` +maxWordsPerSec = (languageWPM / 60) × READING_SKIM_WPM_MULTIPLIER +``` + +| Language WPM | Reading rate | Skim cap (×3) | +|--------------|--------------|---------------| +| 200 (default) | ~3.3 w/s | ~10 w/s | +| 300 | ~5.0 w/s | ~15 w/s | + +`languageWPM` comes from the content language’s `averageReadingSpeed`, defaulting to **200** when unset. + +When `wordsPerSecond > maxWordsPerSec`: + +- dwell stops accumulating +- all partial dwell is cleared +- when scrolling slows or stops (no scroll for 50 ms), dwell starts fresh for the active block + +### Gate 3 — Dwell time + +Dwell is accumulated in **milliseconds** on each animation frame while gates 1, 1b, and 2 pass. It is not a single `setTimeout`. + +Only the **active block** (topmost unread eligible block) accumulates dwell at a time. Other visible blocks wait until earlier blocks are confirmed. + +Required dwell per block: ``` dwellMs = (blockWordCount ÷ languageWPM) × 60 000 clamped to 500 ms … 8 000 ms ``` -- **WPM** comes from the content language’s `averageReadingSpeed`, default **200** when unset. -- Short blocks still need at least **500 ms** of effective dwell. -- Very long blocks cap at **8 s**. +| Constant | Value | Purpose | +|----------|-------|---------| +| `READING_MIN_DWELL_MS` | 500 ms | Minimum time even for tiny blocks | +| `READING_MAX_DWELL_MS` | 8000 ms | Cap for very long blocks | + +When accumulated dwell reaches the threshold, the block is confirmed and progress is saved (if the percentage increased). -When accumulated dwell for a block reaches its threshold, the block is confirmed and progress is persisted (if the percentage increased). +**Short articles:** If every block is already visible without scrolling (e.g. on a large desktop screen), dwell still accumulates via the animation-frame loop. -**No-scroll articles:** On desktop, if every block is already visible (short article), dwell still accumulates via the animation-frame loop without any scroll events. +### Gate 4 — Idle pause -## Progress persistence rules +If there is no scroll or intersection activity for **45 s** (`READING_IDLE_MS`), dwell stops until the user interacts again. -- Stored shape: `[{ contentId, progress }, …]` in `localStorage.readingProgress`. -- Progress **never decreases** for a given article (`Math.max(existing, computed)`). -- On re-open, saved `%` **seeds** the confirmed set (same `Math.round` as when saving) so progress does not drop if the tracker re-initializes. +--- -## Return visit: scroll restore +## Active block -When the user reopens an in-progress article: +The **active block** is the first block in document order that is: -1. After a **300 ms** delay, the scroll container jumps to `progress%` of max scroll. -2. For **400 ms** after that (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the programmatic scroll does not count as reading. +- visible and eligible (gates 1 + 1b), and +- not yet confirmed + +It drives both **skim detection** (whose `wordsPerPixel` to use) and **dwell accumulation** (only this block gains dwell per frame). + +--- + +## Storage + +**Key:** `localStorage.readingProgress` + +**Shape:** -Opt-in restore UI (banner/prompt) is deferred pending further UX discussion. +```json +[{ "contentId": "…", "progress": 42 }] +``` + +**API** (`app/src/globalConfig.ts`): + +- `setReadingProgress(contentId, progress)` — save or update +- `getReadingProgress(contentId)` — read percentage (0 if missing) +- `removeReadingProgress(contentId)` — called automatically at 100% -## When tracking is disabled +**Homepage:** `ContinueReading.vue` reads this list, queries published content by id, and renders a horizontal tile row. -The composable is **off** when: +--- -- Content has a **video** (video posts use a different UX) -- Content has **no text** -- Content id is missing +## Return visit — scroll restore -## Key files +When the user reopens an in-progress article: + +1. After **300 ms**, the scroll container jumps to `progress%` of max scroll. +2. For **400 ms** after that (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the programmatic scroll does not count as reading. + +--- + +## Source files | File | Role | |------|------| -| `app/src/pages/SingleContent/SingleContent.vue` | Wires tracker on text articles | -| `app/src/composables/useReadingProgressTracker.ts` | Gates, dwell loop, auto restore on open | -| `app/src/util/readingTime.ts` | WPM resolution, dwell math, skim cap scaling | -| `app/src/globalConfig.ts` | Read/write `localStorage.readingProgress` | -| `app/src/components/HomePage/ContinueReading.vue` | Homepage row from saved progress | +| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker on text articles | +| `app/src/composables/useReadingProgressTracker.ts` | Gates, dwell loop, scroll restore | +| `app/src/util/readingTime.ts` | WPM, dwell math, words/sec skim cap | +| `app/src/globalConfig.ts` | `localStorage` read/write | +| `app/src/components/HomePage/ContinueReading.vue` | Homepage row | + +--- -## Constants (quick reference) +## Constants | Constant | Value | Meaning | |----------|-------|---------| | `READING_INTERSECTION_RATIO` | `0.5` | Block must be half visible | -| `READING_BASE_MAX_SCROLL_VELOCITY_PX_S` | `1200` | Skim cap at 200 WPM | -| `READING_MIN_SCROLL_SAMPLE_MS` | `50` | Batch scroll events before velocity check | +| `READING_SKIM_WPM_MULTIPLIER` | `3` | Skim cap = 3× language reading rate (w/s) | +| `READING_MIN_SCROLL_SAMPLE_MS` | `50` | Batch scroll events before words/s check | | `READING_RESTORE_GUARD_MS` | `400` | Ignore tracking after programmatic restore | | `READING_IDLE_MS` | `45000` | Pause dwell after inactivity | | `READING_MIN_DWELL_MS` | `500` | Minimum dwell per block | | `READING_MAX_DWELL_MS` | `8000` | Maximum dwell per block | | `DEFAULT_READING_SPEED_WPM` | `200` | Fallback when language has no WPM | +| `READING_BLOCK_END_TOLERANCE_PX` | `4` | Subpixel tolerance for block-end check | + +--- ## Tests -Unit tests live in: +- `app/src/composables/useReadingProgressTracker.spec.ts` — gates, dwell, skim, restore +- `app/src/util/readingTime.spec.ts` — dwell and words/sec math -- `app/src/composables/useReadingProgressTracker.spec.ts` -- `app/src/util/readingTime.spec.ts` +```sh +cd app && npm run test -- src/util/readingTime.spec.ts src/composables/useReadingProgressTracker.spec.ts +``` From aacd003e3a81abc9f6b4d1c932e93548ffa0fe79 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 08:07:04 +0100 Subject: [PATCH 30/46] Refactor reading progress tracker logic to enable tracking based on text presence, regardless of video content. Update documentation to clarify tracking conditions and improve descriptions of functionality. --- app/src/pages/SingleContent/SingleContent.vue | 2 +- docs/reading-progress-tracker.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index dd2ef2a0c8..541215653d 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -479,7 +479,7 @@ const articleProseRef = ref(null); const scrollContainer = ref(window); const readingTrackerEnabled = computed( - () => !!content.value?._id && !content.value?.video && !!content.value?.text, + () => !!content.value?._id && !!content.value?.text, ); const contentId = computed(() => content.value?._id); diff --git a/docs/reading-progress-tracker.md b/docs/reading-progress-tracker.md index 40a7ebf8e4..646dd514ab 100644 --- a/docs/reading-progress-tracker.md +++ b/docs/reading-progress-tracker.md @@ -1,6 +1,6 @@ # Reading progress tracker -The app tracks how far a user has read through a **text article** and saves that progress locally. The homepage **Continue Reading** row shows articles that are still in progress. +The app tracks how far a user has read through the **text body** of a content page and saves that progress locally. The homepage **Continue Reading** row shows articles that are still in progress. Video (or audio) on the same page does not disable tracking when text is present. Progress is measured **block by block**. A block is only counted as read when the user has actually spent time on it — not when they scroll past quickly. @@ -10,12 +10,13 @@ Visual overview: [`reading-progress-tracker.drawio.svg`](reading-progress-tracke ## When tracking runs -Tracking is enabled on **SingleContent** when all of the following are true: +Tracking is enabled on **SingleContent** when: - The content has **text** (`content.text`) -- The content has **no video** - A content id is present +This applies to text-only posts and to posts that also have a video — only the article body blocks are measured, not video playback. + The tracker watches `articleProseRef` — the `
` that renders the CMS article body. | Included in progress | Not included | @@ -190,7 +191,7 @@ When the user reopens an in-progress article: | File | Role | |------|------| -| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker on text articles | +| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker when content has text | | `app/src/composables/useReadingProgressTracker.ts` | Gates, dwell loop, scroll restore | | `app/src/util/readingTime.ts` | WPM, dwell math, words/sec skim cap | | `app/src/globalConfig.ts` | `localStorage` read/write | From bbe014f1046ecb596c902592b75ae51947b46478 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 11 Jun 2026 08:21:52 +0100 Subject: [PATCH 31/46] Update reading progress tracker tests to use MaybeElement --- app/src/composables/useReadingProgressTracker.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index 8ef4df6317..a29bdf60fb 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -1,3 +1,4 @@ +import type { MaybeElement } from "@vueuse/core"; import { defineComponent, nextTick, ref, watchEffect } from "vue"; import { mount, flushPromises } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; @@ -244,9 +245,9 @@ describe("isBlockEligibleForDwell", () => { describe("resolveActiveBlock", () => { it("returns the topmost unread visible block in reading order", () => { - const blocks = [{ id: 0 }, { id: 1 }, { id: 2 }] as unknown as Element[]; - const visible = new Set([blocks[1], blocks[2]]); - const confirmed = new Set([blocks[0]]); + const blocks = [{ id: 0 }, { id: 1 }, { id: 2 }] as unknown as MaybeElement[]; + const visible = new Set([blocks[1], blocks[2]]); + const confirmed = new Set([blocks[0]]); expect(resolveActiveBlock(blocks, visible, confirmed)).toBe(blocks[1]); }); From 29c9c7efb93465e9fea1df2bf00881fc685dc1cc Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 15 Jun 2026 07:04:50 +0100 Subject: [PATCH 32/46] Add continue-reading prompt and segment-based progress tracking. Split tall prose blocks into viewport-height segments so gate 1b works on mobile, show a resume card on article reopen, and fix tracker tests for jsdom. Co-authored-by: Cursor --- api/src/db/seedingDocs/lang-eng.json | 5 + api/src/db/seedingDocs/lang-fra.json | 5 + .../content/ContinueReadingPrompt.spec.ts | 45 +++ .../content/ContinueReadingPrompt.vue | 82 ++++ .../useReadingProgressTracker.spec.ts | 306 +++++++++++---- .../composables/useReadingProgressTracker.ts | 370 +++++++++++++----- app/src/pages/SingleContent/SingleContent.vue | 15 +- app/src/util/readingTime.ts | 5 - docs/reading-progress-tracker.md | 123 +++--- 9 files changed, 729 insertions(+), 227 deletions(-) create mode 100644 app/src/components/content/ContinueReadingPrompt.spec.ts create mode 100644 app/src/components/content/ContinueReadingPrompt.vue diff --git a/api/src/db/seedingDocs/lang-eng.json b/api/src/db/seedingDocs/lang-eng.json index 94466560f0..a2a063255e 100644 --- a/api/src/db/seedingDocs/lang-eng.json +++ b/api/src/db/seedingDocs/lang-eng.json @@ -50,6 +50,8 @@ "settings.device_info.description": "Provide these details when contacting support", "home.title": "Home", "home.continue": "Continue Watching", + "home.continue.watch": "Continue Watching", + "home.continue.read": "Continue Reading", "home.continueListening": "Continue Listening", "explore.title": "Explore", "explore.other": "Other", @@ -57,6 +59,9 @@ "home.newest": "Newest", "content.related_title": "Related", "content.coming_soon": "Coming soon", + "content.continueReading.prompt": "Continue where you left off?", + "content.continueReading.action": "Continue reading", + "content.continueReading.dismiss": "Start from top", "notification.login.title": "You are missing out!", "notification.login.message": "Click here to create an account or log in", "notification.offline.title": "You are offline.", diff --git a/api/src/db/seedingDocs/lang-fra.json b/api/src/db/seedingDocs/lang-fra.json index d4eec95883..e00d0d8a80 100644 --- a/api/src/db/seedingDocs/lang-fra.json +++ b/api/src/db/seedingDocs/lang-fra.json @@ -50,6 +50,8 @@ "settings.device_info.description": "Fournissez ces détails lors de la prise de contact avec le support", "home.title": "Accueil", "home.continue": "Continuer à regarder", + "home.continue.watch": "Continuer à regarder", + "home.continue.read": "Continuer la lecture", "home.continueListening": "Continuer à écouter", "explore.title": "Explore", "explore.other": "Autre", @@ -57,6 +59,9 @@ "home.newest": "Nouveaux", "content.related_title": "Contenus similaires", "content.coming_soon": "Bientôt disponible", + "content.continueReading.prompt": "Reprendre où vous vous êtes arrêté ?", + "content.continueReading.action": "Continuer la lecture", + "content.continueReading.dismiss": "Recommencer depuis le début", "notification.login.title": "Vous manquez quelque chose!", "notification.login.message": "Cliquez ici pour créer un compte ou vous connecter.", "notification.offline.title": "Vous êtes hors ligne.", diff --git a/app/src/components/content/ContinueReadingPrompt.spec.ts b/app/src/components/content/ContinueReadingPrompt.spec.ts new file mode 100644 index 0000000000..987a808746 --- /dev/null +++ b/app/src/components/content/ContinueReadingPrompt.spec.ts @@ -0,0 +1,45 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import ContinueReadingPrompt from "./ContinueReadingPrompt.vue"; + +describe("ContinueReadingPrompt", () => { + it("renders when visible and emits continue on action click", async () => { + const wrapper = mount(ContinueReadingPrompt, { + props: { + visible: true, + progressPercent: 42, + }, + global: { + mocks: { + t: (key: string) => key, + }, + }, + }); + + expect(wrapper.text()).toContain("content.continueReading.prompt"); + expect(wrapper.text()).toContain("42%"); + + await wrapper.get("button").trigger("click"); + expect(wrapper.emitted("continue")).toHaveLength(1); + }); + + it("hides after dismiss without emitting continue", async () => { + const wrapper = mount(ContinueReadingPrompt, { + props: { + visible: true, + progressPercent: 42, + }, + global: { + mocks: { + t: (key: string) => key, + }, + }, + }); + + const buttons = wrapper.findAll("button"); + await buttons[1].trigger("click"); + + expect(wrapper.emitted("continue")).toBeUndefined(); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + }); +}); diff --git a/app/src/components/content/ContinueReadingPrompt.vue b/app/src/components/content/ContinueReadingPrompt.vue new file mode 100644 index 0000000000..bb45fce624 --- /dev/null +++ b/app/src/components/content/ContinueReadingPrompt.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/src/composables/useReadingProgressTracker.spec.ts b/app/src/composables/useReadingProgressTracker.spec.ts index a29bdf60fb..1d46bf2ac0 100644 --- a/app/src/composables/useReadingProgressTracker.spec.ts +++ b/app/src/composables/useReadingProgressTracker.spec.ts @@ -8,12 +8,17 @@ import { applyScrollVelocitySample, isBlockEndInViewport, isBlockEligibleForDwell, + isSegmentEligible, resolveActiveBlock, + resolveActiveSegment, + segmentWordCount, + splitElementIntoSegments, useReadingProgressTracker, + type ReadingSegment, + type ViewportBounds, } from "./useReadingProgressTracker"; import { DEFAULT_READING_SPEED_WPM, - READING_IDLE_MS, READING_SKIM_WPM_MULTIPLIER, computeBlockDwellMs, computeMaxScrollWordsPerSec, @@ -35,7 +40,7 @@ type MockObserver = { el: Element, isIntersecting: boolean, intersectionRatio?: number, - options?: { blockEndVisible?: boolean }, + options?: { blockEndVisible?: boolean; rect?: { top: number; bottom: number } }, ) => void; }; @@ -58,52 +63,72 @@ function makeRect( } as DOMRectReadOnly; } +class TestIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin: string; + readonly thresholds: ReadonlyArray; + callback: IntersectionObserverCallback; + elements: Element[] = []; + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback; + this.rootMargin = options?.rootMargin ?? "0px"; + const threshold = options?.threshold; + this.thresholds = + threshold === undefined ? [0] : Array.isArray(threshold) ? threshold : [threshold]; + observerInstances.push(this as unknown as MockObserver); + } + + observe(el: Element) { + this.elements.push(el); + } + + unobserve(el: Element) { + this.elements = this.elements.filter((e) => e !== el); + } + + disconnect() { + this.elements = []; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + trigger( + el: Element, + isIntersecting: boolean, + intersectionRatio = READING_INTERSECTION_RATIO, + options?: { blockEndVisible?: boolean; rect?: { top: number; bottom: number } }, + ) { + const blockEndVisible = options?.blockEndVisible ?? isIntersecting; + const rootBounds = makeRect(0, 800); + const boundingClientRect = options?.rect + ? makeRect(options.rect.top, options.rect.bottom) + : blockEndVisible + ? makeRect(100, 400) + : makeRect(100, 900); + + this.callback( + [ + { + target: el, + isIntersecting, + intersectionRatio, + boundingClientRect, + rootBounds, + intersectionRect: boundingClientRect, + time: 0, + } as IntersectionObserverEntry, + ], + this, + ); + } +} + vi.stubGlobal( "IntersectionObserver", - vi.fn().mockImplementation((callback: IntersectionObserverCallback) => { - const instance: MockObserver = { - callback, - elements: [], - observe(el: Element) { - instance.elements.push(el); - }, - unobserve(el: Element) { - instance.elements = instance.elements.filter((e) => e !== el); - }, - disconnect() { - instance.elements = []; - }, - trigger( - el: Element, - isIntersecting: boolean, - intersectionRatio = READING_INTERSECTION_RATIO, - options?: { blockEndVisible?: boolean }, - ) { - const blockEndVisible = options?.blockEndVisible ?? isIntersecting; - const rootBounds = makeRect(0, 800); - const boundingClientRect = blockEndVisible - ? makeRect(100, 400) - : makeRect(100, 900); - - callback( - [ - { - target: el, - isIntersecting, - intersectionRatio, - boundingClientRect, - rootBounds, - intersectionRect: boundingClientRect, - time: 0, - } as IntersectionObserverEntry, - ], - instance as unknown as IntersectionObserver, - ); - }, - }; - observerInstances.push(instance); - return instance; - }), + TestIntersectionObserver as unknown as typeof IntersectionObserver, ); function mountTracker( @@ -111,7 +136,10 @@ function mountTracker( scrollable = false, averageReadingSpeed = DEFAULT_READING_SPEED_WPM, blockTexts?: string[], + elementHeight: number | number[] = 300, ) { + mockElementHeight(elementHeight); + const texts = blockTexts ?? Array.from({ length: blockCount }, (_, i) => `Block ${i + 1}`); @@ -170,6 +198,28 @@ function latestObserver() { return observerInstances[observerInstances.length - 1]; } +function mockElementHeight(heightOrHeights: number | number[]) { + const heights = Array.isArray(heightOrHeights) ? heightOrHeights : null; + const defaultHeight = Array.isArray(heightOrHeights) + ? heightOrHeights[0] + : heightOrHeights; + const original = Element.prototype.getBoundingClientRect; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this.tagName === "P") { + const paragraphs = Array.from(document.querySelectorAll("p")); + const index = paragraphs.indexOf(this as HTMLParagraphElement); + const height = + heights && index >= 0 + ? (heights[index] ?? heights[heights.length - 1]) + : defaultHeight; + return makeRect(0, height); + } + return original.call(this); + }); +} + let rafTime = 0; let perfTime = 0; let rafId = 0; @@ -205,14 +255,81 @@ async function readyScrollableTracker(mountResult: ReturnType { + it("returns one segment when element height fits in the viewport", () => { + const el = document.createElement("p"); + el.textContent = "Short paragraph"; + + const segments = splitElementIntoSegments(el, 800, 0, 400); + expect(segments).toHaveLength(1); + expect(segments[0].segmentCount).toBe(1); + expect(segments[0].bottomPx - segments[0].topPx).toBe(400); + }); + + it("splits tall elements into viewport-height segments", () => { + const el = document.createElement("p"); + el.textContent = "Long paragraph with many words"; + + const segments = splitElementIntoSegments(el, 200, 0, 1000); + expect(segments).toHaveLength(5); + expect(segments[0].segmentIndex).toBe(0); + expect(segments[4].segmentIndex).toBe(4); + expect(segments.every((s) => s.segmentCount === 5)).toBe(true); + }); +}); + +describe("isSegmentEligible", () => { + const viewport: ViewportBounds = { top: 0, bottom: 800 }; + + it("returns true when a middle segment is fully visible", () => { + const el = document.createElement("p"); + const segments = splitElementIntoSegments(el, 200, 0, 1000); + const middle = segments[2]; + + expect( + isSegmentEligible(middle, { top: 100 }, viewport), + ).toBe(true); + }); + + it("returns false when the segment bottom is below the viewport", () => { + const el = document.createElement("p"); + const segment: ReadingSegment = { + id: "test-0", + sourceEl: el, + segmentIndex: 0, + segmentCount: 1, + topPx: 0, + bottomPx: 1200, + }; + + expect( + isSegmentEligible(segment, { top: 100 }, viewport), + ).toBe(false); + }); +}); + +describe("segmentWordCount", () => { + it("allocates words proportionally across segments", () => { + const el = document.createElement("p"); + el.textContent = "one two three four five six seven eight nine ten"; + const segments = splitElementIntoSegments(el, 200, 0, 1000); + + const total = segments.reduce((sum, s) => sum + segmentWordCount(s, 1000), 0); + expect(total).toBe(countWords(el.textContent)); + }); +}); + describe("isBlockEndInViewport", () => { it("returns true when the block bottom is inside the viewport", () => { expect(isBlockEndInViewport(400, { top: 0, bottom: 800 })).toBe(true); @@ -253,6 +370,17 @@ describe("resolveActiveBlock", () => { }); }); +describe("resolveActiveSegment", () => { + it("returns the topmost unread visible segment in reading order", () => { + const el = document.createElement("p"); + const segments = splitElementIntoSegments(el, 200, 0, 600); + const visible = new Set([segments[1].id, segments[2].id]); + const confirmed = new Set([segments[0].id]); + + expect(resolveActiveSegment(segments, visible, confirmed)).toBe(segments[1]); + }); +}); + describe("applyScrollVelocitySample", () => { const defaultMaxWordsPerSec = computeMaxScrollWordsPerSec(DEFAULT_READING_SPEED_WPM); const wordsPerPixel = estimateWordsPerPixel(100, 500); @@ -342,6 +470,14 @@ describe("useReadingProgressTracker", () => { vi.stubGlobal("cancelAnimationFrame", (id: number) => { pendingRafCallbacks.delete(id); }); + vi.stubGlobal( + "ResizeObserver", + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + })), + ); }); afterEach(() => { @@ -349,6 +485,7 @@ describe("useReadingProgressTracker", () => { localStorage.removeItem("readingProgress"); performanceNowSpy?.mockRestore(); performanceNowSpy = undefined; + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -374,7 +511,9 @@ describe("useReadingProgressTracker", () => { const observer = latestObserver(); const block = observer.elements[0]; - observer.trigger(block, true, READING_INTERSECTION_RATIO, { blockEndVisible: false }); + observer.trigger(block, true, READING_INTERSECTION_RATIO, { + rect: { top: 550, bottom: 850 }, + }); advanceDwellMs(BLOCK_ONE_DWELL_MS); @@ -468,7 +607,9 @@ describe("useReadingProgressTracker", () => { }); it("saves progress after velocity drops and dwell completes at low speed", async () => { - const { wrapper } = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper } = await readyScrollableTracker( + mountTracker(2, true, DEFAULT_READING_SPEED_WPM, undefined, 200), + ); const observer = latestObserver(); const block = observer.elements[0]; @@ -480,10 +621,12 @@ describe("useReadingProgressTracker", () => { wrapper.unmount(); }); - it("auto-restores scroll on mount when saved progress exists", async () => { + it("does not auto-restore scroll on mount when saved progress exists", async () => { setReadingProgress(TEST_CONTENT_ID, 60); - const { wrapper } = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper } = await readyScrollableTracker( + mountTracker(2, true, DEFAULT_READING_SPEED_WPM, undefined, 200), + ); const scrollEl = wrapper.get('[data-test="scroll-container"]').element as HTMLElement; Object.defineProperty(scrollEl, "scrollHeight", { value: 2000, configurable: true }); Object.defineProperty(scrollEl, "clientHeight", { value: 200, configurable: true }); @@ -492,14 +635,16 @@ describe("useReadingProgressTracker", () => { advancePerfTime(500); - expect(scrollToSpy).toHaveBeenCalled(); + expect(scrollToSpy).not.toHaveBeenCalled(); wrapper.unmount(); }); it("does not overwrite saved progress during the restore scroll guard window", async () => { setReadingProgress(TEST_CONTENT_ID, 60); - const { wrapper, trackerApi } = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper, trackerApi } = await readyScrollableTracker( + mountTracker(2, true, DEFAULT_READING_SPEED_WPM, undefined, 200), + ); trackerApi().restoreScrollPosition(); @@ -541,7 +686,9 @@ describe("useReadingProgressTracker", () => { }); it("allows dwell to start after the restore guard window ends", async () => { - const { wrapper, trackerApi } = await readyScrollableTracker(mountTracker(2, true)); + const { wrapper, trackerApi } = await readyScrollableTracker( + mountTracker(2, true, DEFAULT_READING_SPEED_WPM, undefined, 200), + ); trackerApi().restoreScrollPosition(); @@ -556,41 +703,60 @@ describe("useReadingProgressTracker", () => { wrapper.unmount(); }); - it("pauses dwell accumulation after idle timeout", async () => { - const { wrapper } = mountTracker(); + it("tracks progress without scrolling when blocks are visible on screen", async () => { + const { wrapper } = mountTracker(3); await flushPromises(); await nextTick(); const observer = latestObserver(); observer.trigger(observer.elements[0], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); + observer.trigger(observer.elements[1], true); + advanceDwellMs(BLOCK_ONE_DWELL_MS); - advanceDwellMs(200, 16, { complete: false }); - expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(67); + wrapper.unmount(); + }); - advancePerfTime(READING_IDLE_MS + 2000); - flushRafFrame(16); + it("removes local storage entry when progress reaches 100%", async () => { + const { wrapper } = mountTracker(1); + await flushPromises(); + await nextTick(); + + const observer = latestObserver(); + observer.trigger(observer.elements[0], true); advanceDwellMs(BLOCK_ONE_DWELL_MS); expect(getReadingProgress(TEST_CONTENT_ID)).toBe(0); - - window.dispatchEvent(new Event("scroll")); - advanceDwellMs(BLOCK_ONE_DWELL_MS); - expect(getReadingProgress(TEST_CONTENT_ID)).toBe(50); + expect(localStorage.getItem("readingProgress")).toBe("[]"); wrapper.unmount(); }); - it("tracks progress without scrolling when blocks are visible on screen", async () => { - const { wrapper } = mountTracker(3); + it("tracks progress across segments of a tall paragraph", async () => { + const longText = Array.from({ length: 100 }, (_, i) => `word${i}`).join(" "); + const segmentDwell = computeBlockDwellMs( + Math.round(countWords(longText) / 5), + DEFAULT_READING_SPEED_WPM, + ); + + const { wrapper, trackerApi } = await readyScrollableTracker( + mountTracker(1, true, DEFAULT_READING_SPEED_WPM, [longText], 1000), + ); + await flushPromises(); await nextTick(); + expect(trackerApi().segments.value.length).toBe(5); + const observer = latestObserver(); - observer.trigger(observer.elements[0], true); - advanceDwellMs(BLOCK_ONE_DWELL_MS); - observer.trigger(observer.elements[1], true); - advanceDwellMs(BLOCK_ONE_DWELL_MS); + const block = observer.elements[0]; + observer.trigger(block, true, READING_INTERSECTION_RATIO, { + rect: { top: 100, bottom: 300 }, + }); - expect(getReadingProgress(TEST_CONTENT_ID)).toBe(67); + advanceDwellMs(segmentDwell); + + expect(getReadingProgress(TEST_CONTENT_ID)).toBe(20); wrapper.unmount(); }); }); diff --git a/app/src/composables/useReadingProgressTracker.ts b/app/src/composables/useReadingProgressTracker.ts index 2e118298ba..a76deafc01 100644 --- a/app/src/composables/useReadingProgressTracker.ts +++ b/app/src/composables/useReadingProgressTracker.ts @@ -6,7 +6,6 @@ import { setReadingProgress, } from "@/globalConfig"; import { - READING_IDLE_MS, READING_MIN_SCROLL_SAMPLE_MS, computeBlockDwellMs, computeMaxScrollWordsPerSec, @@ -19,12 +18,13 @@ import type { Uuid } from "luminary-shared"; /** * Tracks article reading progress for the "Continue reading" homepage row. * - * A block is confirmed only when all gates pass: + * A segment is confirmed only when all gates pass: * 1. ≥50% visible in the scroll container - * 1b. Block bottom edge inside the viewport - * 2. Scroll speed below skim cap (words/s, from rendered block density) - * 3. Dwell time reached (active block only — topmost unread eligible block) - * 4. User not idle (45 s without scroll or intersection activity) + * 1b. Segment bottom edge inside the viewport + * 2. Scroll speed below skim cap (words/s, from rendered segment density) + * 3. Dwell time reached (active segment only — topmost unread eligible segment) + * + * Long prose blocks are split into viewport-height segments so gate 1b can pass. * * @see docs/reading-progress-tracker.md */ @@ -45,6 +45,15 @@ export const READING_BLOCK_END_TOLERANCE_PX = 4; export type ViewportBounds = { top: number; bottom: number }; +export type ReadingSegment = { + id: string; + sourceEl: Element; + segmentIndex: number; + segmentCount: number; + topPx: number; + bottomPx: number; +}; + export function isBlockEndInViewport( blockBottom: number, viewport: ViewportBounds, @@ -79,6 +88,69 @@ export function isBlockEligibleForDwell( ); } +/** Split a prose element into viewport-height tracking segments. */ +export function splitElementIntoSegments( + sourceEl: Element, + maxSegmentHeight: number, + elementIndex: number, + elementHeightPx?: number, +): ReadingSegment[] { + const height = elementHeightPx ?? sourceEl.getBoundingClientRect().height; + if (height <= 0) return []; + + const effectiveMax = Math.max(1, maxSegmentHeight); + const segmentCount = height <= effectiveMax ? 1 : Math.ceil(height / effectiveMax); + const segmentHeight = height / segmentCount; + + const segments: ReadingSegment[] = []; + for (let i = 0; i < segmentCount; i++) { + segments.push({ + id: `reading-segment-${elementIndex}-${i}`, + sourceEl, + segmentIndex: i, + segmentCount, + topPx: i * segmentHeight, + bottomPx: (i + 1) * segmentHeight, + }); + } + return segments; +} + +/** Word count for a segment — proportional share of the parent element. */ +export function segmentWordCount( + segment: ReadingSegment, + elementHeightPx?: number, +): number { + const totalWords = countWords(segment.sourceEl.textContent ?? ""); + if (totalWords <= 0) return 0; + + const totalHeight = elementHeightPx ?? segment.sourceEl.getBoundingClientRect().height; + if (totalHeight <= 0) return 0; + + const segmentHeight = segment.bottomPx - segment.topPx; + return Math.round((segmentHeight / totalHeight) * totalWords); +} + +export function isSegmentEligible( + segment: ReadingSegment, + elementRect: Pick, + viewport: ViewportBounds, +): boolean { + const segmentTop = elementRect.top + segment.topPx; + const segmentBottom = elementRect.top + segment.bottomPx; + const segmentHeight = segment.bottomPx - segment.topPx; + + if (segmentHeight <= 0) return false; + + const visibleTop = Math.max(segmentTop, viewport.top); + const visibleBottom = Math.min(segmentBottom, viewport.bottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + + if (visibleHeight / segmentHeight < READING_INTERSECTION_RATIO) return false; + + return isBlockEndInViewport(segmentBottom, viewport); +} + /** Topmost unread block that is currently eligible for dwell (reading order). */ export function resolveActiveBlock( blocks: MaybeElement[], @@ -93,6 +165,20 @@ export function resolveActiveBlock( return null; } +/** Topmost unread segment that is currently eligible for dwell (reading order). */ +export function resolveActiveSegment( + segments: ReadingSegment[], + visibleSegments: Set, + confirmedSegments: Set, +): ReadingSegment | null { + for (const segment of segments) { + if (visibleSegments.has(segment.id) && !confirmedSegments.has(segment.id)) { + return segment; + } + } + return null; +} + /** Batched scroll samples for gate 2 (skim detection). */ export type SkimScrollState = { pendingScrollDeltaY: number; @@ -103,7 +189,7 @@ export type SkimScrollState = { /** * Batch short scroll samples, then compare words/s to the skim cap. - * Caller must pass wordsPerPixel from the active block (> 0). + * Caller must pass wordsPerPixel from the active segment (> 0). */ export function applyScrollVelocitySample( state: SkimScrollState, @@ -158,6 +244,14 @@ function getScrollTop(container: HTMLElement | Window): number { return container === window ? window.scrollY : (container as HTMLElement).scrollTop; } +function getMaxSegmentHeight(container: HTMLElement | Window): number { + const height = + container === window + ? window.innerHeight + : (container as HTMLElement).clientHeight; + return height > 0 ? height : window.innerHeight; +} + export function useReadingProgressTracker(options: { contentId: Ref; articleRoot: Ref; @@ -166,15 +260,15 @@ export function useReadingProgressTracker(options: { /** Language averageReadingSpeed (words per minute); defaults to 200 when unset. */ averageReadingSpeed: Ref; }) { - const blocks = ref([]); - const confirmedBlocks = new Set(); - const visibleBlocks = new Set(); - const blockWordsPerPixel = new WeakMap(); - const dwellAccumulatedMs = new Map(); + const segments = ref([]); + const sourceElements = ref([]); + const confirmedSegments = new Set(); + const visibleSegments = new Set(); + const segmentWordsPerPixel = new Map(); + const dwellAccumulatedMs = new Map(); let lastSavedProgress = -1; let lastScrollY = 0; let lastScrollTime = 0; - let lastActivityMs: number | null = null; let skimScrollState: SkimScrollState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, @@ -184,6 +278,7 @@ export function useReadingProgressTracker(options: { let dwellRafId: number | null = null; let lastDwellFrameTime = 0; let trackedContentId: Uuid | undefined; + let resizeObserver: ResizeObserver | null = null; const isRestoring = ref(false); @@ -208,10 +303,6 @@ export function useReadingProgressTracker(options: { : (options.scrollContainer.value as HTMLElement), ); - function touchActivity(timestamp = performance.now()) { - lastActivityMs = timestamp; - } - function stopDwellLoop() { if (dwellRafId != null) { cancelAnimationFrame(dwellRafId); @@ -227,12 +318,12 @@ export function useReadingProgressTracker(options: { function resetTrackingState() { stopDwellLoop(); clearDwellAccumulation(); - confirmedBlocks.clear(); - visibleBlocks.clear(); + confirmedSegments.clear(); + visibleSegments.clear(); + segmentWordsPerPixel.clear(); lastSavedProgress = -1; lastScrollY = 0; lastScrollTime = 0; - lastActivityMs = null; skimScrollState = { pendingScrollDeltaY: 0, pendingScrollDeltaMs: 0, @@ -242,59 +333,130 @@ export function useReadingProgressTracker(options: { isRestoring.value = false; } - function collectBlocks() { + function collectSegments() { if (!options.articleRoot.value) { - blocks.value = []; + segments.value = []; + sourceElements.value = []; return; } - blocks.value = Array.from( + const maxSegmentHeight = getMaxSegmentHeight(options.scrollContainer.value); + const elements = Array.from( options.articleRoot.value.querySelectorAll(BLOCK_SELECTOR), - ).filter((el) => el.textContent?.trim()) as MaybeElement[]; + ).filter((el) => el.textContent?.trim()) as Element[]; + + const allSegments: ReadingSegment[] = []; + elements.forEach((el, index) => { + allSegments.push(...splitElementIntoSegments(el, maxSegmentHeight, index)); + }); + + segments.value = allSegments; + sourceElements.value = elements as MaybeElement[]; + } + + function getViewportBounds( + entry?: Pick, + ): ViewportBounds { + if (entry?.rootBounds) { + return { top: entry.rootBounds.top, bottom: entry.rootBounds.bottom }; + } + + const container = options.scrollContainer.value; + if (container === window) { + return { top: 0, bottom: window.innerHeight }; + } + + const rect = (container as HTMLElement).getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + } + + function cacheSegmentWordsPerPixel(segment: ReadingSegment) { + const segmentHeight = segment.bottomPx - segment.topPx; + const words = segmentWordCount(segment); + segmentWordsPerPixel.set(segment.id, estimateWordsPerPixel(words, segmentHeight)); + } + + function activeSegmentWordsPerPixel(): number { + const active = resolveActiveSegment(segments.value, visibleSegments, confirmedSegments); + if (!active) return 0; + return segmentWordsPerPixel.get(active.id) ?? 0; } - function cacheBlockWordsPerPixel(el: MaybeElement) { - if (!(el instanceof Element)) return; - const height = el.getBoundingClientRect().height; - const words = countWords(el.textContent ?? ""); - blockWordsPerPixel.set(el, estimateWordsPerPixel(words, height)); + function updateVisibilityForElement( + el: Element, + entry?: IntersectionObserverEntry, + ) { + if (entry && !entry.isIntersecting) { + for (const segment of segments.value) { + if (segment.sourceEl !== el) continue; + visibleSegments.delete(segment.id); + cancelDwell(segment.id); + } + return; + } + + const viewport = getViewportBounds(entry); + const rect = entry?.boundingClientRect ?? el.getBoundingClientRect(); + let startedDwell = false; + + for (const segment of segments.value) { + if (segment.sourceEl !== el) continue; + + if (isSegmentEligible(segment, rect, viewport)) { + cacheSegmentWordsPerPixel(segment); + visibleSegments.add(segment.id); + startedDwell = true; + } else { + visibleSegments.delete(segment.id); + cancelDwell(segment.id); + } + } + + if (startedDwell && !isRestoring.value && !skimScrollState.isSkimming) { + ensureDwellLoop(); + } } - function activeBlockWordsPerPixel(): number { - const active = resolveActiveBlock(blocks.value, visibleBlocks, confirmedBlocks); - if (!(active instanceof Element)) return 0; - return blockWordsPerPixel.get(active) ?? 0; + function refreshAllSegmentVisibility() { + for (const el of sourceElements.value) { + if (el instanceof Element) { + updateVisibilityForElement(el); + } + } } /** Restore in-memory confirmed set from saved % so progress never drops on re-setup. */ function seedConfirmedFromSavedProgress() { const id = options.contentId.value; - if (!id || blocks.value.length === 0) return; + if (!id || segments.value.length === 0) return; const saved = getReadingProgress(id); if (saved <= 0) return; + confirmedSegments.clear(); + if (saved >= 100) { - for (const el of blocks.value) { - if (el instanceof Element) confirmedBlocks.add(el); + for (const segment of segments.value) { + confirmedSegments.add(segment.id); } lastSavedProgress = 100; return; } - const count = Math.round((saved / 100) * blocks.value.length); - for (let i = 0; i < count && i < blocks.value.length; i++) { - const el = blocks.value[i]; - if (el instanceof Element) confirmedBlocks.add(el); + const count = Math.round((saved / 100) * segments.value.length); + for (let i = 0; i < count && i < segments.value.length; i++) { + confirmedSegments.add(segments.value[i].id); } lastSavedProgress = saved; } function persistProgress() { const id = options.contentId.value; - if (!id || blocks.value.length === 0) return; + if (!id || segments.value.length === 0) return; - const computedProgress = Math.round((confirmedBlocks.size / blocks.value.length) * 100); + const computedProgress = Math.round( + (confirmedSegments.size / segments.value.length) * 100, + ); const existing = getReadingProgress(id); const progress = Math.max(existing, computedProgress); @@ -308,20 +470,19 @@ export function useReadingProgressTracker(options: { } } - function markBlockRead(el: MaybeElement) { - if (!el || confirmedBlocks.has(el)) return; - confirmedBlocks.add(el); + function markSegmentRead(segmentId: string) { + if (confirmedSegments.has(segmentId)) return; + confirmedSegments.add(segmentId); persistProgress(); } - function blockDwellMs(el: MaybeElement): number { - if (!(el instanceof Element)) return computeBlockDwellMs(0); - const words = countWords(el.textContent ?? ""); + function segmentDwellMs(segment: ReadingSegment): number { + const words = segmentWordCount(segment); return computeBlockDwellMs(words, options.averageReadingSpeed.value); } - function cancelDwell(el: MaybeElement) { - dwellAccumulatedMs.delete(el); + function cancelDwell(segmentId: string) { + dwellAccumulatedMs.delete(segmentId); } /** Gate 2: stationary reading after a skim burst should resume dwell. */ @@ -345,11 +506,6 @@ export function useReadingProgressTracker(options: { lastDwellFrameTime = timestamp; return; } - if (lastActivityMs !== null && now - lastActivityMs >= READING_IDLE_MS) { - clearDwellAccumulation(); - lastDwellFrameTime = timestamp; - return; - } if (lastDwellFrameTime === 0) { lastDwellFrameTime = timestamp; @@ -360,17 +516,21 @@ export function useReadingProgressTracker(options: { lastDwellFrameTime = timestamp; if (elapsed <= 0) return; - const activeBlock = resolveActiveBlock(blocks.value, visibleBlocks, confirmedBlocks); - if (!activeBlock || confirmedBlocks.has(activeBlock)) return; + const activeSegment = resolveActiveSegment( + segments.value, + visibleSegments, + confirmedSegments, + ); + if (!activeSegment || confirmedSegments.has(activeSegment.id)) return; - const requiredMs = blockDwellMs(activeBlock); - const accumulated = (dwellAccumulatedMs.get(activeBlock) ?? 0) + elapsed; + const requiredMs = segmentDwellMs(activeSegment); + const accumulated = (dwellAccumulatedMs.get(activeSegment.id) ?? 0) + elapsed; if (accumulated >= requiredMs) { - dwellAccumulatedMs.delete(activeBlock); - markBlockRead(activeBlock); + dwellAccumulatedMs.delete(activeSegment.id); + markSegmentRead(activeSegment.id); } else { - dwellAccumulatedMs.set(activeBlock, accumulated); + dwellAccumulatedMs.set(activeSegment.id, accumulated); } } @@ -379,27 +539,18 @@ export function useReadingProgressTracker(options: { if (lastDwellFrameTime === 0) { lastDwellFrameTime = performance.now(); } - if (lastActivityMs === null) { - touchActivity(); - } dwellRafId = requestAnimationFrame(tickDwell); } } - function startDwellIfEligible(el: MaybeElement) { - if (!el || confirmedBlocks.has(el)) return; - if (!visibleBlocks.has(el) || isRestoring.value || skimScrollState.isSkimming) return; - ensureDwellLoop(); - } - function restartDwellForVisibleBlocksAfterSpeedChange() { clearDwellAccumulation(); ensureDwellLoop(); } - function cancelDwellForVisibleBlocks() { - for (const el of visibleBlocks) { - cancelDwell(el); + function cancelDwellForVisibleSegments() { + for (const segmentId of visibleSegments) { + cancelDwell(segmentId); } } @@ -414,10 +565,9 @@ export function useReadingProgressTracker(options: { const container = options.scrollContainer.value; const scrollY = getScrollTop(container); const now = performance.now(); - touchActivity(now); if (lastScrollTime > 0) { - const wordsPerPixel = activeBlockWordsPerPixel(); + const wordsPerPixel = activeSegmentWordsPerPixel(); if (wordsPerPixel > 0) { const { isSkimming, justStoppedSkimming, state } = applyScrollVelocitySample( @@ -445,32 +595,51 @@ export function useReadingProgressTracker(options: { lastScrollY = scrollY; lastScrollTime = now; + refreshAllSegmentVisibility(); } function handleIntersection(entries: IntersectionObserverEntry[]) { - touchActivity(); for (const entry of entries) { - const el = entry.target as MaybeElement; - const eligible = isBlockEligibleForDwell(entry); - - if (eligible) { - cacheBlockWordsPerPixel(el); - visibleBlocks.add(el); - startDwellIfEligible(el); - } else { - visibleBlocks.delete(el); - cancelDwell(el); - } + updateVisibilityForElement(entry.target as Element, entry); } } - const { stop: stopObserver } = useIntersectionObserver(blocks, handleIntersection, { + const { stop: stopObserver } = useIntersectionObserver(sourceElements, handleIntersection, { root: observerRoot, threshold: [0, READING_INTERSECTION_RATIO, 1], }); useEventListener(options.scrollContainer, "scroll", onScroll, { passive: true }); + function setupResizeObserver() { + resizeObserver?.disconnect(); + resizeObserver = null; + + const container = options.scrollContainer.value; + const target = + container === window + ? document.documentElement + : container instanceof HTMLElement + ? container + : null; + + if (!target || typeof ResizeObserver === "undefined") return; + + resizeObserver = new ResizeObserver(() => { + if (!options.enabled.value) return; + const prevLength = segments.value.length; + collectSegments(); + if (segments.value.length !== prevLength) { + visibleSegments.clear(); + segmentWordsPerPixel.clear(); + clearDwellAccumulation(); + seedConfirmedFromSavedProgress(); + refreshAllSegmentVisibility(); + } + }); + resizeObserver.observe(target); + } + function restoreScrollPosition() { const id = options.contentId.value; if (!id) return; @@ -491,7 +660,7 @@ export function useReadingProgressTracker(options: { const targetY = Math.round((percent / 100) * maxScroll); isRestoring.value = true; - cancelDwellForVisibleBlocks(); + cancelDwellForVisibleSegments(); if (container === window) { window.scrollTo({ top: targetY }); @@ -513,25 +682,24 @@ export function useReadingProgressTracker(options: { isSkimming: false, }; lastScrollY = getScrollTop(container); - touchActivity(); + refreshAllSegmentVisibility(); }, READING_RESTORE_GUARD_MS); }, 300); } function setup(contentChanged: boolean) { - collectBlocks(); + collectSegments(); + setupResizeObserver(); if (!options.enabled.value) return; if (contentChanged) { - if (blocks.value.length > 0) { + if (segments.value.length > 0) { seedConfirmedFromSavedProgress(); } - touchActivity(); - restoreScrollPosition(); } - if (blocks.value.length === 0) return; + if (segments.value.length === 0) return; } watch(options.averageReadingSpeed, () => { @@ -544,7 +712,10 @@ export function useReadingProgressTracker(options: { ([enabled, id], oldValues) => { if (!enabled) { resetTrackingState(); - blocks.value = []; + segments.value = []; + sourceElements.value = []; + resizeObserver?.disconnect(); + resizeObserver = null; trackedContentId = undefined; return; } @@ -568,10 +739,13 @@ export function useReadingProgressTracker(options: { onUnmounted(() => { resetTrackingState(); stopObserver(); + resizeObserver?.disconnect(); + resizeObserver = null; }); return { - blocks, + segments, + sourceElements, isRestoring, savedProgressPercent, hasResumableProgress, diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index 541215653d..0979266f04 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -60,6 +60,7 @@ import LoadingBar from "@/components/LoadingBar.vue"; import { activeImageCollection } from "@/components/images/LImageProvider.vue"; import { isExternalNavigation } from "@/router"; import VideoPlayer from "@/components/content/VideoPlayer.vue"; +import ContinueReadingPrompt from "@/components/content/ContinueReadingPrompt.vue"; import LHighlightable from "@/components/common/LHighlightable.vue"; import DropdownMenu from "@/components/common/DropdownMenu.vue"; import { markPageReady } from "@/util/renderState"; @@ -496,7 +497,12 @@ function setScrollContainer() { scrollContainer.value = resolveArticleScrollContainer(); } -useReadingProgressTracker({ +const { + hasResumableProgress, + savedProgressPercent, + isRestoring, + restoreScrollPosition, +} = useReadingProgressTracker({ contentId, articleRoot: articleProseRef, scrollContainer, @@ -948,6 +954,13 @@ watch([isLoading, content, is404], async () => { + +
diff --git a/app/src/util/readingTime.ts b/app/src/util/readingTime.ts index b7ac45ecdf..cb38707cfe 100644 --- a/app/src/util/readingTime.ts +++ b/app/src/util/readingTime.ts @@ -27,11 +27,6 @@ export const READING_SKIM_WPM_MULTIPLIER = 3; /** Batch scroll events shorter than this before measuring words/s (trackpad jitter). */ export const READING_MIN_SCROLL_SAMPLE_MS = 50; -// --- Gate 4: idle pause ---------------------------------------------------------- - -/** Pause dwell when the user has not scrolled or changed visibility for this long. */ -export const READING_IDLE_MS = 45_000; - // --- Language WPM ---------------------------------------------------------------- export function resolveReadingSpeedWpm(wordsPerMinute?: number | null): number { diff --git a/docs/reading-progress-tracker.md b/docs/reading-progress-tracker.md index 646dd514ab..65f263266a 100644 --- a/docs/reading-progress-tracker.md +++ b/docs/reading-progress-tracker.md @@ -2,7 +2,7 @@ The app tracks how far a user has read through the **text body** of a content page and saves that progress locally. The homepage **Continue Reading** row shows articles that are still in progress. Video (or audio) on the same page does not disable tracking when text is present. -Progress is measured **block by block**. A block is only counted as read when the user has actually spent time on it — not when they scroll past quickly. +Progress is measured **segment by segment**. A segment is only counted as read when the user has actually spent time on it — not when they scroll past quickly. Visual overview: [`reading-progress-tracker.drawio.svg`](reading-progress-tracker.drawio.svg) (open in [draw.io](https://app.diagrams.net/)). @@ -23,18 +23,31 @@ The tracker watches `articleProseRef` — the `
` that renders |----------------------|--------------| | `p`, `h1`–`h4`, `li`, `blockquote`, `pre` inside the article body | Page title, hero image, summary, author, reading time, publish date, tags, copyright footer | -Progress starts at the **first block in the article body**. If the HTML begins with an `

`, that heading is block 1. +Progress starts at the **first segment in the article body**. If the HTML begins with an `

`, that heading is segment 1. + +--- + +## Segments (viewport-capped blocks) + +Each prose DOM node (`p`, heading, etc.) becomes one or more **tracking segments**: + +- If the rendered element height fits within the scroll container’s `clientHeight`, it is a single segment. +- If the element is taller than the viewport (e.g. a long paragraph on a phone), it is split into equal-height bands — each band is at most one viewport tall. + +This ensures gate 1b (segment bottom in viewport) can pass on small screens. The prose HTML is **not** modified. + +On resize or rotation, segments are re-collected and progress is re-seeded from the saved percentage so it does not drop. --- ## How progress is calculated ``` -progress % = round(confirmed blocks ÷ total blocks × 100) +progress % = round(confirmed segments ÷ total segments × 100) ``` -1. On article open, the tracker collects all blocks from the prose root. -2. Each block must pass **four gates** before it is added to the `confirmed` set. +1. On article open, the tracker collects segments from the prose root. +2. Each segment must pass **three gates** before it is added to the `confirmed` set. 3. When the confirmed count changes, the percentage is saved to `localStorage` (`readingProgress` key). 4. At **100%**, the entry is **removed** — the article is finished, not “in progress”. 5. Progress **never decreases** for a given article (`Math.max(existing, computed)`). @@ -43,60 +56,59 @@ On re-open, the saved percentage **seeds** the confirmed set so progress does no --- -## The four gates +## The three gates -A block is confirmed only when **all** gates pass at the same time. +A segment is confirmed only when **all** gates pass at the same time. ```mermaid flowchart TD - Start[Block becomes visible] --> G1[Gate 1: 50% visible] - G1 --> G1b[Gate 1b: Block end in viewport] + Start[Segment becomes visible] --> G1[Gate 1: 50% visible] + G1 --> G1b[Gate 1b: Segment end in viewport] G1b --> G2[Gate 2: Scroll speed below skim cap] G2 --> G3[Gate 3: Dwell time reached] - G3 --> Done[Block confirmed → save progress] + G3 --> Done[Segment confirmed → save progress] G2 -->|skimming| Reset[Clear partial dwell] - G1 -->|leaves viewport| Cancel[Discard partial dwell for that block] + G1 -->|leaves viewport| Cancel[Discard partial dwell for that segment] ``` ### Gate 1 — Visibility -- At least **50%** of the block must be visible in the scroll container (`READING_INTERSECTION_RATIO = 0.5`). +- At least **50%** of the **segment** must be visible in the scroll container (`READING_INTERSECTION_RATIO = 0.5`). - The scroll root is `
` when it scrolls (`resolveArticleScrollContainer()`), not the window. -- When a block leaves the viewport, any partial dwell for that block is discarded. +- When a segment leaves the viewport, any partial dwell for that segment is discarded. -### Gate 1b — Block end in viewport +### Gate 1b — Segment end in viewport -- The **bottom edge** of the block must be inside the visible scroll area. -- This ensures the user has scrolled through the block, not just glimpsed the top. +- The **bottom edge** of the segment band must be inside the visible scroll area. +- This ensures the user has scrolled through the segment, not just glimpsed the top. ### Gate 2 — Scroll speed (skim detection) Dwell only accumulates while the user is scrolling slowly enough to be reading. Fast scrolling is treated as **skimming**. -Scroll speed is measured in **words per second**, derived from the block’s **rendered layout** on the current device. This keeps skim detection consistent across phone, tablet, and desktop without a separate viewport lookup. +Scroll speed is measured in **words per second**, derived from the segment’s **rendered layout** on the current device. This keeps skim detection consistent across phone, tablet, and desktop without a separate viewport lookup. -#### Step 1 — Word density per block +#### Step 1 — Word density per segment -When a block becomes eligible, cache its vertical word density (recomputed on each intersection update, including resize/rotate): +When a segment becomes eligible, cache its vertical word density (recomputed on each intersection update, including resize/rotate): ``` -wordCount = countWords(block.textContent) -blockHeight = block.getBoundingClientRect().height -wordsPerPixel = wordCount / blockHeight (0 when height is 0) +segmentWords = round((segmentHeight / elementHeight) × countWords(element)) +wordsPerPixel = segmentWords / segmentHeight (0 when height is 0) ``` -Font size, zoom, and line wrapping are already reflected in `blockHeight`. +Font size, zoom, and line wrapping are already reflected in the rendered heights. #### Step 2 — Words per second -On each scroll event, use the **active block** — the topmost unread block that passes gates 1 and 1b: +On each scroll event, use the **active segment** — the topmost unread segment that passes gates 1 and 1b: ``` wordsScrolled = abs(scrollDeltaY) × wordsPerPixel wordsPerSecond = wordsScrolled / (deltaMs / 1000) ``` -If no block is visible (`wordsPerPixel = 0`), scroll speed is not evaluated. +If no segment is visible (`wordsPerPixel = 0`), scroll speed is not evaluated. Scroll samples shorter than **50 ms** are batched first (`READING_MIN_SCROLL_SAMPLE_MS`) so fast trackpad flings are not missed between events. @@ -117,44 +129,40 @@ When `wordsPerSecond > maxWordsPerSec`: - dwell stops accumulating - all partial dwell is cleared -- when scrolling slows or stops (no scroll for 50 ms), dwell starts fresh for the active block +- when scrolling slows or stops (no scroll for 50 ms), dwell starts fresh for the active segment ### Gate 3 — Dwell time Dwell is accumulated in **milliseconds** on each animation frame while gates 1, 1b, and 2 pass. It is not a single `setTimeout`. -Only the **active block** (topmost unread eligible block) accumulates dwell at a time. Other visible blocks wait until earlier blocks are confirmed. +Only the **active segment** (topmost unread eligible segment) accumulates dwell at a time. Other visible segments wait until earlier segments are confirmed. -Required dwell per block: +Required dwell per segment: ``` -dwellMs = (blockWordCount ÷ languageWPM) × 60 000 +dwellMs = (segmentWordCount ÷ languageWPM) × 60 000 clamped to 500 ms … 8 000 ms ``` | Constant | Value | Purpose | |----------|-------|---------| -| `READING_MIN_DWELL_MS` | 500 ms | Minimum time even for tiny blocks | -| `READING_MAX_DWELL_MS` | 8000 ms | Cap for very long blocks | +| `READING_MIN_DWELL_MS` | 500 ms | Minimum time even for tiny segments | +| `READING_MAX_DWELL_MS` | 8000 ms | Cap for very long segments | -When accumulated dwell reaches the threshold, the block is confirmed and progress is saved (if the percentage increased). +When accumulated dwell reaches the threshold, the segment is confirmed and progress is saved (if the percentage increased). -**Short articles:** If every block is already visible without scrolling (e.g. on a large desktop screen), dwell still accumulates via the animation-frame loop. - -### Gate 4 — Idle pause - -If there is no scroll or intersection activity for **45 s** (`READING_IDLE_MS`), dwell stops until the user interacts again. +**Short articles:** If every segment is already visible without scrolling (e.g. on a large desktop screen), dwell still accumulates via the animation-frame loop. --- -## Active block +## Active segment -The **active block** is the first block in document order that is: +The **active segment** is the first segment in document order that is: - visible and eligible (gates 1 + 1b), and - not yet confirmed -It drives both **skim detection** (whose `wordsPerPixel` to use) and **dwell accumulation** (only this block gains dwell per frame). +It drives both **skim detection** (whose `wordsPerPixel` to use) and **dwell accumulation** (only this segment gains dwell per frame). --- @@ -178,12 +186,20 @@ It drives both **skim detection** (whose `wordsPerPixel` to use) and **dwell acc --- -## Return visit — scroll restore +## Return visit — optional continue prompt + +When the user reopens an in-progress article, a **Continue reading** card slides in from the right on SingleContent (not the notification system). The user chooses: + +- **Continue reading** — scrolls to the saved position after **300 ms** +- **Start from top** — dismisses the prompt; saved progress is kept + +During programmatic restore, for **400 ms** (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the scroll jump does not count as reading. + +--- -When the user reopens an in-progress article: +## Future: time-on-article analytics -1. After **300 ms**, the scroll container jumps to `progress%` of max scroll. -2. For **400 ms** after that (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the programmatic scroll does not count as reading. +Recording how long a user spends on an article (idle pause, total read time) is deferred to a follow-up ticket. The segment + dwell model is designed to support that later. --- @@ -191,11 +207,12 @@ When the user reopens an in-progress article: | File | Role | |------|------| -| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker when content has text | -| `app/src/composables/useReadingProgressTracker.ts` | Gates, dwell loop, scroll restore | +| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker and continue prompt | +| `app/src/composables/useReadingProgressTracker.ts` | Segments, gates, dwell loop, scroll restore | | `app/src/util/readingTime.ts` | WPM, dwell math, words/sec skim cap | | `app/src/globalConfig.ts` | `localStorage` read/write | | `app/src/components/HomePage/ContinueReading.vue` | Homepage row | +| `app/src/components/content/ContinueReadingPrompt.vue` | In-article resume prompt | --- @@ -203,23 +220,23 @@ When the user reopens an in-progress article: | Constant | Value | Meaning | |----------|-------|---------| -| `READING_INTERSECTION_RATIO` | `0.5` | Block must be half visible | +| `READING_INTERSECTION_RATIO` | `0.5` | Segment must be half visible | | `READING_SKIM_WPM_MULTIPLIER` | `3` | Skim cap = 3× language reading rate (w/s) | | `READING_MIN_SCROLL_SAMPLE_MS` | `50` | Batch scroll events before words/s check | | `READING_RESTORE_GUARD_MS` | `400` | Ignore tracking after programmatic restore | -| `READING_IDLE_MS` | `45000` | Pause dwell after inactivity | -| `READING_MIN_DWELL_MS` | `500` | Minimum dwell per block | -| `READING_MAX_DWELL_MS` | `8000` | Maximum dwell per block | +| `READING_MIN_DWELL_MS` | `500` | Minimum dwell per segment | +| `READING_MAX_DWELL_MS` | `8000` | Maximum dwell per segment | | `DEFAULT_READING_SPEED_WPM` | `200` | Fallback when language has no WPM | -| `READING_BLOCK_END_TOLERANCE_PX` | `4` | Subpixel tolerance for block-end check | +| `READING_BLOCK_END_TOLERANCE_PX` | `4` | Subpixel tolerance for segment-end check | --- ## Tests -- `app/src/composables/useReadingProgressTracker.spec.ts` — gates, dwell, skim, restore +- `app/src/composables/useReadingProgressTracker.spec.ts` — segments, gates, dwell, skim, restore - `app/src/util/readingTime.spec.ts` — dwell and words/sec math +- `app/src/components/content/ContinueReadingPrompt.spec.ts` — resume prompt UI ```sh -cd app && npm run test -- src/util/readingTime.spec.ts src/composables/useReadingProgressTracker.spec.ts +cd app && npm run test -- src/util/readingTime.spec.ts src/composables/useReadingProgressTracker.spec.ts src/components/content/ContinueReadingPrompt.spec.ts ``` From 5741ec40b69a7110ab7061566f2c05d6f08ec40f Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 15 Jun 2026 07:08:23 +0100 Subject: [PATCH 33/46] Refactor ContinueReadingPrompt component to simplify visibility logic and update event emissions. The prompt now directly emits 'continue' and 'dismiss' events without intermediate state management. Update SingleContent page to handle prompt visibility based on user interaction. --- .../content/ContinueReadingPrompt.spec.ts | 4 +-- .../content/ContinueReadingPrompt.vue | 30 +++---------------- app/src/pages/SingleContent/SingleContent.vue | 18 +++++++++-- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/app/src/components/content/ContinueReadingPrompt.spec.ts b/app/src/components/content/ContinueReadingPrompt.spec.ts index 987a808746..1c7b8a4bb5 100644 --- a/app/src/components/content/ContinueReadingPrompt.spec.ts +++ b/app/src/components/content/ContinueReadingPrompt.spec.ts @@ -23,7 +23,7 @@ describe("ContinueReadingPrompt", () => { expect(wrapper.emitted("continue")).toHaveLength(1); }); - it("hides after dismiss without emitting continue", async () => { + it("emits dismiss without emitting continue", async () => { const wrapper = mount(ContinueReadingPrompt, { props: { visible: true, @@ -39,7 +39,7 @@ describe("ContinueReadingPrompt", () => { const buttons = wrapper.findAll("button"); await buttons[1].trigger("click"); + expect(wrapper.emitted("dismiss")).toHaveLength(1); expect(wrapper.emitted("continue")).toBeUndefined(); - expect(wrapper.find('[role="dialog"]').exists()).toBe(false); }); }); diff --git a/app/src/components/content/ContinueReadingPrompt.vue b/app/src/components/content/ContinueReadingPrompt.vue index bb45fce624..78155fe74c 100644 --- a/app/src/components/content/ContinueReadingPrompt.vue +++ b/app/src/components/content/ContinueReadingPrompt.vue @@ -1,8 +1,7 @@ diff --git a/docs/features/reading-progress-tracker/README.md b/docs/features/reading-progress-tracker/README.md index 4e6e83023f..797851f6fd 100644 --- a/docs/features/reading-progress-tracker/README.md +++ b/docs/features/reading-progress-tracker/README.md @@ -5,7 +5,7 @@ - App handbook (setup and env): [`app/README.md`](../../../app/README.md) - Implementation: [`app/src/composables/useReadingProgressTracker.ts`](../../../app/src/composables/useReadingProgressTracker.ts) -The app tracks how far a user has read through the **text body** of a content page and saves that progress locally. The homepage **Continue Reading** row shows articles that are still in progress. Video (or audio) on the same page does not disable tracking when text is present. +The app tracks how far a user has read through the **text body** of a content page and saves that progress locally. The homepage **Continue** row shows content still in progress (reading and/or video). Video (or audio) on the same page does not disable tracking when text is present. Progress is measured **segment by segment**. A segment is only counted as read when the user has actually spent time on it — not when they scroll past quickly. @@ -53,7 +53,7 @@ progress % = round(confirmed segments ÷ total segments × 100) 1. On article open, the tracker collects segments from the prose root. 2. Each segment must pass **three gates** before it is added to the `confirmed` set. -3. When the confirmed count changes, the percentage is saved to `localStorage` (`readingProgress` key). +3. When the confirmed count changes, the percentage is saved to `localStorage` (`contentProgress` key, `reading` field on the content entry). 4. At **100%**, the entry is **removed** — the article is finished, not “in progress”. 5. Progress **never decreases** for a given article (`Math.max(existing, computed)`). @@ -173,21 +173,33 @@ It drives both **skim detection** (whose `wordsPerPixel` to use) and **dwell acc ## Storage -**Key:** `localStorage.readingProgress` +**Key:** `localStorage.contentProgress` **Shape:** ```json -[{ "contentId": "…", "progress": 42 }] +[ + { + "contentId": "…", + "updatedAt": 1710000000000, + "watching": { "mediaId": "…", "progress": 90, "duration": 180 }, + "reading": { "progress": 42 } + } +] ``` +Each entry is keyed by `contentId`. `watching` tracks video/audio playback (seconds); `reading` tracks article progress (0–100%). Both can be present on the same content. Entries are ordered by most recently updated; at most **10** are kept. + +On first load, legacy `readingProgress` and `mediaProgress` keys are merged into `contentProgress` and removed. + **API** (`app/src/globalConfig.ts`): -- `setReadingProgress(contentId, progress)` — save or update -- `getReadingProgress(contentId)` — read percentage (0 if missing) -- `removeReadingProgress(contentId)` — called automatically at 100% +- `setReadingProgress(contentId, progress)` / `getReadingProgress(contentId)` / `removeReadingProgress(contentId)` +- `setMediaProgress(mediaId, contentId, progress, duration)` / `getMediaProgress` / `removeMediaProgress` +- `contentProgressAsRef` — reactive list for the homepage row +- `syncContentProgressFromStorage()` / `watchContentProgressStorage()` -**Homepage:** `ContinueReading.vue` reads this list, queries published content by id, and renders a horizontal tile row. +**Homepage:** `Continue.vue` reads `contentProgressAsRef`, queries published content by id, and renders a horizontal tile row (video and reading in progress together). --- @@ -215,8 +227,8 @@ Recording how long a user spends on an article (idle pause, total read time) is | `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker and continue prompt | | `app/src/composables/useReadingProgressTracker.ts` | Segments, gates, dwell loop, scroll restore | | `app/src/util/readingTime.ts` | WPM, dwell math, words/sec skim cap | -| `app/src/globalConfig.ts` | `localStorage` read/write | -| `app/src/components/HomePage/ContinueReading.vue` | Homepage row | +| `app/src/globalConfig.ts` | `localStorage` read/write (`contentProgress`) | +| `app/src/components/HomePage/Continue.vue` | Homepage row | | `app/src/components/content/ContinueReadingPrompt.vue` | In-article resume prompt | --- From 90ca0c49fbc6099f0aed96c4b2896cf41768d93c Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Mon, 15 Jun 2026 14:52:13 +0100 Subject: [PATCH 41/46] Enhance SingleContent tests with improved progress tracking and cleanup This commit updates the SingleContent test suite to incorporate better handling of content progress tracking. Key changes include: - Added `removeReadingProgress` and `syncContentProgressFromStorage` to ensure proper state management during tests. - Cleared local storage and reset timers in the `afterEach` hook for consistent test environments. - Replaced `waitForExpect` with `flushPromises` and `nextTick` for more reliable asynchronous handling. These improvements aim to enhance test reliability and maintainability. --- .../SingleContent/__tests__/SingleContent.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts index fcf3a41cca..28934b16cb 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts @@ -26,7 +26,9 @@ import { appName, getReadingProgress, initLanguage, + removeReadingProgress, setReadingProgress, + syncContentProgressFromStorage, userPreferencesAsRef, cmsUrl, } from "@/globalConfig"; @@ -118,6 +120,8 @@ describe("SingleContent", () => { // Clearing the database before populating it helps prevent some sequencing issues causing the first to fail. await db.docs.clear(); await db.localChanges.clear(); + localStorage.clear(); + syncContentProgressFromStorage(); // IndexedDB-only path; avoid ApiLiveQuery from other specs leaving isConnected true isConnected.value = false; @@ -150,6 +154,7 @@ describe("SingleContent", () => { // Reset notification store spy vi.clearAllMocks(); + vi.useRealTimers(); (auth0 as any).useAuth0 = vi.fn().mockReturnValue({ isAuthenticated: ref(false), @@ -157,6 +162,9 @@ describe("SingleContent", () => { }); afterEach(async () => { + removeReadingProgress(mockEnglishContentDto._id); + localStorage.removeItem("contentProgress"); + syncContentProgressFromStorage(); await db.docs.clear(); cmsUrl.value = ""; isConnected.value = false; @@ -692,11 +700,11 @@ describe("SingleContent", () => { }, }); - await waitForExpect(() => { - expect(wrapper.text()).toContain(mockEnglishContentDto.title); - }); + await flushPromises(); + await nextTick(); expect(getReadingProgress(mockEnglishContentDto._id)).toBe(60); + wrapper.unmount(); }); }); From 119521252858e3f4fd39c54c3b3668d051694bd9 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Wed, 17 Jun 2026 17:01:03 +0100 Subject: [PATCH 42/46] Unify reading and media progress bars on content tiles. Show max(reading%, watching%) without duration labels so the Continue row treats articles and video consistently. --- .../components/content/ContentTile.spec.ts | 82 +++++++++++++++++-- app/src/components/content/ContentTile.vue | 61 +++++--------- 2 files changed, 96 insertions(+), 47 deletions(-) diff --git a/app/src/components/content/ContentTile.spec.ts b/app/src/components/content/ContentTile.spec.ts index 544db9c77e..1ab328b6d3 100644 --- a/app/src/components/content/ContentTile.spec.ts +++ b/app/src/components/content/ContentTile.spec.ts @@ -224,7 +224,7 @@ describe("ContentTile", () => { expect(playIconOutline.exists()).toBe(false); }); - it("shows the progress and duration if the content has a video", () => { + it("shows the progress bar if the content has a video", () => { const content = { _id: "sample-content-id", title: "Sample Content", @@ -257,9 +257,8 @@ describe("ContentTile", () => { }, }); - // Duration 300s = 5:00 - expect(wrapper.html()).toContain("5:00"); expect(wrapper.html()).toContain('style="width: 40%'); + expect(wrapper.html()).not.toContain("5:00"); }); it("does not show media progress when showProgress is false", () => { @@ -297,7 +296,7 @@ describe("ContentTile", () => { expect(wrapper.html()).not.toContain('style="width: 40%'); }); - it("does not show a progress bar for reading-only content even when showProgress is true", () => { + it("shows a progress bar for reading-only content when showProgress is true", () => { const content = { _id: "sample-reading-id", title: "Reading Article", @@ -326,8 +325,79 @@ describe("ContentTile", () => { }, }); - expect(wrapper.html()).not.toContain("45%"); - expect(wrapper.html()).not.toContain('style="width: 45%'); + expect(wrapper.html()).toContain('style="width: 45%'); + }); + + it("shows reading progress when it is higher than video progress", () => { + const content = { + _id: "sample-mixed-id", + title: "Mixed Content", + slug: "mixed-content", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + video: "sample-mixed-media-id", + text: "

Hello

", + parentId: "post-blog1", + } as unknown as ContentDto; + + setMediaProgress("sample-mixed-media-id", content._id, 120, 300); // 40% + setReadingProgress(content._id, 60); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: true, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + PlayIcon, + PlayIconOutline, + }, + }, + }); + + expect(wrapper.html()).toContain('style="width: 60%'); + }); + + it("shows video progress when it is higher than reading progress", () => { + const content = { + _id: "sample-mixed-id-2", + title: "Mixed Content 2", + slug: "mixed-content-2", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + video: "sample-mixed-media-id-2", + text: "

Hello

", + parentId: "post-blog1", + } as unknown as ContentDto; + + setMediaProgress("sample-mixed-media-id-2", content._id, 210, 300); // 70% + setReadingProgress(content._id, 30); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: true, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + PlayIcon, + PlayIconOutline, + }, + }, + }); + + expect(wrapper.html()).toContain('style="width: 70%'); }); it("renders title on the image in overlay mode without text below", () => { diff --git a/app/src/components/content/ContentTile.vue b/app/src/components/content/ContentTile.vue index b4a62ebfcd..1667ee668e 100644 --- a/app/src/components/content/ContentTile.vue +++ b/app/src/components/content/ContentTile.vue @@ -4,8 +4,8 @@ import { DateTime } from "luxon"; import LImage from "../images/LImage.vue"; import { type AspectRatio, type ImageSize } from "../images/LImageProvider.vue"; import { PlayIcon, SpeakerWaveIcon } from "@heroicons/vue/24/solid"; -import { getMediaDuration, getMediaProgress } from "@/globalConfig"; -import { computed, ref } from "vue"; +import { getMediaDuration, getMediaProgress, getReadingProgress } from "@/globalConfig"; +import { computed } from "vue"; import { useI18n } from "vue-i18n"; const { t } = useI18n(); @@ -50,11 +50,6 @@ const mediaIconClass = computed(() => : "relative z-20 h-8 w-8 text-white lg:h-12 lg:w-12", ); -const media = ref<{ progress: number; duration: number }>({ - progress: 0, - duration: 0, -}); - const isComingSoon = computed(() => { const publishDate = props.content.publishDate; // "Coming soon" = published doc with a future publishDate AND the opt-in flag set. @@ -65,23 +60,10 @@ const isComingSoon = computed(() => { ); }); -function formatDuration(seconds: number): string { - const totalSeconds = Math.floor(seconds); - const hrs = Math.floor(totalSeconds / 3600); - const mins = Math.floor((totalSeconds % 3600) / 60); - const secs = totalSeconds % 60; - - if (hrs > 0) { - return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; - } else { - return `${mins.toString().padStart(1, "0")}:${secs.toString().padStart(2, "0")}`; - } -} - -const durationText = ref(""); -const hasMediaProgress = ref(false); +const displayProgress = computed(() => { + if (!props.showProgress) return 0; -if (props.showProgress) { + let mediaProgressPercent = 0; const mediaIds = props.content.video ? [props.content.video] : (props.content.parentMedia?.fileCollections ?? []).map((f) => f.fileUrl); @@ -91,14 +73,16 @@ if (props.showProgress) { const mediaDuration = getMediaDuration(mediaId, props.content._id); if (mediaProgress > 0 && mediaDuration > 0) { - hasMediaProgress.value = true; - media.value.progress = Math.min(100, (mediaProgress / mediaDuration) * 100); - media.value.duration = mediaDuration; - durationText.value = formatDuration(mediaDuration); + mediaProgressPercent = Math.min(100, (mediaProgress / mediaDuration) * 100); break; } } -} + + const readingProgressPercent = getReadingProgress(props.content._id); + return Math.max(mediaProgressPercent, readingProgressPercent); +}); + +const hasProgress = computed(() => displayProgress.value > 0); From 7b093d5dc8b6003fc0120d28f6c255575ce7548a Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Wed, 17 Jun 2026 17:01:04 +0100 Subject: [PATCH 43/46] Publish desktop sidebar width as a CSS variable. Expose --desktop-sidebar-w so fixed overlays can align with the content column when the sidebar is expanded or collapsed. --- .../components/navigation/DesktopSidebar.vue | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/components/navigation/DesktopSidebar.vue b/app/src/components/navigation/DesktopSidebar.vue index 569e93553e..aecb64bebf 100644 --- a/app/src/components/navigation/DesktopSidebar.vue +++ b/app/src/components/navigation/DesktopSidebar.vue @@ -1,5 +1,5 @@