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 Winther
2026-10-30 22:03:51 +01:00
co-authored by Claude Opus 5.5
parent d204775235
commit 5fa59310f3
24 changed files with 506 additions and 136 deletions
+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");
});