/** * The Log: one chronological feed of a user's engagements (reads, plays, watches) and listens, newest first. * * Each item has an `at` sort key: a listen's `listened_at`; an engagement's finish date, else its start * date, else when it was logged. Partial dates sort as strings ("2025-03" sits after "2025-03-31" and * before "2025-04"), never through `Date`. Pages use a keyset cursor over (at, kind, id), all descending, * so equal timestamps across the two kinds page without gaps or repeats. */ import type { Db } from "@sttracker/db/db"; import { fromJson } from "@sttracker/db/json"; import type { PartialDate } from "@sttracker/plugin-sdk/types"; import { sql } from "kysely"; import { fromStoredDate } from "./entries.ts"; import { ValidationError } from "./errors.ts"; export type LogItem = | { kind: "engagement"; id: string; at: string; entryId: string; workId: string; title: string; mediaType: string; coverUrl: string; startedAt: PartialDate | null; finishedAt: PartialDate | null; platform: string | null; rating: number | null; } | { kind: "listen"; id: string; at: string; workId: string | null; title: string; mediaType: "track"; artist: string; track: string; album: string | null; source: string; }; type Kind = LogItem["kind"]; interface Cursor { at: string; kind: Kind; id: string; } function encodeCursor(c: Cursor): string { return Buffer.from(JSON.stringify([c.at, c.kind, c.id])).toString("base64url"); } function decodeCursor(s: string): Cursor { try { const [at, kind, id] = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as unknown[]; if (typeof at === "string" && (kind === "engagement" || kind === "listen") && typeof id === "string") return { at, kind, id }; } catch { // Fall through to the validation error. } throw new ValidationError("cursor", "invalid"); } /** (at, kind, id) < cursor, with this query's fixed kind: kinds sort "listen" > "engagement". */ function after(kind: Kind, c: Cursor): "lt" | "le" | "tie" { if (kind === c.kind) return "tie"; return kind < c.kind ? "le" : "lt"; } export async function listLog( db: Db, userId: string, opts: { limit?: number; cursor?: string } = {}, ): Promise<{ items: LogItem[]; nextCursor: string | null }> { const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); const cursor = opts.cursor ? decodeCursor(opts.cursor) : null; const at = sql`coalesce(nullif(g.finished_at, ''), nullif(g.started_at, ''), g.created_at)`; let eq = db.k .selectFrom("engagements as g") .innerJoin("entries as e", "e.id", "g.entry_id") .innerJoin("works as w", "w.id", "e.work_id") .select([ "g.id", "g.entry_id", "g.started_at", "g.started_precision", "g.finished_at", "g.finished_precision", "g.platform", "g.rating", "e.work_id", "e.overrides", "e.media_type_override", "w.media_type", "w.title", "w.cover_url", at.as("at"), ]) .where("e.user_id", "=", userId) .where("e.deleted_at", "is", null) .where("g.deleted_at", "is", null); let lq = db.k .selectFrom("listens") .select(["id", "listened_at", "artist", "track", "album", "work_id", "source"]) .where("user_id", "=", userId); if (cursor) { const ge = after("engagement", cursor); eq = eq.where((x) => ge === "lt" ? x(at, "<", cursor.at) : ge === "le" ? x(at, "<=", cursor.at) : x.or([x(at, "<", cursor.at), x.and([x(at, "=", cursor.at), x("g.id", "<", cursor.id)])]), ); const gl = after("listen", cursor); lq = lq.where((x) => gl === "lt" ? x("listened_at", "<", cursor.at) : gl === "le" ? x("listened_at", "<=", cursor.at) : x.or([x("listened_at", "<", cursor.at), x.and([x("listened_at", "=", cursor.at), x("id", "<", cursor.id)])]), ); } const [engagements, listens] = await Promise.all([ eq.orderBy(at, "desc").orderBy("g.id", "desc").limit(limit + 1).execute(), lq.orderBy("listened_at", "desc").orderBy("id", "desc").limit(limit + 1).execute(), ]); const merged: LogItem[] = [ ...engagements.map((r): LogItem => { const o = fromJson<{ title?: unknown; coverUrl?: unknown }>(r.overrides, {}); return { kind: "engagement", id: r.id, at: r.at, entryId: r.entry_id, workId: r.work_id, title: typeof o.title === "string" && o.title ? o.title : r.title, mediaType: r.media_type_override ?? r.media_type, coverUrl: typeof o.coverUrl === "string" ? o.coverUrl : r.cover_url, startedAt: fromStoredDate(r.started_at, r.started_precision), finishedAt: fromStoredDate(r.finished_at, r.finished_precision), platform: r.platform, rating: r.rating, }; }), ...listens.map( (r): LogItem => ({ kind: "listen", id: r.id, at: r.listened_at, workId: r.work_id, title: r.track, mediaType: "track", artist: r.artist, track: r.track, album: r.album, source: r.source, }), ), ]; // Same order as the SQL: at, then kind, then id, all descending. Plain code-unit comparison on both sides. const cmp = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0); merged.sort((a, b) => cmp(a.at, b.at) || cmp(a.kind, b.kind) || cmp(a.id, b.id)); const items = merged.slice(0, limit); const last = items[items.length - 1]; return { items, nextCursor: merged.length > limit && last ? encodeCursor(last) : null }; }