feat(web): log of reads, plays, watches and listens, by month
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b38a2674f4
commit
ec39491f47
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/">{() => <Library selectedId={null} />}</Route>
|
||||
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
|
||||
<Route path="/changes" component={Changes} />
|
||||
<Route path="/log" component={Log} />
|
||||
<Route path="/sources" component={Sources} />
|
||||
<Route path="/sources/:id">{(p) => <SourceDetail key={p.id} id={p.id} />}</Route>
|
||||
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
|
||||
|
||||
@@ -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 (
|
||||
<div className="screen">
|
||||
<div className="screen-inner narrow">
|
||||
<header className="screen-head">
|
||||
<h1>Log</h1>
|
||||
<p className="lede">Everything you've finished, started and listened to, newest first.</p>
|
||||
</header>
|
||||
{log.error && log.data && <LoadError error={log.error} retry={() => void log.refetch()} stale />}
|
||||
{log.isPending ? (
|
||||
<ul className="log" aria-busy="true" aria-label="Loading your log">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<li key={i} className="log-row skeleton" aria-hidden="true"><div className="cover small" /><div><div className="bar" /><div className="bar" /></div></li>
|
||||
))}
|
||||
</ul>
|
||||
) : log.error && !log.data ? (
|
||||
<LoadError error={log.error} retry={() => void log.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>Nothing logged yet</strong>
|
||||
<p>Log a read, watch or play from any entry in your library, or connect a source to bring your history in.</p>
|
||||
<div className="form-actions" style={{ marginTop: 14 }}>
|
||||
<Link href="/" className="btn">Go to library</Link>
|
||||
<Link href="/sources" className="btn ghost">Connect a source</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{groups.map((g) => (
|
||||
<section key={g.month} className="log-month" aria-label={g.month}>
|
||||
<h2>{g.month}</h2>
|
||||
<ul className="log">
|
||||
{g.items.map((it) => (
|
||||
<li key={`${it.kind}:${it.id}`}>{it.kind === "engagement" ? <EngagementRow it={it} scale={scale} /> : <ListenRow it={it} />}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{log.hasNextPage && (
|
||||
<button type="button" className="btn small more-btn" disabled={log.isFetchingNextPage} onClick={() => void log.fetchNextPage()}>
|
||||
{log.isFetchingNextPage ? "Loading…" : "Show older"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EngagementRow({ it, scale }: { it: Extract<LogItem, { kind: "engagement" }>; scale: ReturnType<typeof useRatingScale> }) {
|
||||
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 (
|
||||
<Link href={`/entries/${it.entryId}`} className="log-row">
|
||||
<Cover url={it.coverUrl} mediaType={it.mediaType} draft={false} size="small" />
|
||||
<div className="log-main">
|
||||
<span className="log-title">{it.title}</span>
|
||||
<span className="log-meta">
|
||||
{mediaTypeLabel(it.mediaType)} · {verb} {when}
|
||||
{it.platform ? ` · ${it.platform}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{it.rating !== null && <span className="stars log-stars" role="img" aria-label={describeRating(it.rating, scale)}>{formatRating(it.rating, scale)}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ListenRow({ it }: { it: Extract<LogItem, { kind: "listen" }> }) {
|
||||
return (
|
||||
<div className="log-row listen">
|
||||
<div className="cover small listen-cover" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" className="icon"><path d="M9 18V5l11-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM20 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" /></svg>
|
||||
</div>
|
||||
<div className="log-main">
|
||||
<span className="log-title">{it.track}</span>
|
||||
<span className="log-meta">
|
||||
{it.artist}
|
||||
{it.album ? ` · ${it.album}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<time className="log-when" dateTime={it.at}>{listenTime(it.at)}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user