From 67d94f51070367aaac90655ecd4831d60fdd91f9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 22:16:06 +0100 Subject: [PATCH] feat(web): changes inbox grouped by run, with per-entry filter Co-Authored-By: Claude Opus 5.5 --- apps/web/src/api.ts | 174 +++++++++++++++++++++++++++++++- apps/web/src/labels.ts | 149 +++++++++++++++++++++++++++ apps/web/src/main.tsx | 2 + apps/web/src/routes/changes.tsx | 170 +++++++++++++++++++++++++++++++ apps/web/src/theme.css | 81 +++++++++++++++ apps/web/test/labels.test.ts | 119 ++++++++++++++++++++++ 6 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/labels.ts create mode 100644 apps/web/src/routes/changes.tsx create mode 100644 apps/web/test/labels.test.ts diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 245491c..9608659 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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; + 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; + 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; 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("GET", "sources"), + plugins: () => request("GET", "plugins"), + createSource: (body: { pluginId: string; name: string; config: Record; scheduleMinutes?: number | null }) => + request("POST", "sources", { body }), + patchSource: (id: string, body: Partial<{ name: string; config: Record; scheduleMinutes: number | null; status: "active" | "paused" }>) => + request("PATCH", `sources/${id}`, { body }), + deleteSource: (id: string) => request("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("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("GET", `sources/${id}/runs`, { query: { limit: 20 } }), + changes: () => request>("GET", "changes", { query: { limit: 200 } }), + resolveChange: (id: string, action: "accept" | "ignore" | "keep_mine") => request("POST", `changes/${id}/resolve`, { body: { action } }), + + log: (cursor?: string) => request>("GET", "log", { query: { cursor, limit: 50 } }), + + lists: () => request("GET", "lists"), + list: (id: string) => request("GET", `lists/${id}`), + createList: (body: { name: string; kind: ListKind; description?: string }) => request("POST", "lists", { body }), + patchList: (id: string, body: Partial>) => + request("PATCH", `lists/${id}`, { body }), + deleteList: (id: string) => request("DELETE", `lists/${id}`), + addListItem: (id: string, workId: string) => request("POST", `lists/${id}/items`, { body: { workId } }), + moveListItem: (itemId: string, position: number) => request("PATCH", `list-items/${itemId}`, { body: { position } }), + removeListItem: (itemId: string) => request("DELETE", `list-items/${itemId}`), + + ownedCopies: () => request("GET", "copies"), + updateCopy: (id: string, body: Partial>) => request("PATCH", `copies/${id}`, { body }), + deleteCopy: (id: string) => request("DELETE", `copies/${id}`), + + allCustomFields: () => request("GET", "custom-fields"), + createCustomField: (body: { name: string; type: CustomFieldType; options?: string[]; appliesTo?: string[] | "all"; level?: "entry" | "engagement" | "copy" }) => + request("POST", "custom-fields", { body }), + + tokens: () => request("GET", "me/tokens"), + createToken: (name: string, scopes: AppToken["scopes"]) => request<{ id: string; token: string }>("POST", "me/tokens", { body: { name, scopes } }), + revokeToken: (id: string) => request("DELETE", `me/tokens/${id}`), + createInvite: (ttlDays?: number) => request<{ code: string }>("POST", "admin/invites", { body: ttlDays ? { ttlDays } : {} }), + reloadPlugins: () => request("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("GET", "about"), customFields: (mediaType: string) => request("GET", "custom-fields", { query: { mediaType, level: "entry" } }), work: (id: string) => request("GET", `works/${id}`), diff --git a/apps/web/src/labels.ts b/apps/web/src/labels.ts new file mode 100644 index 0000000..65b3c0f --- /dev/null +++ b/apps/web/src/labels.ts @@ -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 = { + 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 ". */ +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::`. */ +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(events: E[]): { runId: string | null; events: E[] }[] { + const groups: { runId: string | null; events: E[] }[] = []; + const at = new Map(); + 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; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f9d42e8..3c680e7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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() { {() => } {(p) => } + {(p) => } diff --git a/apps/web/src/routes/changes.tsx b/apps/web/src/routes/changes.tsx new file mode 100644 index 0000000..91fbfed --- /dev/null +++ b/apps/web/src/routes/changes.tsx @@ -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 = { + changed: "Changed", + conflict: "Sources disagree", + removed: "Gone from source", + merged: "Merged at source", + added: "Added", +}; + +const DONE_LABEL: Record = { + 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=` 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 ( +
+
+
+

Changes

+

When a source changes something you've edited, it waits here. Nothing changes until you choose.

+
+ {entryId && ( +

+ Showing changes to {entry.data?.work.title ?? "one entry"}. Show all changes +

+ )} +

{said}

+ {changes.error && changes.data && void changes.refetch()} stale />} + + {changes.isPending ? ( + + ); +} + +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 ( +
+

+ {name} + + sync {timeAgo(first.createdAt)} + + {events.length} waiting +

+
    + {events.map((ev) => ( + + ))} +
+
+ ); +} + +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(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>(key); + if (prev) qc.setQueryData>(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 ( +
  • +
    + {TYPE_LABEL[ev.type] ?? ev.type} + + {where} + {ev.targetEntity === "entry" && ev.targetId && ev.title ? {title} : {title}} + + +
    +

    {d.message}

    + {d.before && d.after && ( +
    +
    +
    {d.before.label}
    +
    {changeValue(d.field ?? "", d.before.value, scale)}
    +
    +
    +
    {d.after.label}
    +
    {changeValue(d.field ?? "", d.after.value, scale)}
    +
    +
    + )} +
    + {d.actions.map((a) => ( + + ))} +
    + {error &&

    {error}

    } +
  • + ); +} diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 804d2b2..7776c8c 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -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; } +} diff --git a/apps/web/test/labels.test.ts b/apps/web/test/labels.test.ts new file mode 100644 index 0000000..f4965f7 --- /dev/null +++ b/apps/web/test/labels.test.ts @@ -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); + }); +});