Files
webamp/packages/skin-database/app/(modern)/scroll/eventLogger.ts
T
Jordan EldredgeandClaude Opus 5.5 9999094dde Make the new UI's grid and feed two views of the same list
The feed was its own shuffled list (museum favorites plus random skins).
Now it continues through whatever the grid shows, the museum or a search:
tapping a skin opens the feed on it with the list before and after it,
and going back lands the grid on the skin you were on. Feed URLs are
plain permalinks; the list lives in the tab (listPosition.ts), and a
permalink opened fresh is followed by the museum. /feed redirects to the
museum's first skin.

- Event logging v3: one session per tab visit spanning both views, with
  view_switch events and each feed view's list, list index and depth.
  Older events are still accepted.
- The feed loads readmes and like counts on demand.
- Keyboard paging steps from the skin actually on screen, so it can't get
  stuck after a scroll is interrupted.
- Going back to the grid animates the skin into its tile. React's
  <ViewTransition> can't animate Back with Next's router, so it's done
  by hand.
- Grid tiles and view transition snapshots render pixelated.
- Download saves the skin under its file name, not its md5.
- A feed URL for a missing skin returns a 404 status.
- The list position is kept apart from the server actions, so pages with
  only the menu bar don't each compile another copy of the server code;
  this kept the build within the server's memory.
- Declare reselect (used by legacy-client) and give knexfile.ts named
  exports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 10:05:25 -07:00

233 lines
6.4 KiB
TypeScript

"use client";
import {
EVENT_SCHEMA_VERSION,
type LoggedEvent,
type MuseumView,
type UserEvent,
type ViewVia,
} from "./eventTypes";
import { DESKTOP_MEDIA_QUERY } from "./breakpoints";
const ENDPOINT = "/scroll/events";
// Send a batch once this many events are queued, or this long after the first
// one, whichever comes first. Also sent immediately when the page is hidden.
const MAX_BATCH = 20;
const FLUSH_DELAY_MS = 5000;
export type EventLogger = {
log(event: UserEvent): void;
// Send whatever is queued. `unloading` uses sendBeacon, which survives the
// page being hidden or closed.
flush(options?: { unloading?: boolean }): void;
};
/**
* Queues a session's events and sends them to /scroll/events in batches, each
* stamped with the schema version, a per-session sequence number and the
* client's clock.
*/
function createEventLogger(sessionId: string): EventLogger {
let queue: LoggedEvent[] = [];
let seq = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
function flush({ unloading = false } = {}) {
if (timer != null) {
clearTimeout(timer);
timer = null;
}
if (queue.length === 0) return;
const body = JSON.stringify({ sessionId, events: queue });
queue = [];
if (
unloading &&
navigator.sendBeacon?.(
ENDPOINT,
new Blob([body], { type: "application/json" })
)
) {
return;
}
fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {
// Analytics only; don't surface failures.
});
}
function log(event: UserEvent) {
queue.push({
...event,
v: EVENT_SCHEMA_VERSION,
seq: seq++,
t: Date.now(),
});
if (queue.length >= MAX_BATCH) {
flush();
} else if (timer == null) {
timer = setTimeout(flush, FLUSH_DELAY_MS);
}
}
return { log, flush };
}
// The visit's session: everything from opening the museum in a tab until
// it's closed or reloaded, across the grid and the feed.
type Session = {
logger: EventLogger;
started: boolean;
ended: boolean;
// The skin in view in the feed, while it's showing.
feedSkinMd5: string | null;
};
let session: Session | null = null;
const endListeners = new Set<() => void>();
const resumeListeners = new Set<() => void>();
function getSession(): Session {
if (session == null) {
const created: Session = {
logger: createEventLogger(crypto.randomUUID()),
started: false,
ended: false,
feedSkinMd5: null,
};
session = created;
watchPageLifecycle(created);
}
return session;
}
// Log the page being hidden and shown, and send what we have whenever it's
// hidden: mobile browsers often discard backgrounded pages without warning.
// The session ends on pagehide, and resumes if the page comes back from the
// back/forward cache.
function watchPageLifecycle(s: Session): void {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
s.logger.log({ type: "page_hidden", skinMd5: s.feedSkinMd5 });
s.logger.flush({ unloading: true });
} else {
s.logger.log({ type: "page_visible", skinMd5: s.feedSkinMd5 });
}
});
addEventListener("pagehide", () => {
if (s.ended) return;
s.ended = true;
endListeners.forEach((listener) => listener());
s.logger.log({ type: "session_end", reason: "pagehide" });
s.logger.flush({ unloading: true });
});
addEventListener("pageshow", (e) => {
if (!e.persisted || !s.ended) return;
s.ended = false;
s.logger.log({ type: "session_resume" });
resumeListeners.forEach((listener) => listener());
});
}
// The session's logger. The session starts with its first event.
const sessionLogger: EventLogger = {
log(event) {
getSession().logger.log(event);
},
flush(options) {
getSession().logger.flush(options);
},
};
export function useEventLogger(): EventLogger {
return sessionLogger;
}
// How the next view is being reached, set by in-app links just before they
// navigate. Read when the view opens; if nothing set it, it was the browser's
// history (or something we don't know about).
let nextViewVia: ViewVia | null = null;
export function setNextViewVia(via: ViewVia): void {
nextViewVia = via;
}
/**
* Log arriving at a view: the session's start, if this is its first view,
* otherwise a switch to it. Returns how the view was reached ("link" for the
* session's first). Call once per visit to the view.
*/
export function enterView(
view: MuseumView,
context: {
query: string | null;
skinMd5: string | null;
listIndex: number | null;
}
): "link" | ViewVia {
const s = getSession();
const via = nextViewVia ?? "history";
nextViewVia = null;
if (!s.started) {
s.started = true;
s.logger.log({
type: "session_start",
view,
query: context.query,
skinMd5: context.skinMd5,
...getVisitContext(),
});
// Send right away: this is what records the session.
s.logger.flush();
return "link";
}
s.logger.log({ type: "view_switch", to: view, via, ...context });
return via;
}
/** The feed's skin in view (null when leaving the feed), for page_hidden/visible. */
export function setFeedSkin(md5: string | null): void {
getSession().feedSkinMd5 = md5;
}
/** Called when the session ends (before session_end is logged). */
export function onSessionEnd(listener: () => void): () => void {
endListeners.add(listener);
return () => {
endListeners.delete(listener);
};
}
/** Called when an ended session resumes (after session_resume is logged). */
export function onSessionResume(listener: () => void): () => void {
resumeListeners.add(listener);
return () => {
resumeListeners.delete(listener);
};
}
// Context logged when a session starts.
function getVisitContext(): {
referrerHost: string | null;
layout: "mobile" | "desktop";
pointer: "coarse" | "fine";
} {
let referrerHost: string | null = null;
try {
const referrer = document.referrer ? new URL(document.referrer) : null;
if (referrer != null && referrer.host !== location.host) {
referrerHost = referrer.host;
}
} catch {
// Not a URL we can parse; leave it out.
}
return {
referrerHost,
layout: matchMedia(DESKTOP_MEDIA_QUERY).matches ? "desktop" : "mobile",
pointer: matchMedia("(pointer: coarse)").matches ? "coarse" : "fine",
};
}