diff --git a/apps/web/src/labels.ts b/apps/web/src/labels.ts
index 65b3c0f..9a9bca8 100644
--- a/apps/web/src/labels.ts
+++ b/apps/web/src/labels.ts
@@ -147,3 +147,12 @@ export function changeValue(field: string, value: unknown, scale: RatingScale, m
else text = String(value);
return text.length > 160 ? `${text.slice(0, 159)}…` : text;
}
+
+const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
+
+/** The Log's month heading: "2026-10…" → "October 2026"; a bare year stays a year. Never through `Date`. */
+export function logMonth(at: string): string {
+ const y = at.slice(0, 4);
+ const m = Number(at.slice(5, 7));
+ return m >= 1 && m <= 12 ? `${MONTH_NAMES[m - 1]} ${y}` : y;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c93d481..dc51c3c 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,6 +8,7 @@ import { Shell } from "./components/Shell.tsx";
import { useMe } from "./hooks.ts";
import { Changes } from "./routes/changes.tsx";
import { Library } from "./routes/library.tsx";
+import { Log } from "./routes/log.tsx";
import { Login } from "./routes/login.tsx";
import { NotYet } from "./routes/not-yet.tsx";
import { Register } from "./routes/register.tsx";
@@ -59,6 +60,7 @@ function App() {
{() => }
{(p) => }
+
{(p) => }
{(p) => }
diff --git a/apps/web/src/routes/log.tsx b/apps/web/src/routes/log.tsx
new file mode 100644
index 0000000..ee1e4ee
--- /dev/null
+++ b/apps/web/src/routes/log.tsx
@@ -0,0 +1,119 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Link } from "wouter";
+import { api, type LogItem } from "../api.ts";
+import { Cover } from "../components/Cover.tsx";
+import { LoadError } from "../components/LoadError.tsx";
+import { describeRating, engagementNouns, formatDateRange, formatRating, mediaTypeLabel, MONTHS, shortDate } from "../format.ts";
+import { logMonth } from "../labels.ts";
+import { useRatingScale } from "../settings.ts";
+
+function listenTime(at: string): string {
+ if (at.length <= 10) return shortDate(at);
+ const d = new Date(at);
+ if (Number.isNaN(d.getTime())) return at;
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+
+export function Log() {
+ const scale = useRatingScale();
+ const log = useInfiniteQuery({
+ queryKey: ["log"],
+ queryFn: ({ pageParam }) => api.log(pageParam),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (p) => p.nextCursor ?? undefined,
+ });
+ const items = log.data?.pages.flatMap((p) => p.items) ?? [];
+ const groups: { month: string; items: LogItem[] }[] = [];
+ for (const it of items) {
+ const month = logMonth(it.at);
+ const last = groups[groups.length - 1];
+ if (last && last.month === month) last.items.push(it);
+ else groups.push({ month, items: [it] });
+ }
+
+ return (
+
+
+
+ {log.error && log.data &&
void log.refetch()} stale />}
+ {log.isPending ? (
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : log.error && !log.data ? (
+ void log.refetch()} />
+ ) : items.length === 0 ? (
+
+
Nothing logged yet
+
Log a read, watch or play from any entry in your library, or connect a source to bring your history in.
+
+ Go to library
+ Connect a source
+
+
+ ) : (
+ <>
+ {groups.map((g) => (
+
+ {g.month}
+
+ {g.items.map((it) => (
+ - {it.kind === "engagement" ? : }
+ ))}
+
+
+ ))}
+ {log.hasNextPage && (
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function EngagementRow({ it, scale }: { it: Extract; scale: ReturnType }) {
+ const nouns = engagementNouns(it.mediaType);
+ const dated = it.startedAt || it.finishedAt;
+ const verb = it.finishedAt ? nouns.past : it.startedAt ? "Started" : "Logged";
+ const when = dated ? formatDateRange(it.startedAt, it.finishedAt).replace(/^Started /, "") : shortDate(it.at);
+ return (
+
+
+
+ {it.title}
+
+ {mediaTypeLabel(it.mediaType)} · {verb} {when}
+ {it.platform ? ` · ${it.platform}` : ""}
+
+
+ {it.rating !== null && {formatRating(it.rating, scale)}}
+
+ );
+}
+
+function ListenRow({ it }: { it: Extract }) {
+ return (
+
+
+
+ {it.track}
+
+ {it.artist}
+ {it.album ? ` · ${it.album}` : ""}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css
index a603095..b27e65b 100644
--- a/apps/web/src/theme.css
+++ b/apps/web/src/theme.css
@@ -553,3 +553,23 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px;
.conn-row .conn-meta { grid-column: 1; }
.conn-row .badge { grid-row: 1; grid-column: 2; }
}
+
+/* ---------- Log ---------- */
+.screen-inner.narrow { max-width: 720px; }
+.log-month { margin-top: 22px; }
+.log-month h2 { font-size: 0.78rem; font-weight: 600; color: var(--text-faint); margin-bottom: 4px; position: sticky; top: 0; background: var(--surface-1); padding: 6px 0; z-index: 1; }
+.log { list-style: none; }
+.log-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 7px 10px; margin: 0 -10px; border-radius: var(--r-control); }
+a.log-row { transition: background 0.12s; }
+a.log-row:hover { background: var(--surface-2); }
+.log-main { min-width: 0; display: grid; }
+.log-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.log-meta { font-size: 0.76rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.log-stars { font-size: 0.72rem; }
+.log-when { font-size: 0.76rem; color: var(--text-faint); white-space: nowrap; }
+.listen-cover { display: grid; place-items: center; background: color-mix(in srgb, var(--media-music) 55%, var(--surface-2)); color: #f6d3e1; }
+.listen-cover .icon { width: 14px; height: 14px; }
+.log-row.skeleton .bar { background: var(--surface-2); height: 10px; border-radius: 4px; width: 60%; }
+.log-row.skeleton .bar + .bar { width: 35%; margin-top: 6px; height: 8px; }
+.log-row.skeleton .cover { background: var(--surface-2); }
+.more-btn { margin-top: 18px; }
diff --git a/apps/web/test/labels.test.ts b/apps/web/test/labels.test.ts
index f4965f7..4e12c67 100644
--- a/apps/web/test/labels.test.ts
+++ b/apps/web/test/labels.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { ChangeEvent } from "../src/api.ts";
-import { changeValue, describeChange, groupByRun, scheduleLabel, SCHEDULES } from "../src/labels.ts";
+import { changeValue, describeChange, groupByRun, logMonth, scheduleLabel, SCHEDULES } from "../src/labels.ts";
const base: ChangeEvent = {
id: "ev1",
@@ -117,3 +117,12 @@ describe("changeValue", () => {
expect(changeValue("review", "x".repeat(300), "5half")).toHaveLength(160);
});
});
+
+describe("logMonth", () => {
+ test("month headings from partial dates and timestamps", () => {
+ expect(logMonth("2026-10-01")).toBe("October 2026");
+ expect(logMonth("2025-03")).toBe("March 2025");
+ expect(logMonth("2019")).toBe("2019");
+ expect(logMonth("2026-01-31T23:59:00.000Z")).toBe("January 2026");
+ });
+});