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 <[email protected]>
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
+4
View File
@@ -23,6 +23,10 @@ Dark, calm, dense. Three panes on desktop (nav · library list · entry detail);
- Type: Inter for the UI; Gelasio for long-form review text.
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
- Orange means "a platform changed something". Never use it decoratively.
- Text on `--warn` (the Changes count, the "See what changed" button) is dark `#1c0f04` (6.4:1), never white: white on `#f97316` is 2.8:1 and fails WCAG AA.
- Sidebar group labels ("Sources") use `--text-faint`. The mockup's `#6f69a3` is 3.6:1 on `--surface-0` and fails AA.
- Placeholders on `--surface-3` inputs use `--text-muted`: `--text-faint` there is 4.15:1.
- Fonts are self-hosted in `apps/web/src/fonts/` (Inter, Gelasio; both OFL). The app makes no third-party font requests.
- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover.
## Media type colours (shared by both surfaces)
+11 -1
View File
@@ -3,7 +3,7 @@
* `X-Requested-With: sttracker` (the server's CSRF rule) and, unless it is multipart, a JSON content type.
* Wire types mirror the server's responses; the web app cannot import `@sttracker/core`.
*/
import type { PartialDate, Status } from "./format.ts";
import type { PartialDate, RatingScale, Status } from "./format.ts";
export class ApiError extends Error {
constructor(
@@ -138,6 +138,11 @@ export interface Attachment {
size: number;
}
export interface Preferences {
ratingScale: RatingScale;
defaultVisibility: "private" | "friends" | "public";
}
export interface Connection {
id: string;
pluginId: string;
@@ -206,7 +211,10 @@ export interface EntryQuery {
}
export const api = {
authStatus: () => request<{ hasUsers: boolean }>("GET", "auth/status"),
me: () => request<User>("GET", "me"),
preferences: () => request<Preferences>("GET", "me/preferences"),
updatePreferences: (patch: Partial<Preferences>) => request<Preferences>("PATCH", "me/preferences", { body: patch }),
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
request<User>("POST", "auth/register", { body }),
@@ -228,6 +236,8 @@ export const api = {
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
attachments: (entity: "entry" | "engagement" | "copy", entityId: string) =>
request<Attachment[]>("GET", "attachments", { query: { entity, entityId } }),
upload: (file: File, entityId: string) => {
const form = new FormData();
form.set("file", file);
+48 -33
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent, type ReactNode } from "react";
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link } from "wouter";
import { ApiError, api, type Attachment, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
import { api, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
import { orderEngagements, setCustomValue } from "../edits.ts";
import {
actorConnectionId,
describeRating,
@@ -9,6 +10,7 @@ import {
formatDateRange,
formatPartialDate,
formatRating,
friendlyError,
ordinal,
parsePartialDate,
rollupLabel,
@@ -33,7 +35,6 @@ function Box({ title, id, action, wide, className, children }: { title: string;
);
}
const errText = (e: unknown) => (e instanceof ApiError ? e.message : "That didn't save. Try again.");
// ---------------------------------------------------------------------------------------------
// Reads / plays
@@ -63,9 +64,14 @@ export function EngagementsBox({
setAdding(false);
refresh();
},
onError: (e) => setFormError(errText(e)),
onError: (e) => setFormError(friendlyError(e)),
});
const [removeError, setRemoveError] = useState<string | null>(null);
const remove = useMutation({
mutationFn: api.deleteEngagement,
onSuccess: () => (setRemoveError(null), refresh()),
onError: (e) => setRemoveError(friendlyError(e)),
});
const remove = useMutation({ mutationFn: api.deleteEngagement, onSuccess: refresh });
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -84,7 +90,7 @@ export function EngagementsBox({
}
// Oldest is the 1st; show newest first, like the mockup.
const ordered = [...(engagements ?? [])].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
const ordered = orderEngagements(engagements ?? []);
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
return (
@@ -120,6 +126,7 @@ export function EngagementsBox({
</div>
))
)}
{removeError && <p className="error-text" role="alert">{removeError}</p>}
{adding && (
<form onSubmit={submit} aria-label={nouns.log}>
<div className="two">
@@ -170,7 +177,7 @@ export function CopyBox({ entry }: { entry: EntryView }) {
setAdding(false);
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
},
onError: (e) => setFormError(errText(e)),
onError: (e) => setFormError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
@@ -255,21 +262,18 @@ export function CopyBox({ entry }: { entry: EntryView }) {
// Attachments
// ---------------------------------------------------------------------------------------------
// The API has no "list attachments of an entry" route yet, so this shows what was uploaded in this session.
const uploaded = new Map<string, Attachment[]>();
export function AttachmentsBox({ entry }: { entry: EntryView }) {
const [items, setItems] = useState<Attachment[]>(() => uploaded.get(entry.id) ?? []);
const qc = useQueryClient();
const list = useQuery({ queryKey: ["attachments", entry.id], queryFn: () => api.attachments("entry", entry.id) });
const items = list.data ?? [];
const [error, setError] = useState<string | null>(null);
const upload = useMutation({
mutationFn: (file: File) => api.upload(file, entry.id),
onSuccess: (a) => {
const next = [...items, a];
uploaded.set(entry.id, next);
setItems(next);
onSuccess: () => {
setError(null);
void qc.invalidateQueries({ queryKey: ["attachments", entry.id] });
},
onError: (e) => setError(e instanceof ApiError && e.code === "validation" ? "That file type isn't supported. Use an image, a PDF or a text file." : errText(e)),
onError: (e) => setError(friendlyError(e)),
});
return (
<Box title="Attachments" id="box-attachments">
@@ -309,7 +313,8 @@ export function AttachmentsBox({ entry }: { entry: EntryView }) {
</label>
</div>
{error && <p className="error-text" role="alert">{error}</p>}
{items.length === 0 && !error && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
{list.error && <p className="error-text" role="alert">{friendlyError(list.error)}</p>}
{items.length === 0 && !error && !list.isPending && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
</Box>
);
}
@@ -353,12 +358,11 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
if (!defs.data?.length) return null;
const save = (def: CustomFieldDef, value: unknown) => {
const next = { ...entry.customFields };
if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) delete next[def.id];
else next[def.id] = value;
if (JSON.stringify(next) !== JSON.stringify(entry.customFields)) patch.mutate({ customFields: next });
};
const save = (def: CustomFieldDef, value: unknown) =>
patch.edit((cur) => {
const next = setCustomValue(cur.customFields, def.id, value);
return JSON.stringify(next) === JSON.stringify(cur.customFields) ? null : { customFields: next };
});
return (
<Box title="Details" id="box-custom" wide>
<div className="custom">
@@ -366,7 +370,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">{errText(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
@@ -468,7 +472,7 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
<Link href={`/entries/${c.id}`}>
<span className="t">
{c.work.title}
<span className="s">{statusLabel(c.status, c.work.mediaType)}</span>
<span className="s">{statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)}</span>
</span>
{c.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
@@ -490,9 +494,20 @@ 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 save = (v: string) => {
const next = v.trim() === "" ? null : v;
if (next === entry.review) return;
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]);
const save = () => {
const next = text.trim() === "" ? null : text;
if ((next ?? "") === (entry.review ?? "")) return;
synced.current = next ?? "";
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
};
return (
@@ -506,15 +521,15 @@ export function ReviewBox({ entry }: { entry: EntryView }) {
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
defaultValue={entry.review ?? ""}
value={text}
placeholder="What stayed with you? Markdown works here."
onChange={() => setSaved(false)}
onBlur={(e) => save(e.target.value)}
onChange={(e) => (setText(e.target.value), setSaved(false))}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save(e.currentTarget.value);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
}}
/>
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
+14 -18
View File
@@ -2,7 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { ApiError, api, type EntryView } from "../api.ts";
import { engagementNouns, formatPartialDate, mediaTypeLabel, STATUSES, statusLabel } from "../format.ts";
import { addTag, removeTag } from "../edits.ts";
import { engagementNouns, formatPartialDate, friendlyError, mediaTypeLabel, shortDate, STATUSES, statusLabel } from "../format.ts";
import { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Cover } from "./Cover.tsx";
@@ -23,7 +24,7 @@ export function EntryDetail({ id }: { id: string }) {
<div className="detail-empty" role="alert">
<p>
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
{gone ? "It may have been deleted or moved. Pick another from your library." : entry.error.message}
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
</p>
</div>
);
@@ -51,8 +52,7 @@ function Detail({ entry }: { entry: EntryView }) {
const meta = [mediaTypeLabel(type)];
if (entry.work.year) meta.push(String(entry.work.year));
if (finished.length) {
const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
meta.push(`${past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
meta.push(`${nouns.past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
meta.push(`last finished ${formatPartialDate(last)}`);
}
@@ -91,8 +91,12 @@ function Detail({ entry }: { entry: EntryView }) {
</button>
</div>
</div>
<Tags entry={entry} onChange={(tags) => patch.mutate({ tags })} />
{patch.error && <p className="error-text" role="alert">{patch.error.message}</p>}
<Tags
tags={entry.tags}
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>}
</div>
</header>
@@ -141,26 +145,18 @@ function whoChanged(pending: { connectionId: string | null }[], sources: { id: s
return ids.size > 1 ? "Your sources" : "A source";
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function shortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
function Tags({ entry, onChange }: { entry: EntryView; onChange: (tags: string[]) => void }) {
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
const [draft, setDraft] = useState("");
function add() {
const t = draft.trim();
if (!t) return;
if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
if (draft.trim()) onAdd(draft);
setDraft("");
}
return (
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
{entry.tags.map((t) => (
{tags.map((t) => (
<li key={t} className="pill">
{t}
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onChange(entry.tags.filter((x) => x !== t))}>
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
<Icon name="x" />
</button>
</li>
+4 -4
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { api, type EntryView, type HistoryRecord } from "../api.ts";
import { actorConnectionId, formatPartialDate, formatRating, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
import { actorConnectionId, formatPartialDate, formatRating, friendlyError, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
import { useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Icon } from "./Icon.tsx";
@@ -52,7 +52,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
const show = (field: string, v: unknown): string => {
if (v === null || v === undefined || v === "") return "empty";
if (field === "rating") return formatRating(v as number, scale);
if (field === "status") return statusLabel(v as Status, entry.work.mediaType);
if (field === "status") return statusLabel(v as Status, entry.mediaTypeOverride ?? entry.work.mediaType);
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
@@ -74,7 +74,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
</button>
</header>
{history.isPending ? null : history.error ? (
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{history.error.message}</p>
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{friendlyError(history.error)}</p>
) : (
<ol>
{history.data.map((h) => {
@@ -109,7 +109,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
})}
</ol>
)}
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{restore.error.message}</p>}
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{friendlyError(restore.error)}</p>}
</aside>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
import { useState, type ReactNode } from "react";
import { useLocation } from "wouter";
import { api, type EntryView, type Work } from "../api.ts";
import { mediaTypeLabel } from "../format.ts";
import { friendlyError, mediaTypeLabel } from "../format.ts";
import { useDebounced } from "../hooks.ts";
import { Cover } from "./Cover.tsx";
import { Icon } from "./Icon.tsx";
@@ -96,7 +96,7 @@ export function LinkToCatalogue({ entry }: { entry: EntryView }) {
</ul>
)
)}
{error && <p className="error-text" role="alert">{error.message}</p>}
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
</section>
);
}
+9 -5
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { api } from "../api.ts";
import { useMe, usePendingChanges, useSources } from "../hooks.ts";
import { resetLibraryState } from "../routes/library.tsx";
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
return (
@@ -25,10 +26,12 @@ export function Shell({ children }: { children: ReactNode }) {
async function signOut() {
try {
await api.logout();
} finally {
qc.clear();
navigate("/login", { replace: true });
} catch {
// The session may already be gone; signing out locally is still right.
}
qc.clear();
resetLibraryState();
navigate("/login", { replace: true });
}
return (
@@ -43,8 +46,9 @@ export function Shell({ children }: { children: ReactNode }) {
<NavLink href="/changes" active={at("/changes")}>
Changes
{pending > 0 && (
<span className="count" aria-label={`${pending} waiting`}>
<span className="count">
{changes.data?.nextCursor ? `${pending}+` : pending}
<span className="sr-only"> waiting</span>
</span>
)}
</NavLink>
@@ -54,7 +58,7 @@ export function Shell({ children }: { children: ReactNode }) {
{(sources.data ?? []).map((s) => (
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
<span className="name">{s.name}</span>
<span className={`dot${s.status === "active" ? "" : " paused"}`} aria-label={s.status === "active" ? "Active" : "Paused"} />
<span className={`dot${s.status === "active" ? "" : " paused"}`} role="img" aria-label={s.status === "active" ? "Active" : "Paused"} />
</NavLink>
))}
<NavLink href="/sources" active={path === "/sources"}>
+42
View File
@@ -0,0 +1,42 @@
/**
* Next-value builders for entry edits. Callers build from the latest cached entry (not a render's
* props), so quick successive edits (two tag adds) compose instead of overwriting each other.
*/
import type { PartialDate } from "./format.ts";
export function addTag(tags: string[], tag: string): string[] {
const t = tag.trim();
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) return tags;
return [...tags, t];
}
export function removeTag(tags: string[], tag: string): string[] {
return tags.filter((x) => x !== tag);
}
/** Set a custom field value by definition id; null, "" and [] remove it. */
export function setCustomValue(fields: Record<string, unknown>, id: string, value: unknown): Record<string, unknown> {
const next = { ...fields };
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) delete next[id];
else next[id] = value;
return next;
}
/** The optimistic view of an entry after a PATCH. */
export function applyEntryPatch<T extends object>(entry: T, patch: Partial<T>): T {
return { ...entry, ...patch };
}
/**
* Reads/plays oldest first: by `finishedAt ?? startedAt` (partial dates compare as strings), then by
* when they were logged. Undated ones count as oldest.
*/
export function orderEngagements<G extends { createdAt: string; startedAt: PartialDate | null; finishedAt: PartialDate | null }>(list: G[]): G[] {
const key = (g: G) => (g.finishedAt?.value || g.startedAt?.value || "");
return [...list].sort((a, b) => {
const ka = key(a);
const kb = key(b);
if (ka !== kb) return ka < kb ? -1 : 1;
return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0;
});
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+72 -8
View File
@@ -9,7 +9,7 @@ export interface PartialDate {
/** How ratings are shown and entered. Stored ratings are always integers 0–100. */
export type RatingScale = "5half" | "5" | "10" | "100";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Size of one step of the scale, in 0–100 units. */
export function ratingStep(scale: RatingScale): number {
@@ -160,14 +160,20 @@ export function statusLabel(status: Status, type = "other"): string {
}
/** What one time through a work is called: reads, plays, watches, listens. */
export function engagementNouns(type: string): { box: string; one: string; verb: string; log: string } {
if (["book", "comic", "manga"].includes(type)) return { box: "Reads", one: "read", verb: "Reading", log: "Log a read" };
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", log: "Log a play" };
export function engagementNouns(type: string): { box: string; one: string; verb: string; past: string; log: string } {
if (["book", "comic", "manga"].includes(type)) return { box: "Reads", one: "read", verb: "Reading", past: "Read", log: "Log a read" };
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", past: "Played", log: "Log a play" };
if (["movie", "show", "season", "episode", "video"].includes(type))
return { box: "Watches", one: "watch", verb: "Watching", log: "Log a watch" };
return { box: "Watches", one: "watch", verb: "Watching", past: "Watched", log: "Log a watch" };
if (["podcast", "podcast_episode", "audio_drama", "artist", "album", "track"].includes(type))
return { box: "Listens", one: "listen", verb: "Listening", log: "Log a listen" };
return { box: "Times through", one: "time", verb: "In progress", log: "Log a time" };
return { box: "Listens", one: "listen", verb: "Listening", past: "Listened", log: "Log a listen" };
return { box: "Times through", one: "time", verb: "In progress", past: "Finished", log: "Log a time" };
}
/** "12 Oct" for a real timestamp, in local time. */
export function shortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
@@ -189,8 +195,66 @@ export function timeAgo(iso: string, now: Date = new Date()): string {
return `${t.getDate()} ${MONTHS[t.getMonth()]} ${t.getFullYear()}`;
}
/** "Hardcover" from actor "plugin:hardcover:<id>", for history rows. */
/** The connection id in a plugin actor `plugin:<pluginId>:<connectionId>`, else null. */
export function actorConnectionId(actor: string): string | null {
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
return m ? m[1]! : null;
}
// ---------------------------------------------------------------------------------------------
// Errors in plain words
// ---------------------------------------------------------------------------------------------
const FIELD_LABELS: Record<string, string> = {
startedAt: "Started",
finishedAt: "Finished",
acquiredAt: "When",
acquiredFrom: "Bought at",
priceCents: "Price",
privateNotes: "Private notes",
inviteCode: "Invite code",
coverUrl: "Cover",
workId: "Catalogue entry",
};
/** "customFields.Website" → "Website"; "displayName" → "Display name". */
function fieldLabel(field: string): string {
if (field.startsWith("customFields.")) return field.slice("customFields.".length);
const last = field.split(".").pop() ?? field;
if (FIELD_LABELS[last]) return FIELD_LABELS[last]!;
const words = last.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
}
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.
*/
export function friendlyError(err: unknown): 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;
if (code !== "validation") return message;
const first = message.split("; ")[0] ?? "";
const i = first.indexOf(": ");
const field = i > 0 ? first.slice(0, i) : "";
const reason = i > 0 ? first.slice(i + 2) : first;
const label = fieldLabel(field || "value");
if (field === "file" && /larger|large/.test(reason)) return TOO_LARGE;
if (field === "mime") return "That file type isn't supported. Use an image, a PDF or a text file.";
if (field === "password") return "Passwords need at least 8 characters.";
if (field === "username") return "Usernames are 2–32 characters: lowercase letters, digits and underscores.";
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 === "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.`;
if (reason === "not_number" || reason === "not_integer") return `${label} needs a number.`;
if (reason === "unknown_option" || reason === "duplicate_option") return `${label} has an option that no longer exists. Pick one from the list.`;
if (reason === "duplicate") return `${label} is already in use. Pick another.`;
return `${label} wasn't accepted. Check it and try again.`;
}
+33 -5
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type ChangeEvent, type EntryPatch } from "./api.ts";
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
import { applyEntryPatch } from "./edits.ts";
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
export function usePendingChanges() {
@@ -31,16 +32,43 @@ export function useDebounced<T>(value: T, ms = 220): T {
return v;
}
/** Shared save path for every detail edit: PATCH, then refresh the entry and the list. */
/**
* Shared save path for every detail edit. The cached entry is updated optimistically (rolled back on
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
* builds from the latest cache, so quick successive edits compose.
*/
export function useEntryPatch(id: string) {
const qc = useQueryClient();
return useMutation({
const key = ["entry", id];
const mutationKey = ["entry-patch", id];
const m = useMutation({
mutationKey,
scope: { id: `entry-patch:${id}` },
mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<EntryView>(key);
if (prev) qc.setQueryData<EntryView>(key, applyEntryPatch<EntryView>(prev, patch));
return { prev };
},
onError: (_e, _patch, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
void qc.invalidateQueries({ queryKey: key });
},
onSuccess: (entry) => {
qc.setQueryData(["entry", id], entry);
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
},
onSettled: () => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["history", id] });
},
});
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
const cur = qc.getQueryData<EntryView>(key);
const patch = cur ? build(cur) : null;
if (patch) m.mutate(patch);
};
return { ...m, edit };
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { Redirect, Route, Switch } from "wouter";
import { ApiError } from "./api.ts";
import { friendlyError } from "./format.ts";
import { Shell } from "./components/Shell.tsx";
import { useMe } from "./hooks.ts";
import { Library } from "./routes/library.tsx";
@@ -35,7 +36,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
<div className="auth-card" role="alert">
<span className="logo">STTracker</span>
<h1>Can't load your library</h1>
<p className="lede">{me.error.message}</p>
<p className="lede">{friendlyError(me.error)}</p>
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
</div>
</div>
+21 -11
View File
@@ -1,11 +1,11 @@
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { useRef, useState, type FormEvent } from "react";
import { Link, useLocation } from "wouter";
import { ApiError, api, type EntryView } from "../api.ts";
import { api, type EntryView } from "../api.ts";
import { Cover } from "../components/Cover.tsx";
import { EntryDetail } from "../components/EntryDetail.tsx";
import { Icon } from "../components/Icon.tsx";
import { describeRating, formatRating, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
import { describeRating, formatRating, friendlyError, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
import { useDebounced, usePendingChanges } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
@@ -34,7 +34,13 @@ export function Library({ selectedId }: { selectedId: string | null }) {
}
// Filters survive navigating between entries (the list pane remounts on route change).
let savedFilters: { q: string; mediaType?: string; status?: Status; draft: boolean } = { q: "", draft: false };
type Filters = { q: string; mediaType?: string; status?: Status; draft: boolean };
let savedFilters: Filters = { q: "", draft: false };
/** Forget per-session library state (on sign-out). */
export function resetLibraryState(): void {
savedFilters = { q: "", draft: false };
}
function ListPane({ selectedId }: { selectedId: string | null }) {
const [, navigate] = useLocation();
@@ -57,22 +63,24 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
const flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
const [quickError, setQuickError] = useState<string | null>(null);
const quickInput = useRef<HTMLInputElement>(null);
const quick = useMutation({
mutationFn: api.quickAdd,
onSuccess: (entry, _t) => {
qc.setQueryData(["entry", entry.id], entry);
void qc.invalidateQueries({ queryKey: ["entries"] });
navigate(`/entries/${entry.id}`);
if (quickInput.current) quickInput.current.value = "";
quickInput.current?.focus();
},
onError: (e) => setQuickError(e instanceof ApiError ? e.message : "Couldn't add that. Try again."),
onError: (e) => setQuickError(friendlyError(e)),
});
function onQuick(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
const title = input.value.trim();
const title = (quickInput.current?.value ?? "").trim();
if (!title || quick.isPending) return;
setQuickError(null);
quick.mutate(title, { onSuccess: () => (input.value = "") });
quick.mutate(title);
}
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
@@ -98,12 +106,14 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
<label className="sr-only" htmlFor="quick-add">Quick add</label>
<input
id="quick-add"
ref={quickInput}
name="title"
className="input"
placeholder="Quick add: type a title, sort it out later"
autoComplete="off"
maxLength={1000}
disabled={quick.isPending}
readOnly={quick.isPending}
aria-busy={quick.isPending}
aria-describedby={quickError ? "quick-error" : undefined}
/>
</form>
@@ -157,7 +167,7 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
</ul>
) : entries.error ? (
<div className="list-note" role="alert">
<p>{entries.error.message}</p>
<p>{friendlyError(entries.error)}</p>
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
</div>
) : items.length === 0 ? (
@@ -208,7 +218,7 @@ export function rowMeta(e: EntryView): string {
function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType<typeof useRatingScale> }) {
return (
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
<Cover url={e.work.coverUrl} mediaType={e.work.mediaType} draft={e.draft} />
<Cover url={e.work.coverUrl} mediaType={e.mediaTypeOverride ?? e.work.mediaType} draft={e.draft} />
<div style={{ minWidth: 0 }}>
<span className="title">{e.work.title}</span>
<div className="meta">{rowMeta(e)}</div>
+10 -5
View File
@@ -1,13 +1,15 @@
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { Link, useLocation } from "wouter";
import { ApiError, api } from "../api.ts";
import { Link, Redirect, useLocation } from "wouter";
import { api } from "../api.ts";
import { friendlyError } from "../format.ts";
export function Login() {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -19,11 +21,14 @@ export function Login() {
qc.setQueryData(["me"], user);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Sign-in failed. Try again.");
setError(friendlyError(err));
setBusy(false);
}
}
// A fresh instance has nobody to sign in as: go straight to creating the admin account.
if (status.data?.hasUsers === false) return <Redirect to="/register" replace />;
return (
<main className="auth">
<div className="auth-card">
@@ -43,7 +48,7 @@ export function Login() {
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
</form>
<p className="alt">
First time here? <Link className="text-link" href="/register">Create the admin account</Link>
Got an invite? <Link className="text-link" href="/register">Create your account</Link>
</p>
</div>
</main>
+11 -7
View File
@@ -1,18 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { Link, useLocation, useSearch } from "wouter";
import { ApiError, api } from "../api.ts";
import { friendlyError } from "../format.ts";
/**
* First-run screen. The first account becomes the admin; later accounts need an invite code. The API
* cannot say whether users exist, so the screen assumes first run and switches to invite mode when
* the server answers `invite_required` (or the link carries `?invite=`).
* First-run screen. The first account becomes the admin; later accounts need an invite code.
* `GET auth/status` says which; `?invite=` prefills the code from an invite link.
*/
export function Register() {
const qc = useQueryClient();
const [, navigate] = useLocation();
const invite = new URLSearchParams(useSearch()).get("invite") ?? "";
const [needInvite, setNeedInvite] = useState(invite !== "");
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
const [inviteRequired, setInviteRequired] = useState(false);
const needInvite = invite !== "" || inviteRequired || status.data?.hasUsers === true;
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -31,13 +33,15 @@ export function Register() {
navigate("/", { replace: true });
} catch (err) {
if (err instanceof ApiError && err.code === "invite_required") {
setNeedInvite(true);
setInviteRequired(true);
setError(inviteCode ? "That invite code didn't work. Check it, or ask the admin for a new one." : "This STTracker already has an admin. Ask them for an invite code and enter it below.");
} else setError(err instanceof ApiError ? err.message : "Couldn't create the account. Try again.");
} else setError(friendlyError(err));
setBusy(false);
}
}
if (status.isPending && !invite) return <div className="boot"><span className="logo">STTracker</span></div>;
return (
<main className="auth">
<div className="auth-card">
+7 -32
View File
@@ -1,38 +1,13 @@
import { useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api.ts";
import type { RatingScale } from "./format.ts";
/**
* The rating scale preference. The API has no preferences endpoint yet, so it lives in this browser
* (localStorage) and defaults to five stars with halves. Settings (Task 18) writes it with `setRatingScale`.
*/
const KEY = "sttracker.ratingScale";
const SCALES: RatingScale[] = ["5half", "5", "10", "100"];
const listeners = new Set<() => void>();
function read(): RatingScale {
try {
const v = localStorage.getItem(KEY);
return SCALES.includes(v as RatingScale) ? (v as RatingScale) : "5half";
} catch {
return "5half";
}
}
export function setRatingScale(scale: RatingScale): void {
localStorage.setItem(KEY, scale);
for (const l of listeners) l();
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
const onStorage = (e: StorageEvent) => e.key === KEY && cb();
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
};
/** The signed-in user's preferences (GET me/preferences). Settings updates them with `api.updatePreferences`. */
export function usePreferences() {
return useQuery({ queryKey: ["preferences"], queryFn: api.preferences, staleTime: 5 * 60_000 });
}
/** The rating scale preference; five stars with halves until preferences load. */
export function useRatingScale(): RatingScale {
return useSyncExternalStore(subscribe, read, () => "5half");
return usePreferences().data?.ratingScale ?? "5half";
}
+18 -1
View File
@@ -8,6 +8,22 @@
font-display: swap;
}
/* Gelasio (OFL), official variable fonts from github.com/google/fonts ofl/gelasio, unmodified. */
@font-face {
font-family: "Gelasio";
src: url("./fonts/gelasio-wght.ttf") format("truetype");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gelasio";
src: url("./fonts/gelasio-italic-wght.ttf") format("truetype");
font-weight: 400 700;
font-style: italic;
font-display: swap;
}
:root {
/* DESIGN.md */
--surface-0: #17152b;
@@ -62,7 +78,7 @@ body {
line-height: 1.45;
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
caret-color: var(--rating);
caret-color: var(--text);
}
::selection { background: #6d5dfc80; color: #fff; }
* { scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent; }
@@ -274,6 +290,7 @@ svg.icon { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: no
.box .r > :last-child { text-align: right; }
.box .r .x { opacity: 0; transition: opacity 0.15s; }
.box .r:hover .x, .box .r .x:focus-visible { opacity: 1; }
@media (hover: none) { .box .r .x { opacity: 1; } }
.box .empty-line { color: var(--text-muted); font-size: 0.84rem; }
.box form { display: grid; gap: 10px; margin-top: 8px; }
.box form .two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+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");
});
+2 -2
View File
@@ -15,8 +15,8 @@ user layer or files a Changes-inbox event.
| Path | Responsibility | State |
|---|---|---|
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
| `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists |
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
+26
View File
@@ -4,6 +4,32 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin
One entry per decision a future maintainer would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts
**Status:** accepted
**Context:** Task 17 and its fix round 1 (controller rulings).
**Decision:**
- **Three small API routes for the web app.** `GET auth/status` (no auth) → `{ hasUsers }`: `/login` redirects a fresh
instance to `/register`, which shows "Create the admin account", or invite mode when users exist (also on
`invite_required`, and with `?invite=` prefilled). `GET attachments?entity=&entityId=` lists an owned record's
attachments with their links (404 when the record is not the caller's). `GET/PATCH me/preferences` →
`{ ratingScale: "5half"|"5"|"10"|"100", defaultVisibility }`, stored in `users.preferences` under the same keys
`defaultVisibility()` already reads (`packages/core/src/preferences.ts`); unknown stored keys are kept.
- **Rating scale** comes from preferences (default five stars with halves). Stored ratings stay integers 0–100; the web
formats them with `formatRating(value, scale)`.
- **Entry edits are optimistic and serial.** `useEntryPatch` updates the cached entry in `onMutate` (rollback on error),
runs one entry's PATCHes in order through a TanStack mutation `scope`, and `edit(cur => patch)` builds from the latest
cache, so two quick tag adds keep both. Only the last queued edit writes the server's answer back.
- **Keyboard rating saves once:** arrow steps update locally and save 500 ms after the last key, on blur, or on unmount,
so stepping does not write a history row per keypress.
- **Errors in the product's voice:** every error display goes through `friendlyError()` (`apps/web/src/format.ts`), which maps
`validation` field/reason pairs and 413 to plain copy; other API errors keep the server's message.
- **AA contrast on the approved palette:** dark text on `--warn`, `--text-faint` group labels, `--text-muted` placeholders on
`--surface-3` (recorded in DESIGN.md).
- **Fonts are vendored** (Inter woff2 from fontsource; Gelasio as the official variable TTFs from github.com/google/fonts
`ofl/gelasio`, unmodified, because no woff2 tooling was available offline) with their OFL texts.
- **The web app mirrors wire types** in `apps/web/src/api.ts` instead of importing `@sttracker/core` (apps/web has no
workspace dependency on it, and the browser bundle must not pull server code).
## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
+4
View File
@@ -18,6 +18,9 @@ Small, specific, independently actionable. Remove only when actually done.
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
- [ ] Phase 2 and Phase 3 plans (spec §12).
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
- [ ] Web: convert the vendored Gelasio variable TTFs (~170 KB each) to latin-subset woff2 once fonttools/woff2 is available; Bun inlines fonts into the CSS bundle.
- [ ] Web/API: "Linked sources" is inferred from `plugin:` actors in `fieldSources`, so a source that matched an entry but never wrote a field is not shown. Add a per-entry source-links route.
- [ ] Web: the detail pane only lists attachments of the entry itself, not those linked to its reads or copies.
## Someday
@@ -32,6 +35,7 @@ Small, specific, independently actionable. Remove only when actually done.
file, and compare steps and token use.
## Known issues
- [ ] Web: in production the bundled HTML references `/../../chunk-*.css|js` (Bun bundles `../../web/index.html` from `apps/server`). Browsers normalise it to `/chunk-*` and it loads; check it behind a reverse proxy.
- [ ] Plugins: reload re-imports entry files only; edits to a plugin's other modules need a server restart.
- [ ] Plugins: rate limiting is per run (each run builds its own client), not per plugin (spec §11); share a limiter per plugin in Phase 2.
- [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.