fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
- Tags and custom fields update the cached entry optimistically and build from the latest cache; one entry's PATCHes run in order (mutation scope). - The review textarea follows server changes (Restore) unless edited. - friendlyError() maps validation reasons and 413 to plain copy everywhere. - Rating scale from me/preferences; /login and /register use auth/status; Attachments lists from the API. - Gelasio vendored (official OFL variable TTFs); minors from the review. - Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d204775235
commit
5fa59310f3
24 files changed
+506
-136
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { addTag, applyEntryPatch, orderEngagements, removeTag, setCustomValue } from "../src/edits.ts";
|
||||
|
||||
describe("entry edit builders", () => {
|
||||
test("two quick tag adds built from the latest cache keep both", () => {
|
||||
// Each edit reads the cache as left by the previous (optimistic) edit, not the render's props.
|
||||
let cache = { tags: [] as string[], rating: null as number | null };
|
||||
const edit = (build: (cur: typeof cache) => Partial<typeof cache>) => (cache = applyEntryPatch(cache, build(cache)));
|
||||
edit((cur) => ({ tags: addTag(cur.tags, "cosmic horror") }));
|
||||
edit((cur) => ({ tags: addTag(cur.tags, "re-read") }));
|
||||
expect(cache.tags).toEqual(["cosmic horror", "re-read"]);
|
||||
});
|
||||
|
||||
test("addTag trims and ignores case duplicates; removeTag removes one", () => {
|
||||
expect(addTag(["Horror"], " horror ")).toEqual(["Horror"]);
|
||||
expect(addTag(["a"], " ")).toEqual(["a"]);
|
||||
expect(removeTag(["a", "b"], "a")).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("setCustomValue sets, and removes empty values", () => {
|
||||
expect(setCustomValue({ a: 1 }, "b", "x")).toEqual({ a: 1, b: "x" });
|
||||
expect(setCustomValue({ a: 1, b: "x" }, "b", "")).toEqual({ a: 1 });
|
||||
expect(setCustomValue({ a: [1] }, "a", [])).toEqual({});
|
||||
expect(setCustomValue({ a: 1 }, "a", null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderEngagements", () => {
|
||||
const g = (id: string, createdAt: string, finished?: string, started?: string) => ({
|
||||
id,
|
||||
createdAt,
|
||||
finishedAt: finished ? { value: finished, precision: "day" as const } : null,
|
||||
startedAt: started ? { value: started, precision: "day" as const } : null,
|
||||
});
|
||||
test("sorts by finishedAt ?? startedAt, then createdAt; undated first", () => {
|
||||
const list = [
|
||||
g("late", "2026-01-01T00:00:00Z", "2026-09-14"),
|
||||
g("early-logged-later", "2026-10-01T00:00:00Z", "2025-03"),
|
||||
g("started-only", "2026-02-01T00:00:00Z", undefined, "2025-06-01"),
|
||||
g("undated", "2026-03-01T00:00:00Z"),
|
||||
];
|
||||
expect(orderEngagements(list).map((x) => x.id)).toEqual(["undated", "early-logged-later", "started-only", "late"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatDateRange, formatPartialDate, formatRating, parsePartialDate, rollupLabel } from "../src/format.ts";
|
||||
import { formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
@@ -66,3 +66,31 @@ describe("rollupLabel", () => {
|
||||
expect(rollupLabel("book", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("friendlyError", () => {
|
||||
const v = (message: string, status = 400) => ({ status, code: "validation", message });
|
||||
test("maps validation reasons to plain copy", () => {
|
||||
expect(friendlyError(v("customFields.Website: not_url"))).toBe("Website needs a full web address, starting with https://.");
|
||||
expect(friendlyError(v("finishedAt: not_date"))).toBe("Finished needs a date like 2025, 2025-03 or 2025-03-14.");
|
||||
expect(friendlyError(v("rating: out_of_range"))).toBe("Rating is out of range. Ratings go from 0 to 100.");
|
||||
expect(friendlyError(v("title: required"))).toBe("Title can't be empty.");
|
||||
expect(friendlyError(v("review: too_long"))).toBe("Review is too long. Shorten it and try again.");
|
||||
expect(friendlyError(v("password: Too small: expected string to have >=8 characters"))).toBe("Passwords need at least 8 characters.");
|
||||
expect(friendlyError(v("title: Too big: expected string to have <=1000 characters"))).toBe("Title is too long. Shorten it and try again.");
|
||||
expect(friendlyError(v("mime: unsupported file type"))).toBe("That file type isn't supported. Use an image, a PDF or a text file.");
|
||||
expect(friendlyError(v("customFields.Mood: unknown_option"))).toBe("Mood has an option that no longer exists. Pick one from the list.");
|
||||
expect(friendlyError(v("displayName: weird_reason"))).toBe("Display name wasn't accepted. Check it and try again.");
|
||||
});
|
||||
test("413 means the file is too large", () => {
|
||||
expect(friendlyError({ status: 413, code: "validation", message: "Request body is too large (max 50 MB)." })).toBe("That file is too large. The limit is 25 MB.");
|
||||
expect(friendlyError(v("file: larger than 25 MB"))).toBe("That file is too large. The limit is 25 MB.");
|
||||
});
|
||||
test("other API errors keep the server's message; unknown errors get a generic line", () => {
|
||||
expect(friendlyError({ status: 401, code: "unauthenticated", message: "Wrong username or password." })).toBe("Wrong username or password.");
|
||||
expect(friendlyError(new Error("boom"))).toBe("Something went wrong. Try again.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shortDate", () => {
|
||||
expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct");
|
||||
});
|
||||
Reference in new issue
Block a user