fix(web): keep review text on a failed save; review round 2

- Review draft sync is a pure helper (format.ts): incoming values are
  ignored while a save is in flight, and a failed save restores the
  previous synced value, so the typed text is kept whichever of the
  rollback and the error callback lands first. Tests cover both orders.
- A failed background refetch keeps showing the cached entry instead of
  the error screen (it unmounted the review box mid-outage).
- History restore joins the entry-patch mutation scope.
- updatePreferences: read-modify-write in a transaction, row locked on
  Postgres.
- Invalidate auth-status after register; out-of-range rating errors speak
  in the user's scale.

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:14:02 +01:00
1 parent 5fa59310f3
commit 4608c977b7
7 files changed
+140 -30

No files matched your search

+32 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
import { draftFailedSave, draftIncoming, draftStartSave, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
describe("formatRating", () => {
test("5 stars with halves", () => {
@@ -72,7 +72,9 @@ describe("friendlyError", () => {
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("rating: out_of_range"))).toBe("Pick a rating between 0 and 5 stars.");
expect(friendlyError(v("rating: out_of_range"), { ratingScale: "10" })).toBe("Pick a rating between 0 and 10.");
expect(friendlyError(v("customFields.Mood: out_of_range"), { ratingScale: "5" })).toBe("Pick a rating for Mood between 0 and 5 stars.");
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.");
@@ -94,3 +96,31 @@ describe("friendlyError", () => {
test("shortDate", () => {
expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct");
});
describe("review draft sync", () => {
const typed = { text: "Second thoughts.", synced: "First thoughts.", saving: false };
test("a rollback after a failed save keeps the local text (rollback lands first)", () => {
let d = draftStartSave(typed); // optimistic cache now says "Second thoughts."
d = draftIncoming(d, "Second thoughts.");
const was = typed.synced;
d = draftIncoming(d, "First thoughts."); // onError rolled the cache back
d = draftFailedSave(d, was);
expect(d.text).toBe("Second thoughts.");
expect(d.synced).toBe("First thoughts."); // so the text still counts as unsaved
expect(d.saving).toBe(false);
});
test("a rollback after a failed save keeps the local text (error callback first)", () => {
let d = draftStartSave(typed);
d = draftFailedSave(d, typed.synced);
d = draftIncoming(d, "First thoughts.");
expect(d.text).toBe("Second thoughts.");
});
test("a change from elsewhere (Restore) replaces untouched text but not edited text", () => {
const clean = { text: "Second thoughts.", synced: "Second thoughts.", saving: false };
expect(draftIncoming(clean, "First thoughts.").text).toBe("First thoughts.");
expect(draftIncoming(typed, "Something else").text).toBe("Second thoughts.");
});
});