feat(web): changes inbox grouped by run, with per-entry filter

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 22:16:06 +01:00
co-authored by Claude Opus 5.5
parent 4166ecb8f5
commit 67d94f5107
6 changed files with 693 additions and 2 deletions
+172 -2
View File
@@ -147,9 +147,121 @@ export interface Connection {
id: string;
pluginId: string;
name: string;
status: string;
status: "active" | "paused" | "error";
lastRunAt: string | null;
hasCredentials: boolean;
config: Record<string, unknown>;
scheduleMinutes: number | null;
createdAt: string;
}
export interface ConfigField {
key: string;
label: string;
type: "string" | "secret" | "number" | "boolean" | "select";
required?: boolean;
options?: string[];
help?: string;
}
export interface PluginInfo {
id: string;
name: string;
version: string;
mediaTypes: string[];
capabilities: string[];
config: ConfigField[];
attribution?: string;
deviceAuth: boolean;
}
export interface Run {
id: string;
mode: "full" | "changes" | "file";
status: "running" | "ok" | "failed";
startedAt: string;
finishedAt: string | null;
counts: Record<string, number>;
log: string;
error: string | null;
}
export interface DeviceStart {
userCode: string;
verificationUrl: string;
deviceCode: string;
intervalSec: number;
expiresInSec: number;
}
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;
};
export type ListKind = "list" | "grouping" | "thread";
export interface List {
id: string;
name: string;
description: string;
kind: ListKind;
visibility: "private" | "friends" | "public";
rating: number | null;
review: string | null;
createdAt: string;
updatedAt: string;
}
export interface ListItem {
id: string;
workId: string;
position: number;
note: string | null;
work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string };
}
export interface ListWithItems extends List {
items: ListItem[];
}
export interface OwnedCopy extends Copy {
workTitle: string | null;
createdAt: string;
}
export interface AppToken {
id: string;
name: string;
scopes: ("read" | "write" | "ingest")[];
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
export interface About {
name: string;
attributions: { pluginId: string; name: string; attribution: string }[];
}
export interface ChangeEvent {
@@ -157,10 +269,15 @@ export interface ChangeEvent {
type: string;
status: string;
connectionId: string | null;
runId: string | null;
targetEntity: string;
targetId: string | null;
payload: Record<string, unknown>;
createdAt: string;
/** The target entry's title (an engagement's entry; a list's name), when it still exists. */
title?: string | null;
/** Merged events: the title of the entry this was merged into, when known. */
intoTitle?: string | null;
}
export interface HistoryRecord {
@@ -176,10 +293,14 @@ export interface HistoryRecord {
export interface CustomFieldDef {
id: string;
name: string;
type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
type: CustomFieldType;
options: string[];
appliesTo?: string[] | "all";
level?: "entry" | "engagement" | "copy";
}
export type CustomFieldType = "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
export interface Work {
id: string;
media_type: string;
@@ -247,7 +368,56 @@ export const api = {
},
sources: () => request<Connection[]>("GET", "sources"),
plugins: () => request<PluginInfo[]>("GET", "plugins"),
createSource: (body: { pluginId: string; name: string; config: Record<string, unknown>; scheduleMinutes?: number | null }) =>
request<Connection>("POST", "sources", { body }),
patchSource: (id: string, body: Partial<{ name: string; config: Record<string, unknown>; scheduleMinutes: number | null; status: "active" | "paused" }>) =>
request<Connection>("PATCH", `sources/${id}`, { body }),
deleteSource: (id: string) => request<void>("DELETE", `sources/${id}`),
runSource: (id: string) => request<{ jobId: string }>("POST", `sources/${id}/run`),
importToSource: (id: string, file: File) => {
const form = new FormData();
form.set("file", file);
return request<{ jobId: string }>("POST", `sources/${id}/import`, { form });
},
deviceStart: (id: string) => request<DeviceStart>("POST", `sources/${id}/device/start`),
devicePoll: (id: string, deviceCode: string) => request<{ status: "pending" | "connected" }>("POST", `sources/${id}/device/poll`, { body: { deviceCode } }),
runs: (id: string) => request<Run[]>("GET", `sources/${id}/runs`, { query: { limit: 20 } }),
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
resolveChange: (id: string, action: "accept" | "ignore" | "keep_mine") => request<void>("POST", `changes/${id}/resolve`, { body: { action } }),
log: (cursor?: string) => request<Page<LogItem>>("GET", "log", { query: { cursor, limit: 50 } }),
lists: () => request<List[]>("GET", "lists"),
list: (id: string) => request<ListWithItems>("GET", `lists/${id}`),
createList: (body: { name: string; kind: ListKind; description?: string }) => request<List>("POST", "lists", { body }),
patchList: (id: string, body: Partial<Pick<List, "name" | "description" | "kind" | "rating" | "review" | "visibility">>) =>
request<List>("PATCH", `lists/${id}`, { body }),
deleteList: (id: string) => request<void>("DELETE", `lists/${id}`),
addListItem: (id: string, workId: string) => request<ListItem>("POST", `lists/${id}/items`, { body: { workId } }),
moveListItem: (itemId: string, position: number) => request<void>("PATCH", `list-items/${itemId}`, { body: { position } }),
removeListItem: (itemId: string) => request<void>("DELETE", `list-items/${itemId}`),
ownedCopies: () => request<OwnedCopy[]>("GET", "copies"),
updateCopy: (id: string, body: Partial<Omit<Copy, "id">>) => request<Copy>("PATCH", `copies/${id}`, { body }),
deleteCopy: (id: string) => request<void>("DELETE", `copies/${id}`),
allCustomFields: () => request<CustomFieldDef[]>("GET", "custom-fields"),
createCustomField: (body: { name: string; type: CustomFieldType; options?: string[]; appliesTo?: string[] | "all"; level?: "entry" | "engagement" | "copy" }) =>
request<CustomFieldDef>("POST", "custom-fields", { body }),
tokens: () => request<AppToken[]>("GET", "me/tokens"),
createToken: (name: string, scopes: AppToken["scopes"]) => request<{ id: string; token: string }>("POST", "me/tokens", { body: { name, scopes } }),
revokeToken: (id: string) => request<void>("DELETE", `me/tokens/${id}`),
createInvite: (ttlDays?: number) => request<{ code: string }>("POST", "admin/invites", { body: ttlDays ? { ttlDays } : {} }),
reloadPlugins: () => request<PluginInfo[]>("POST", "admin/plugins/reload"),
importBundle: (file: File) => {
const form = new FormData();
form.set("file", file);
return request<{ works: number; entries: number }>("POST", "import/bundle", { form });
},
about: () => request<About>("GET", "about"),
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
work: (id: string) => request<Work>("GET", `works/${id}`),
+149
View File
@@ -0,0 +1,149 @@
/** Copy for the Changes inbox and Sources screens. Pure functions: no DOM, no network. */
import type { ChangeEvent } from "./api.ts";
import { formatPartialDate, formatRating, type PartialDate, type RatingScale, type Status, statusLabel } from "./format.ts";
export type ChangeAction = "accept" | "ignore" | "keep_mine";
export interface ChangeDescription {
message: string;
/** The field a changed/conflict event is about. */
field?: string;
before?: { label: string; value: unknown };
after?: { label: string; value: unknown };
actions: { action: ChangeAction; label: string }[];
}
interface SourceLike {
id: string;
name: string;
}
const FIELD_NAMES: Record<string, string> = {
rating: "the rating",
status: "the status",
review: "the review",
tags: "the tags",
favorite: "the favourite mark",
privateNotes: "the private notes",
startedAt: "the start date",
finishedAt: "the finish date",
progress: "the progress",
platform: "the platform",
notes: "the notes",
name: "the name",
description: "the description",
items: "the items",
visibility: "who can see it",
};
/** "rating" → "the rating"; unknown keys read as "the <words>". */
export function fieldPhrase(field: string): string {
if (FIELD_NAMES[field]) return FIELD_NAMES[field]!;
return `the ${field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase()}`;
}
/** The connection id in `plugin:<pluginId>:<connectionId>`. */
function connectionOf(actor: unknown): string | null {
const m = typeof actor === "string" ? /^plugin:[^:]+:(.+)$/.exec(actor) : null;
return m ? m[1]! : null;
}
function sourceName(sources: SourceLike[], connectionId: string | null): string {
return sources.find((s) => s.id === connectionId)?.name ?? "A source";
}
const FIELD_ACTIONS: ChangeDescription["actions"] = [
{ action: "accept", label: "Use the new value" },
{ action: "keep_mine", label: "Keep mine" },
{ action: "ignore", label: "Ignore" },
];
/** What a pending change says and which buttons it offers, in the product's words. */
export function describeChange(ev: ChangeEvent, sources: SourceLike[]): ChangeDescription {
const src = sourceName(sources, ev.connectionId);
const p = ev.payload;
if (ev.type === "removed") {
return {
message: `${src} no longer has this. Your copy here is safe.`,
actions: [
{ action: "accept", label: "Delete here too" },
{ action: "ignore", label: "Keep it" },
],
};
}
if (ev.type === "merged") {
return {
message: `${src} merged this into ${ev.intoTitle || "another entry"}.`,
actions: [
{ action: "accept", label: "Follow the merge" },
{ action: "ignore", label: "Ignore" },
],
};
}
const field = typeof p.field === "string" ? p.field : "";
if (ev.type === "conflict") {
const incoming = sourceName(sources, connectionOf(p.incomingSource) ?? ev.connectionId);
const current = p.currentSource === "user" ? "You" : sourceName(sources, connectionOf(p.currentSource));
const other = current === "A source" ? "another source" : current;
return {
message: `${incoming} and ${other} disagree about ${fieldPhrase(field)}.`,
field,
before: { label: current === "You" ? "Yours" : current, value: p.current ?? null },
after: { label: incoming, value: p.incoming ?? null },
actions: FIELD_ACTIONS,
};
}
if (ev.type === "changed") {
return {
message: `${src} changed ${fieldPhrase(field)}.`,
field,
before: { label: "Yours", value: p.from ?? null },
after: { label: src, value: p.to ?? null },
actions: FIELD_ACTIONS,
};
}
return { message: `${src} added this.`, actions: [{ action: "ignore", label: "Dismiss" }] };
}
/** Sync schedule choices, in minutes (null is off). */
export const SCHEDULES: (number | null)[] = [null, 60, 360, 1440];
export function scheduleLabel(minutes: number | null | undefined): string {
if (!minutes) return "Off";
if (minutes === 60) return "Every hour";
if (minutes === 1440) return "Daily";
if (minutes % 1440 === 0) return `Every ${minutes / 1440} days`;
if (minutes % 60 === 0) return `Every ${minutes / 60} hours`;
return `Every ${minutes} minutes`;
}
/** Events grouped by sync run, keeping the newest-first order of first appearance. */
export function groupByRun<E extends { runId: string | null }>(events: E[]): { runId: string | null; events: E[] }[] {
const groups: { runId: string | null; events: E[] }[] = [];
const at = new Map<string | null, number>();
for (const e of events) {
const i = at.get(e.runId);
if (i === undefined) {
at.set(e.runId, groups.length);
groups.push({ runId: e.runId, events: [e] });
} else groups[i]!.events.push(e);
}
return groups;
}
const isPartialDate = (v: unknown): v is PartialDate =>
!!v && typeof v === "object" && typeof (v as PartialDate).value === "string" && typeof (v as PartialDate).precision === "string";
/** A before/after value in the user's terms: stars in their scale, "Reading", "Mar 2025". Long text is cut to 160. */
export function changeValue(field: string, value: unknown, scale: RatingScale, mediaType = "other"): string {
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) return "Nothing";
if (field === "rating" && typeof value === "number") return formatRating(value, scale);
if (field === "status" && typeof value === "string") return statusLabel(value as Status, mediaType);
if (isPartialDate(value)) return formatPartialDate(value) || "No date";
if (typeof value === "boolean") return value ? "Yes" : "No";
let text: string;
if (Array.isArray(value)) text = value.map((v) => (typeof v === "string" ? v : JSON.stringify(v))).join(", ");
else if (typeof value === "object") text = JSON.stringify(value);
else text = String(value);
return text.length > 160 ? `${text.slice(0, 159)}…` : text;
}
+2
View File
@@ -6,6 +6,7 @@ import { ApiError } from "./api.ts";
import { friendlyError } from "./format.ts";
import { Shell } from "./components/Shell.tsx";
import { useMe } from "./hooks.ts";
import { Changes } from "./routes/changes.tsx";
import { Library } from "./routes/library.tsx";
import { Login } from "./routes/login.tsx";
import { NotYet } from "./routes/not-yet.tsx";
@@ -56,6 +57,7 @@ function App() {
<Switch>
<Route path="/">{() => <Library selectedId={null} />}</Route>
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
<Route path="/changes" component={Changes} />
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
</Switch>
</Shell>
+170
View File
@@ -0,0 +1,170 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useSearch } from "wouter";
import { api, type ChangeEvent, type Connection, type Page } from "../api.ts";
import { LoadError } from "../components/LoadError.tsx";
import { changesFor, usePendingChanges, useSources } from "../hooks.ts";
import { friendlyError, timeAgo } from "../format.ts";
import { changeValue, describeChange, groupByRun, type ChangeAction } from "../labels.ts";
import { useRatingScale } from "../settings.ts";
const TYPE_LABEL: Record<string, string> = {
changed: "Changed",
conflict: "Sources disagree",
removed: "Gone from source",
merged: "Merged at source",
added: "Added",
};
const DONE_LABEL: Record<ChangeAction, string> = {
accept: "Done. The new value is in.",
keep_mine: "Kept yours. That source won't change it again.",
ignore: "Ignored. Nothing changed.",
};
/** The Changes inbox: pending source changes, grouped by sync run. `?entry=<id>` narrows it to one entry. */
export function Changes() {
const search = new URLSearchParams(useSearch());
const entryId = search.get("entry");
const changes = usePendingChanges();
const sources = useSources();
const engagements = useQuery({ queryKey: ["engagements", entryId], queryFn: () => api.engagements(entryId!), enabled: !!entryId });
const entry = useQuery({ queryKey: ["entry", entryId], queryFn: () => api.entry(entryId!), enabled: !!entryId });
const [said, setSaid] = useState("");
const all = changes.data?.items ?? [];
const items = entryId ? changesFor(all, entryId, new Set((engagements.data ?? []).map((g) => g.id))) : all;
const groups = groupByRun(items);
return (
<div className="screen">
<div className="screen-inner">
<header className="screen-head">
<h1>Changes</h1>
<p className="lede">When a source changes something you've edited, it waits here. Nothing changes until you choose.</p>
</header>
{entryId && (
<p className="filter-note">
Showing changes to <b>{entry.data?.work.title ?? "one entry"}</b>. <Link href="/changes" className="text-link">Show all changes</Link>
</p>
)}
<p className="sr-only" role="status" aria-live="polite">{said}</p>
{changes.error && changes.data && <LoadError error={changes.error} retry={() => void changes.refetch()} stale />}
{changes.isPending ? (
<div className="change-skeleton" aria-hidden="true">
<div className="change skeleton" />
<div className="change skeleton" />
</div>
) : changes.error && !changes.data ? (
<LoadError error={changes.error} retry={() => void changes.refetch()} />
) : items.length === 0 ? (
<div className="empty-state">
<strong>{entryId ? "Nothing waiting for this entry" : "All caught up"}</strong>
<p>
{entryId
? "Its sources agree with what you have."
: "Your sources agree with your library. When one changes something you edited, you'll decide here."}
</p>
</div>
) : (
groups.map((g) => (
<RunGroup key={g.runId ?? "none"} events={g.events} sources={sources.data ?? []} onResolved={setSaid} />
))
)}
{changes.data?.nextCursor && items.length > 0 && (
<p className="hint more-note">Showing the newest 200. Resolve some to see older ones.</p>
)}
</div>
</div>
);
}
function RunGroup({ events, sources, onResolved }: { events: ChangeEvent[]; sources: Connection[]; onResolved: (s: string) => void }) {
const first = events[0]!;
const name = sources.find((s) => s.id === first.connectionId)?.name ?? "A source";
return (
<section className="run-group" aria-label={`${name}, ${timeAgo(first.createdAt)}`}>
<h2 className="run-head">
<span className="who">{name}</span>
<span className="sep" aria-hidden="true">·</span>
<span>sync {timeAgo(first.createdAt)}</span>
<span className="sep" aria-hidden="true">·</span>
<span className="tabular">{events.length} waiting</span>
</h2>
<ol className="changes">
{events.map((ev) => (
<ChangeCard key={ev.id} ev={ev} sources={sources} onResolved={onResolved} />
))}
</ol>
</section>
);
}
function ChangeCard({ ev, sources, onResolved }: { ev: ChangeEvent; sources: Connection[]; onResolved: (s: string) => void }) {
const qc = useQueryClient();
const scale = useRatingScale();
const d = describeChange(ev, sources);
const [error, setError] = useState<string | null>(null);
const key = ["changes", "pending"];
const resolve = useMutation({
mutationFn: (action: ChangeAction) => api.resolveChange(ev.id, action),
onMutate: async () => {
setError(null);
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<Page<ChangeEvent>>(key);
if (prev) qc.setQueryData<Page<ChangeEvent>>(key, { ...prev, items: prev.items.filter((c) => c.id !== ev.id) });
return { prev };
},
onError: (e, _a, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
setError(friendlyError(e));
},
onSuccess: (_v, action) => onResolved(DONE_LABEL[action]),
onSettled: () => {
void qc.invalidateQueries({ queryKey: key });
void qc.invalidateQueries({ queryKey: ["entries"] });
if (ev.targetEntity === "entry" && ev.targetId) {
void qc.invalidateQueries({ queryKey: ["entry", ev.targetId] });
void qc.invalidateQueries({ queryKey: ["history", ev.targetId] });
}
void qc.invalidateQueries({ queryKey: ["engagements"] });
},
});
const title = ev.title ?? (ev.targetEntity === "list" ? "A list" : "An entry that's gone");
const where = ev.targetEntity === "engagement" ? "A read or play of " : ev.targetEntity === "list" ? "List " : "";
return (
<li className={`change type-${ev.type}`}>
<div className="change-top">
<span className="kind">{TYPE_LABEL[ev.type] ?? ev.type}</span>
<span className="change-title">
{where}
{ev.targetEntity === "entry" && ev.targetId && ev.title ? <Link href={`/entries/${ev.targetId}`}>{title}</Link> : <b>{title}</b>}
</span>
<time className="when" dateTime={ev.createdAt}>{timeAgo(ev.createdAt)}</time>
</div>
<p className="change-msg">{d.message}</p>
{d.before && d.after && (
<dl className="diff">
<div className="was">
<dt>{d.before.label}</dt>
<dd className={d.field === "rating" && d.before.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.before.value, scale)}</dd>
</div>
<div className="now">
<dt>{d.after.label}</dt>
<dd className={d.field === "rating" && d.after.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.after.value, scale)}</dd>
</div>
</dl>
)}
<div className="change-actions">
{d.actions.map((a) => (
<button key={a.action} type="button" className={`btn small${a.action === "accept" ? " primary-soft" : ""}`} disabled={resolve.isPending} onClick={() => resolve.mutate(a.action)}>
{a.label}
</button>
))}
</div>
{error && <p className="error-text" role="alert">{error}</p>}
</li>
);
}
+81
View File
@@ -392,3 +392,84 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px;
.alert { flex-direction: column; align-items: stretch; }
.box form .two { grid-template-columns: 1fr; }
}
/* =====================================================================================
Task 18 screens: Changes, Sources, Log, Lists, Owned, Settings, About
===================================================================================== */
/* ---------- Load errors ---------- */
.load-error { padding: 18px 0; color: var(--text-muted); display: grid; gap: 10px; justify-items: start; }
.stale-banner {
display: flex; align-items: center; gap: 10px; justify-content: space-between;
background: #3a1d24; border: 1px solid #f8717144; color: #ffd4d4;
border-radius: var(--r-control); padding: 6px 6px 6px 12px; font-size: 0.8rem; margin: 0 0 12px;
}
.stale-banner .btn.ghost { color: #ffd4d4; }
.stale-banner.in-list { margin: 4px 16px 8px; }
/* ---------- Screen frame ---------- */
.screen { height: 100%; overflow-y: auto; }
.screen-inner { padding: 26px 32px 64px; max-width: 900px; }
.screen-head { margin-bottom: 20px; }
.screen-head h1 { font-size: 1.35rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.012em; text-wrap: balance; }
.screen-head .lede { color: var(--text-muted); margin-top: 5px; max-width: 64ch; }
.screen-head.with-action { display: flex; gap: 16px; align-items: flex-start; justify-content: space-between; }
.section { margin-top: 30px; }
.section > h2, .section-head h2 { font-size: 0.95rem; font-weight: 650; letter-spacing: -0.005em; }
.section > .hint, .section-head + .hint { margin: 4px 0 12px; max-width: 64ch; display: block; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.empty-state { padding: 34px 0 12px; max-width: 46ch; color: var(--text-muted); }
.empty-state strong { display: block; color: var(--text); font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
.empty-state .btn { margin-top: 14px; }
.filter-note { font-size: 0.84rem; color: var(--text-muted); margin: -6px 0 18px; }
.filter-note b { color: var(--text); font-weight: 600; }
.more-note { margin-top: 14px; }
.btn.primary-soft { background: #6d5dfc33; color: #ded9ff; }
.btn.primary-soft:hover:not(:disabled) { background: #6d5dfc55; }
.btn.danger { color: #ffb4a8; }
.btn.danger:hover:not(:disabled) { background: #4a1f2a; color: #ffd4d4; }
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.72rem; font-weight: 550; padding: 1px 8px; border-radius: var(--r-pill); background: var(--surface-3); color: var(--pill-text); white-space: nowrap; }
.badge.ok::before, .badge.off::before, .badge.bad::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge.ok { color: var(--ok); background: #34d39914; }
.badge.off { color: var(--text-muted); }
.badge.bad { color: #ffb4a8; background: #f8717114; }
.secret-once {
margin-top: 12px; border: 1px solid #fbbf2455; background: #fbbf240d; border-radius: var(--r-control);
padding: 10px 12px; display: grid; gap: 8px;
}
.secret-once p { font-size: 0.8rem; color: var(--text-muted); }
.secret-once .copy-row { display: flex; gap: 8px; align-items: center; }
.secret-once code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.82rem; color: var(--rating); background: var(--surface-0); padding: 6px 9px; border-radius: 6px; }
/* ---------- Changes ---------- */
.run-group { margin-top: 26px; }
.run-group:first-of-type { margin-top: 6px; }
.run-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 0.78rem; font-weight: 500; color: var(--text-faint); margin-bottom: 8px; }
.run-head .who { color: var(--text-muted); font-weight: 600; }
.changes { list-style: none; display: grid; gap: 10px; }
.change { background: var(--surface-2); border-radius: var(--r-box); padding: 13px 16px 14px; display: grid; gap: 8px; }
.change.skeleton { height: 128px; }
.change-skeleton { display: grid; gap: 10px; }
.change-top { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.change .kind { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: 0.72rem; font-weight: 600; color: #fdba74; }
.change .kind::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
.change-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; color: var(--text-muted); }
.change-title a, .change-title b { color: var(--text); font-weight: 600; }
.change-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
.change .when { flex: none; font-size: 0.74rem; color: var(--text-faint); }
.change-msg { color: var(--warn-text); font-size: 0.86rem; }
.diff { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--surface-3); border-radius: var(--r-control); overflow: hidden; }
.diff > div { background: #221e40; padding: 8px 12px; min-width: 0; }
.diff dt { font-size: 0.72rem; color: var(--text-faint); font-weight: 600; margin-bottom: 2px; }
.diff dd { font-size: 0.86rem; overflow-wrap: anywhere; }
.diff .was dd { color: var(--text-muted); }
.diff .now dd { color: var(--text); }
.diff dd.stars { color: var(--rating); font-size: 0.8rem; }
.change-actions { display: flex; gap: 6px; flex-wrap: wrap; padding-top: 2px; }
@media (max-width: 860px) {
.screen-inner { padding: 18px 16px 48px; }
.diff { grid-template-columns: 1fr; }
.change-top { flex-wrap: wrap; }
.change-title { white-space: normal; }
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test";
import type { ChangeEvent } from "../src/api.ts";
import { changeValue, describeChange, groupByRun, scheduleLabel, SCHEDULES } from "../src/labels.ts";
const base: ChangeEvent = {
id: "ev1",
type: "changed",
status: "pending",
connectionId: "c1",
runId: "r1",
targetEntity: "entry",
targetId: "e1",
payload: {},
createdAt: "2026-10-30T10:00:00.000Z",
title: "Piranesi",
};
const sources = [
{ id: "c1", pluginId: "hardcover", name: "Hardcover" },
{ id: "c2", pluginId: "trakt", name: "Trakt" },
];
describe("describeChange", () => {
test("removed", () => {
const d = describeChange({ ...base, type: "removed", payload: { key: "b:1", kind: "entry" } }, sources);
expect(d.message).toBe("Hardcover no longer has this. Your copy here is safe.");
expect(d.actions).toEqual([
{ action: "accept", label: "Delete here too" },
{ action: "ignore", label: "Keep it" },
]);
});
test("merged, with and without the target title", () => {
const ev = { ...base, type: "merged", payload: { key: "b:1", intoKey: "b:2" }, intoTitle: "Piranesi (Deluxe)" };
const d = describeChange(ev, sources);
expect(d.message).toBe("Hardcover merged this into Piranesi (Deluxe).");
expect(d.actions).toEqual([
{ action: "accept", label: "Follow the merge" },
{ action: "ignore", label: "Ignore" },
]);
expect(describeChange({ ...ev, intoTitle: null }, sources).message).toBe("Hardcover merged this into another entry.");
});
test("conflict shows both sources' values", () => {
const d = describeChange(
{
...base,
type: "conflict",
connectionId: "c2",
payload: { field: "rating", current: 80, currentSource: "plugin:hardcover:c1", incoming: 60, incomingSource: "plugin:trakt:c2" },
},
sources,
);
expect(d.message).toBe("Trakt and Hardcover disagree about the rating.");
expect(d.field).toBe("rating");
expect(d.before).toEqual({ label: "Hardcover", value: 80 });
expect(d.after).toEqual({ label: "Trakt", value: 60 });
expect(d.actions).toEqual([
{ action: "accept", label: "Use the new value" },
{ action: "keep_mine", label: "Keep mine" },
{ action: "ignore", label: "Ignore" },
]);
});
test("changed shows yours against the source's", () => {
const d = describeChange({ ...base, payload: { field: "status", from: "completed", to: "in_progress" } }, sources);
expect(d.message).toBe("Hardcover changed the status.");
expect(d.before).toEqual({ label: "Yours", value: "completed" });
expect(d.after).toEqual({ label: "Hardcover", value: "in_progress" });
expect(d.actions.map((a) => a.label)).toEqual(["Use the new value", "Keep mine", "Ignore"]);
});
test("an unknown source still reads well", () => {
const d = describeChange({ ...base, type: "removed", connectionId: null, payload: {} }, []);
expect(d.message).toBe("A source no longer has this. Your copy here is safe.");
});
});
describe("scheduleLabel", () => {
test("the four choices", () => {
expect(scheduleLabel(null)).toBe("Off");
expect(scheduleLabel(60)).toBe("Every hour");
expect(scheduleLabel(360)).toBe("Every 6 hours");
expect(scheduleLabel(1440)).toBe("Daily");
expect(SCHEDULES).toEqual([null, 60, 360, 1440]);
});
test("other intervals", () => {
expect(scheduleLabel(30)).toBe("Every 30 minutes");
expect(scheduleLabel(120)).toBe("Every 2 hours");
expect(scheduleLabel(2880)).toBe("Every 2 days");
});
});
describe("groupByRun", () => {
test("keeps newest-first order and groups events of one run", () => {
const g = groupByRun([
{ ...base, id: "3", runId: "rB" },
{ ...base, id: "2", runId: "rA" },
{ ...base, id: "1", runId: "rB" },
]);
expect(g.map((x) => [x.runId, x.events.map((e) => e.id)])).toEqual([
["rB", ["3", "1"]],
["rA", ["2"]],
]);
});
});
describe("changeValue", () => {
test("values read in the user's terms", () => {
expect(changeValue("rating", 80, "5half")).toBe("★★★★☆");
expect(changeValue("rating", 85, "10")).toBe("8.5/10");
expect(changeValue("status", "in_progress", "5half", "book")).toBe("Reading");
expect(changeValue("finishedAt", { value: "2025-03", precision: "month" }, "5half")).toBe("Mar 2025");
expect(changeValue("tags", ["sci-fi", "re-read"], "5half")).toBe("sci-fi, re-read");
expect(changeValue("favorite", true, "5half")).toBe("Yes");
expect(changeValue("rating", null, "5half")).toBe("Nothing");
expect(changeValue("tags", [], "5half")).toBe("Nothing");
expect(changeValue("review", "x".repeat(300), "5half")).toHaveLength(160);
});
});