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:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 22:03:51 +01:00
1 parent d204775235
commit 5fa59310f3
24 files changed
+506 -136

No files matched your search

+44
View File
@@ -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"]);
});
});
+29 -1
View File
@@ -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");
});