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

+

Everything you've finished, started and listened to, newest first.

+
+ {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"); + }); +});