Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/feedthejoe / src/adapters/observer.js |
| Date | Not specified in source |
| Repository | Janady13/feedthejoe |
| Revision | 0e608a2b515a959bf35d2783428766624400ca3c |
| Branch | main |
| Path or archive member | src/adapters/observer.js |
| Original SHA-256 | e6d2bb43ccad68b61dfde1204cb04759a305e7bdffefccb2cff0b4593dff283a |
| Git blob SHA | 58d92458f465b0446606ad666efe50e3245fa196 |
| Reading-copy SHA-256 | e6d2bb43ccad68b61dfde1204cb04759a305e7bdffefccb2cff0b4593dff283a |
Recovered source transcript
Download complete source text ↓138 lines · 5.3 KB
/**
* Signal capture: turn browsing behaviour into normalised events.
*
* This is the only file that knows what a scroll or a viewport is. The core
* receives {kind, itemId, completion} and nothing else, which is why the ranker
* can be tested without a DOM.
*
* The hard part is honesty. It is easy to build a capture layer that flatters
* the model — count every card that crossed the viewport as a "read" and the
* feed will look brilliantly engaging while learning nothing. Three rules keep
* it truthful:
*
* 1. Dwell only accrues while the tab is actually foregrounded. A page left
* open overnight must not register as the most compelling thing on the site.
* 2. Dwell is measured against estimated read time, so a long item cannot win
* simply by being long.
* 3. Passing through fast is recorded as a SKIP, not as nothing. A model that
* can only learn from positives drifts toward whatever it showed first.
*/
import { Signal, completionRatio } from '../core/signal/signals.js';
/** A card must be this visible before its dwell clock starts. */
const VISIBLE_RATIO = 0.55;
/** Below this dwell, leaving the viewport counts as a skip rather than a read. */
const SKIP_SECONDS = 1.1;
/** Above this completion ratio we call it a read. */
const READ_RATIO = 0.45;
/**
* @param {object} opts
* @param {HTMLElement} opts.root Container holding the cards.
* @param {Map<string, object>} opts.index itemId -> item (for wordCount).
* @param {(e: import('../ports/types.js').SignalEvent) => void} opts.onSignal
* @returns {{observe: (el: Element) => void, disconnect: () => void}}
*/
export function createObserver({ root, index, onSignal }) {
/** @type {Map<string, {enteredAt: number, dwell: number, seen: boolean}>} */
const tracked = new Map();
let foregrounded = document.visibilityState === 'visible';
const stateFor = (id) => {
let s = tracked.get(id);
if (!s) { s = { enteredAt: 0, dwell: 0, seen: false }; tracked.set(id, s); }
return s;
};
/** Stop the clock on a card and emit whatever that dwell earned. */
function settle(id) {
const s = tracked.get(id);
if (!s || s.enteredAt === 0) return;
s.dwell += (performance.now() - s.enteredAt) / 1000;
s.enteredAt = 0;
const item = index.get(id);
if (!item) return;
if (s.dwell < SKIP_SECONDS) {
onSignal({ kind: Signal.SKIP, itemId: id });
return;
}
const ratio = completionRatio(s.dwell, item.wordCount);
if (ratio >= READ_RATIO) {
// A return visit to something already read is the stronger signal.
onSignal(s.seen
? { kind: Signal.REWATCH, itemId: id }
: { kind: Signal.COMPLETION, itemId: id, completion: ratio });
s.seen = true;
}
s.dwell = 0;
}
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
const id = entry.target.dataset.itemId;
if (!id) continue;
if (entry.isIntersecting && entry.intersectionRatio >= VISIBLE_RATIO) {
if (foregrounded) stateFor(id).enteredAt = performance.now();
} else {
settle(id);
}
}
}, { threshold: [0, VISIBLE_RATIO, 1] });
// Rule 1: the clock stops with the tab. Without this, "left the laptop open"
// becomes the strongest engagement signal on the site.
function onVisibility() {
foregrounded = document.visibilityState === 'visible';
if (!foregrounded) {
for (const [id, s] of tracked) {
if (s.enteredAt !== 0) { s.dwell += (performance.now() - s.enteredAt) / 1000; s.enteredAt = 0; }
}
} else {
for (const el of root.querySelectorAll('[data-item-id]')) {
const r = el.getBoundingClientRect();
const vis = r.top < innerHeight * 0.6 && r.bottom > innerHeight * 0.4;
if (vis) stateFor(el.dataset.itemId).enteredAt = performance.now();
}
}
}
document.addEventListener('visibilitychange', onVisibility);
// Copying a link is an intent to show someone else — the closest honest
// analogue to a share on a surface with no share button.
function onCopy() {
const sel = document.getSelection();
if (!sel || sel.isCollapsed) return;
const node = sel.anchorNode instanceof Element ? sel.anchorNode : sel.anchorNode?.parentElement;
const card = node?.closest?.('[data-item-id]');
if (card?.dataset.itemId) onSignal({ kind: Signal.SHARE, itemId: card.dataset.itemId });
}
document.addEventListener('copy', onCopy);
function onClick(ev) {
const card = ev.target instanceof Element ? ev.target.closest('[data-item-id]') : null;
if (!card) return;
if (ev.target.closest('a[href]')) onSignal({ kind: Signal.EXPAND, itemId: card.dataset.itemId });
}
root.addEventListener('click', onClick);
// A departing visitor's last card never fires an intersection change, so its
// dwell would be lost. pagehide is the reliable terminal event (unload is not,
// and it breaks bfcache).
const onPageHide = () => { for (const id of tracked.keys()) settle(id); };
addEventListener('pagehide', onPageHide);
return {
observe(el) { io.observe(el); },
disconnect() {
io.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('copy', onCopy);
root.removeEventListener('click', onClick);
removeEventListener('pagehide', onPageHide);
},
};
}