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:
co-authored by
Claude Opus 5.5
parent
4166ecb8f5
commit
67d94f5107
+172
-2
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user