Merge branch 'task/17' into task/18

This commit is contained in:
Lucas Winther
2026-10-30 22:14:56 +01:00
7 changed files with 140 additions and 30 deletions
+39 -17
View File
@@ -6,6 +6,10 @@ import { orderEngagements, setCustomValue } from "../edits.ts";
import {
actorConnectionId,
describeRating,
draftFailedSave,
draftIncoming,
draftSaved,
draftStartSave,
engagementNouns,
formatDateRange,
formatPartialDate,
@@ -16,6 +20,7 @@ import {
rollupLabel,
statusLabel,
timeAgo,
type Draft,
type PartialDate,
} from "../format.ts";
import { useEntryPatch, useSources } from "../hooks.ts";
@@ -357,6 +362,7 @@ export function SourcesBox({ entry, engagements }: { entry: EntryView; engagemen
export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
const scale = useRatingScale();
if (!defs.data?.length) return null;
const save = (def: CustomFieldDef, value: unknown) =>
patch.edit((cur) => {
@@ -370,7 +376,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
))}
</div>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
</Box>
);
}
@@ -494,21 +500,33 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
export function ReviewBox({ entry }: { entry: EntryView }) {
const patch = useEntryPatch(entry.id);
const [saved, setSaved] = useState(false);
const [text, setText] = useState(entry.review ?? "");
// The server value this text was last in sync with. When the review changes elsewhere (Restore,
// another tab) and the user has not typed since, show the new value, so a later blur can't undo it.
const synced = useRef(entry.review ?? "");
useEffect(() => {
const incoming = entry.review ?? "";
if (incoming === synced.current) return;
setText((cur) => (cur === synced.current ? incoming : cur));
synced.current = incoming;
}, [entry.review]);
// Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows
// up here unless the user has typed since; a failed save keeps what they typed.
const [draft, setDraft] = useState<Draft>(() => ({ text: entry.review ?? "", synced: entry.review ?? "", saving: false }));
const inFlight = useRef(0);
useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]);
const save = () => {
const next = text.trim() === "" ? null : text;
if ((next ?? "") === (entry.review ?? "")) return;
synced.current = next ?? "";
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
const next = draft.text.trim() === "" ? null : draft.text;
const stored = next ?? "";
if (stored === draft.synced && stored === (entry.review ?? "")) return;
const was = draft.synced;
inFlight.current += 1;
setDraft((d) => ({ ...draftStartSave(d), synced: stored }));
const settle = (d: Draft) => ({ ...d, saving: inFlight.current > 0 });
patch.mutate(
{ review: next },
{
onSuccess: () => {
inFlight.current -= 1;
setDraft((d) => settle(draftSaved(d, stored)));
setSaved(true);
},
onError: () => {
inFlight.current -= 1;
setDraft((d) => settle(draftFailedSave(d, was)));
},
},
);
};
return (
<Box
@@ -521,9 +539,13 @@ export function ReviewBox({ entry }: { entry: EntryView }) {
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
value={text}
value={draft.text}
placeholder="What stayed with you? Markdown works here."
onChange={(e) => (setText(e.target.value), setSaved(false))}
onChange={(e) => {
const text = e.target.value;
setDraft((d) => ({ ...d, text }));
setSaved(false);
}}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
+4 -2
View File
@@ -18,7 +18,9 @@ const TIMES = ["", "once", "twice", "three times"];
export function EntryDetail({ id }: { id: string }) {
const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
if (entry.isPending) return <div className="detail" aria-busy="true" />;
if (entry.error) {
// A failed background refetch (server briefly down) keeps showing the cached entry, so nothing the
// user is typing gets unmounted; only a first load that fails shows the error screen.
if (!entry.data) {
const gone = entry.error instanceof ApiError && entry.error.status === 404;
return (
<div className="detail-empty" role="alert">
@@ -96,7 +98,7 @@ function Detail({ entry }: { entry: EntryView }) {
onAdd={(t) => patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))}
onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))}
/>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
</div>
</header>
@@ -30,6 +30,8 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
const closeBtn = useRef<HTMLButtonElement>(null);
const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
const restore = useMutation({
// Same scope as the entry's PATCHes (useEntryPatch), so a restore never races a pending blur-save.
scope: { id: `entry-patch:${entry.id}` },
mutationFn: api.restore,
onSuccess: () => {
for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
+45 -2
View File
@@ -231,8 +231,9 @@ const TOO_LARGE = "That file is too large. The limit is 25 MB.";
/**
* An API error in the product's voice: what happened and how to fix it. Validation messages arrive as
* `field: reason` (core) or `field: Zod message`, several joined by "; "; the first one is described.
* Pass the user's rating scale so out-of-range ratings are described in the scale they see.
*/
export function friendlyError(err: unknown): string {
export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale } = {}): string {
if (!err || typeof err !== "object" || !("code" in err) || !("message" in err)) return "Something went wrong. Try again.";
const { status, code, message } = err as { status?: number; code: string; message: string };
if (status === 413) return TOO_LARGE;
@@ -249,7 +250,11 @@ export function friendlyError(err: unknown): string {
if (field === "currency" || reason === "not_cents") return "Price needs a number like 14 or 14.99, and a three-letter currency like GBP.";
if (reason === "not_url") return `${label} needs a full web address, starting with https://.`;
if (reason === "not_date" || reason === "bad_precision") return `${label} needs a date like 2025, 2025-03 or 2025-03-14.`;
if (reason === "out_of_range") return `${label} is out of range. Ratings go from 0 to 100.`;
if (reason === "out_of_range") {
const scale = opts.ratingScale ?? "5half";
const range = scale === "10" ? "0 and 10" : scale === "100" ? "0 and 100" : "0 and 5 stars";
return label === "Rating" ? `Pick a rating between ${range}.` : `Pick a rating for ${label} between ${range}.`;
}
if (reason === "required" || /^Too small: .*>=1 (character|item)/.test(reason) || /received undefined$/.test(reason)) return `${label} can't be empty.`;
if (reason === "too_long" || reason.startsWith("Too big")) return `${label} is too long. Shorten it and try again.`;
if (reason.startsWith("Too small")) return `${label} is too short.`;
@@ -258,3 +263,41 @@ export function friendlyError(err: unknown): string {
if (reason === "duplicate") return `${label} is already in use. Pick another.`;
return `${label} wasn't accepted. Check it and try again.`;
}
// ---------------------------------------------------------------------------------------------
// Review draft sync
// ---------------------------------------------------------------------------------------------
/**
* A text field edited locally and saved on blur. `synced` is the server value the text last agreed
* with; text that differs from it is unsaved and is never replaced. While a save is in flight,
* incoming values (the optimistic update, then a rollback on failure) are ignored, so the outcome
* does not depend on whether the rollback or the error callback lands first.
*/
export interface Draft {
text: string;
synced: string;
saving: boolean;
}
/** A server value arrived (refetch, Restore, rollback). Untouched text follows it; edited text is kept. */
export function draftIncoming(d: Draft, incoming: string): Draft {
if (d.saving || incoming === d.synced) return d;
const was = d.synced;
return { text: d.text === was ? incoming : d.text, synced: incoming, saving: false };
}
/** Saving the current text: it becomes the synced value unless the save fails. */
export function draftStartSave(d: Draft): Draft {
return { text: d.text, synced: d.text, saving: true };
}
/** The save succeeded with this stored value. */
export function draftSaved(d: Draft, stored: string): Draft {
return { ...d, synced: stored, saving: false };
}
/** The save failed: go back to the value before it, so the local text still counts as unsaved. */
export function draftFailedSave(d: Draft, was: string): Draft {
return { ...d, synced: was, saving: false };
}
+1
View File
@@ -29,6 +29,7 @@ export function Register() {
setError(null);
try {
await api.register({ username, password, displayName: displayName || undefined, inviteCode: inviteCode || undefined });
void qc.invalidateQueries({ queryKey: ["auth-status"] });
qc.setQueryData(["me"], await api.login(username, password));
navigate("/", { replace: true });
} catch (err) {
+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.");
});
});
+17 -7
View File
@@ -1,4 +1,5 @@
import type { Db } from "@sttracker/db/db";
import type { Executor } from "./history.ts";
import { fromJson, toJson } from "@sttracker/db/json";
import { nowIso } from "@sttracker/db/ids";
import type { Visibility } from "@sttracker/db/schema";
@@ -16,15 +17,20 @@ export interface Preferences {
export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" };
async function readRaw(db: Db, userId: string): Promise<Record<string, unknown>> {
const u = await db.k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst();
async function readRaw(k: Executor, userId: string, lock = false): Promise<Record<string, unknown>> {
let q = k.selectFrom("users").select("preferences").where("id", "=", userId);
if (lock) q = q.forUpdate();
const u = await q.executeTakeFirst();
if (!u) throw new NotFoundError("user");
return fromJson<Record<string, unknown>>(u.preferences, {});
}
/** The user's preferences; missing or invalid stored values fall back to the defaults. */
export async function getPreferences(db: Db, userId: string): Promise<Preferences> {
const raw = await readRaw(db, userId);
return toPreferences(await readRaw(db.k, userId));
}
function toPreferences(raw: Record<string, unknown>): Preferences {
return {
ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale,
defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility)
@@ -37,8 +43,12 @@ export async function getPreferences(db: Db, userId: string): Promise<Preference
export async function updatePreferences(db: Db, userId: string, patch: Partial<Preferences>): Promise<Preferences> {
if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid");
if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid");
const raw = await readRaw(db, userId);
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
await db.k.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
return getPreferences(db, userId);
// Read-modify-write in one transaction, with the user row locked on Postgres (SQLite runs one
// connection), so concurrent updates of different keys don't drop each other.
return db.k.transaction().execute(async (tx) => {
const raw = await readRaw(tx, userId, db.dialect === "postgres");
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
await tx.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
return toPreferences(next);
});
}