feat(web): api client and display formatters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a540208e1
commit
0bb871cd19
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The `/api/v1` client. Cookie auth (`credentials: "same-origin"`); every non-GET request sends
|
||||
* `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";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
export interface RequestOpts {
|
||||
body?: unknown;
|
||||
form?: FormData;
|
||||
query?: Record<string, QueryValue>;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(method: Method, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
const url = `/api/v1/${path}${qs.size ? `?${qs}` : ""}`;
|
||||
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
let body: BodyInit | undefined;
|
||||
if (method !== "GET") {
|
||||
headers["x-requested-with"] = "sttracker";
|
||||
if (opts.form) body = opts.form;
|
||||
else {
|
||||
headers["content-type"] = "application/json";
|
||||
if (opts.body !== undefined) body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await (opts.fetchImpl ?? fetch)(url, { method, headers, body, credentials: "same-origin" });
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Can't reach the server. Check your connection and try again.");
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
|
||||
if (err?.code) throw new ApiError(res.status, err.code, err.message ?? err.code);
|
||||
throw new ApiError(res.status, `http_${res.status}`, `The server answered ${res.status}. Try again in a moment.`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: "admin" | "member";
|
||||
}
|
||||
|
||||
export interface EntryView {
|
||||
id: string;
|
||||
workId: string;
|
||||
status: Status;
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
privateNotes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
visibility: "private" | "friends" | "public";
|
||||
overrides: { title?: string; coverUrl?: string; year?: number | null };
|
||||
mediaTypeOverride: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null };
|
||||
draft: boolean;
|
||||
childSummary: { total: number; completed: number } | null;
|
||||
}
|
||||
|
||||
export type EntryPatch = Partial<
|
||||
Pick<EntryView, "status" | "rating" | "review" | "privateNotes" | "favorite" | "tags" | "customFields" | "visibility" | "overrides">
|
||||
>;
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: string;
|
||||
entryId: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
progress: Record<string, number | string>;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Copy {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
titleHint: string | null;
|
||||
format: string | null;
|
||||
acquiredFrom: string | null;
|
||||
acquiredAt: PartialDate | null;
|
||||
priceCents: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
caption: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lastRunAt: string | null;
|
||||
hasCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
connectionId: string | null;
|
||||
targetEntity: string;
|
||||
targetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
actor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomFieldDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
id: string;
|
||||
media_type: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
cover_url: string;
|
||||
creators: { name: string; role?: string }[];
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
workId: string;
|
||||
candidateWorkId: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Endpoints
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface EntryQuery {
|
||||
q?: string;
|
||||
mediaType?: string;
|
||||
status?: Status;
|
||||
draft?: boolean;
|
||||
parentId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<User>("GET", "me"),
|
||||
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 }),
|
||||
logout: () => request<void>("POST", "auth/logout"),
|
||||
|
||||
entries: (query: EntryQuery) => request<Page<EntryView>>("GET", "entries", { query: { ...query } }),
|
||||
entry: (id: string) => request<EntryView>("GET", `entries/${id}`),
|
||||
quickAdd: (title: string) => request<EntryView>("POST", "entries/quick", { body: { title } }),
|
||||
patchEntry: (id: string, patch: EntryPatch) => request<EntryView>("PATCH", `entries/${id}`, { body: patch }),
|
||||
moveEntry: (id: string, workId: string) => request<EntryView>("POST", `entries/${id}/move`, { body: { workId } }),
|
||||
history: (id: string) => request<HistoryRecord[]>("GET", `entries/${id}/history`),
|
||||
restore: (historyId: string) => request<void>("POST", `history/${historyId}/restore`),
|
||||
|
||||
engagements: (entryId: string) => request<Engagement[]>("GET", `entries/${entryId}/engagements`),
|
||||
addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) =>
|
||||
request<Engagement>("POST", `entries/${entryId}/engagements`, { body }),
|
||||
deleteEngagement: (id: string) => request<void>("DELETE", `engagements/${id}`),
|
||||
|
||||
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
||||
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
|
||||
|
||||
upload: (file: File, entityId: string) => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", entityId);
|
||||
return request<Attachment>("POST", "attachments", { form });
|
||||
},
|
||||
|
||||
sources: () => request<Connection[]>("GET", "sources"),
|
||||
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
|
||||
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
|
||||
|
||||
work: (id: string) => request<Work>("GET", `works/${id}`),
|
||||
searchWorks: (q: string, mediaType?: string) => request<Work[]>("GET", "works/search", { query: { q, mediaType, limit: 8 } }),
|
||||
suggestions: (workId: string) => request<Suggestion[]>("GET", `works/${workId}/suggestions`),
|
||||
acceptSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/accept`),
|
||||
rejectSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/reject`),
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/** Display helpers. Pure functions: no DOM, no network. Partial dates never go through `Date`. */
|
||||
|
||||
export type Precision = "day" | "month" | "year" | "unknown";
|
||||
export interface PartialDate {
|
||||
value: string;
|
||||
precision: Precision;
|
||||
}
|
||||
|
||||
/** 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"];
|
||||
|
||||
/** Size of one step of the scale, in 0–100 units. */
|
||||
export function ratingStep(scale: RatingScale): number {
|
||||
return scale === "5half" ? 10 : scale === "5" ? 20 : scale === "10" ? 5 : 1;
|
||||
}
|
||||
|
||||
/** Snap a 0–100 value to the scale's nearest step. */
|
||||
export function snapRating(value: number, scale: RatingScale): number {
|
||||
const step = ratingStep(scale);
|
||||
return Math.min(100, Math.max(0, Math.round(value / step) * step));
|
||||
}
|
||||
|
||||
/** `formatRating(90, "5half")` is `"★★★★½"`; null is `""`. */
|
||||
export function formatRating(value: number | null | undefined, scale: RatingScale): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const v = snapRating(value, scale);
|
||||
if (scale === "10") return `${v / 10}/10`;
|
||||
if (scale === "100") return `${v}/100`;
|
||||
const stars = v / 20;
|
||||
const full = Math.floor(stars);
|
||||
const half = stars - full >= 0.5;
|
||||
return "★".repeat(full) + (half ? "½" : "") + "☆".repeat(5 - full - (half ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Screen-reader text for a rating: "4½ stars", "9 out of 10", "Not rated". */
|
||||
export function describeRating(value: number | null | undefined, scale: RatingScale): string {
|
||||
if (value === null || value === undefined) return "Not rated";
|
||||
const v = snapRating(value, scale);
|
||||
if (scale === "10") return `${v / 10} out of 10`;
|
||||
if (scale === "100") return `${v} out of 100`;
|
||||
const stars = v / 20;
|
||||
const whole = Math.floor(stars);
|
||||
const label = stars % 1 ? `${whole === 0 ? "" : whole}½` : String(whole);
|
||||
return `${label} ${stars === 1 ? "star" : "stars"}`;
|
||||
}
|
||||
|
||||
/** `{value:"2025-03",precision:"month"}` is `"Mar 2025"`. Unknown or missing dates are `""`. */
|
||||
export function formatPartialDate(pd: PartialDate | null | undefined): string {
|
||||
if (!pd || pd.precision === "unknown" || !pd.value) return "";
|
||||
const y = pd.value.slice(0, 4);
|
||||
if (pd.precision === "year") return y;
|
||||
const month = MONTHS[Number(pd.value.slice(5, 7)) - 1] ?? "";
|
||||
if (pd.precision === "month") return `${month} ${y}`;
|
||||
return `${Number(pd.value.slice(8, 10))} ${month} ${y}`;
|
||||
}
|
||||
|
||||
const isExact = (pd: PartialDate | null | undefined) => !pd || pd.precision === "day";
|
||||
|
||||
/**
|
||||
* A read/play's dates: "2 Sep – 14 Sep 2026", "Mar 2025 (no exact date)", "Started 2 Sep 2026".
|
||||
* Anything coarser than a day is flagged so a month never passes for a precise date.
|
||||
*/
|
||||
export function formatDateRange(start: PartialDate | null | undefined, end: PartialDate | null | undefined): string {
|
||||
const s = formatPartialDate(start);
|
||||
const e = formatPartialDate(end);
|
||||
const suffix = isExact(start) && isExact(end) ? "" : " (no exact date)";
|
||||
if (!s && !e) return "No dates";
|
||||
if (s && !e) return `Started ${s}${suffix}`;
|
||||
if (!s) return `${e}${suffix}`;
|
||||
if (s === e) return `${e}${suffix}`;
|
||||
// Drop the repeated year: "2 Sep – 14 Sep 2026".
|
||||
const sameYear = start!.value.slice(0, 4) === end!.value.slice(0, 4) && start!.precision !== "year";
|
||||
const left = sameYear ? s.slice(0, -5) : s;
|
||||
return `${left} – ${e}${suffix}`;
|
||||
}
|
||||
|
||||
/** Accepts `YYYY`, `YYYY-MM` and `YYYY-MM-DD`. Anything else (including `03/04/2019`) is null. */
|
||||
export function parsePartialDate(input: string): PartialDate | null {
|
||||
const s = input.trim();
|
||||
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s);
|
||||
if (!m) return null;
|
||||
if (m[2] === undefined) return { value: s, precision: "year" };
|
||||
const month = Number(m[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
if (m[3] === undefined) return { value: s, precision: "month" };
|
||||
const day = Number(m[3]);
|
||||
const y = Number(m[1]);
|
||||
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||
const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!;
|
||||
return day >= 1 && day <= dim ? { value: s, precision: "day" } : null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
book: "Book",
|
||||
comic: "Comic",
|
||||
manga: "Manga",
|
||||
game: "Game",
|
||||
movie: "Movie",
|
||||
show: "Show",
|
||||
season: "Season",
|
||||
episode: "Episode",
|
||||
podcast: "Podcast",
|
||||
podcast_episode: "Podcast episode",
|
||||
audio_drama: "Audio drama",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
track: "Track",
|
||||
video: "Video",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export function mediaTypeLabel(type: string): string {
|
||||
return TYPE_LABELS[type] ?? type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/** The cover colour family for a media type (DESIGN.md "Media type colours"). */
|
||||
export function mediaColour(type: string): string {
|
||||
if (["book", "comic", "manga"].includes(type)) return "#6b3fa0";
|
||||
if (["podcast", "podcast_episode", "audio_drama"].includes(type)) return "#2f6b58";
|
||||
if (type === "game") return "#c1652f";
|
||||
if (["movie", "show", "season", "episode", "video"].includes(type)) return "#344b7a";
|
||||
if (["artist", "album", "track"].includes(type)) return "#a33b62";
|
||||
return "#4b4485";
|
||||
}
|
||||
|
||||
const CHILD_NOUN: Record<string, [string, string]> = {
|
||||
show: ["season", "seasons"],
|
||||
season: ["episode", "episodes"],
|
||||
podcast: ["episode", "episodes"],
|
||||
audio_drama: ["episode", "episodes"],
|
||||
manga: ["volume", "volumes"],
|
||||
comic: ["issue", "issues"],
|
||||
book: ["volume", "volumes"],
|
||||
album: ["track", "tracks"],
|
||||
artist: ["album", "albums"],
|
||||
};
|
||||
|
||||
/** Children roll up as "season 3 of 5", "5 seasons" (none finished) or "all 200 episodes". */
|
||||
export function rollupLabel(type: string, summary: { total: number; completed: number } | null): string | null {
|
||||
if (!summary || summary.total === 0) return null;
|
||||
const [one, many] = CHILD_NOUN[type] ?? ["part", "parts"];
|
||||
const { total, completed } = summary;
|
||||
if (completed === 0) return `${total} ${total === 1 ? one : many}`;
|
||||
if (completed >= total) return total === 1 ? `1 ${one}` : `all ${total} ${many}`;
|
||||
return `${one} ${completed} of ${total}`;
|
||||
}
|
||||
|
||||
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
|
||||
export const STATUSES: Status[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"];
|
||||
|
||||
/** Status in the words of the medium: "Reading", "Playing", "Watching", "Listening". */
|
||||
export function statusLabel(status: Status, type = "other"): string {
|
||||
if (status === "in_progress") {
|
||||
const verb = engagementNouns(type).verb;
|
||||
return verb;
|
||||
}
|
||||
return { planned: "Planned", paused: "Paused", completed: "Finished", dropped: "Dropped", none: "No status" }[status];
|
||||
}
|
||||
|
||||
/** 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" };
|
||||
if (["movie", "show", "season", "episode", "video"].includes(type))
|
||||
return { box: "Watches", one: "watch", verb: "Watching", 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" };
|
||||
}
|
||||
|
||||
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
|
||||
export function ordinal(n: number): string {
|
||||
const tens = n % 100;
|
||||
if (tens >= 11 && tens <= 13) return `${n}th`;
|
||||
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
|
||||
}
|
||||
|
||||
/** "4 min ago", "3 h ago", "2 days ago", else "14 Sep 2026". For real timestamps only. */
|
||||
export function timeAgo(iso: string, now: Date = new Date()): string {
|
||||
const t = new Date(iso);
|
||||
const s = Math.round((now.getTime() - t.getTime()) / 1000);
|
||||
if (Number.isNaN(s)) return "";
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
||||
if (s < 86400 * 7) return `${Math.floor(s / 86400)} ${s < 86400 * 2 ? "day" : "days"} ago`;
|
||||
return `${t.getDate()} ${MONTHS[t.getMonth()]} ${t.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** "Hardcover" from actor "plugin:hardcover:<id>", for history rows. */
|
||||
export function actorConnectionId(actor: string): string | null {
|
||||
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ApiError, request } from "../src/api.ts";
|
||||
|
||||
function fakeFetch(res: Response | (() => never)) {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fn = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
if (typeof res === "function") return res();
|
||||
return res.clone();
|
||||
}) as unknown as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
describe("request", () => {
|
||||
test("throws an ApiError carrying code on a non-2xx JSON error", async () => {
|
||||
const { fn } = fakeFetch(
|
||||
new Response(JSON.stringify({ error: { code: "validation", message: "title: required" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("validation");
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.message).toBe("title: required");
|
||||
});
|
||||
|
||||
test("non-GET requests send the CSRF header, JSON and same-origin cookies", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "e1" }), { status: 201 }));
|
||||
const out = await request<{ id: string }>("POST", "entries/quick", { body: { title: "Dune" }, fetchImpl: fn });
|
||||
expect(out).toEqual({ id: "e1" });
|
||||
const { url, init } = calls[0]!;
|
||||
expect(url).toBe("/api/v1/entries/quick");
|
||||
const h = new Headers(init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
expect(init.credentials).toBe("same-origin");
|
||||
expect(init.body).toBe(JSON.stringify({ title: "Dune" }));
|
||||
});
|
||||
|
||||
test("login and register also send the CSRF header and JSON content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "u1" })));
|
||||
await request("POST", "auth/login", { body: { username: "a", password: "b" }, fetchImpl: fn });
|
||||
await request("POST", "history/h1/restore", { fetchImpl: fn });
|
||||
for (const c of calls) {
|
||||
const h = new Headers(c.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
}
|
||||
});
|
||||
|
||||
test("multipart uploads send the CSRF header but let fetch set the content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "a1" }), { status: 201 }));
|
||||
const form = new FormData();
|
||||
form.set("entity", "entry");
|
||||
await request("POST", "attachments", { form, fetchImpl: fn });
|
||||
const h = new Headers(calls[0]!.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBeNull();
|
||||
expect(calls[0]!.init.body).toBe(form);
|
||||
});
|
||||
|
||||
test("GET builds the query string and skips empty values", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ items: [] })));
|
||||
await request("GET", "entries", { query: { q: "dune", status: undefined, draft: true, limit: 50 }, fetchImpl: fn });
|
||||
expect(calls[0]!.url).toBe("/api/v1/entries?q=dune&draft=true&limit=50");
|
||||
});
|
||||
|
||||
test("204 resolves to undefined", async () => {
|
||||
const { fn } = fakeFetch(new Response(null, { status: 204 }));
|
||||
expect(await request("DELETE", "engagements/x", { fetchImpl: fn })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a non-JSON error still becomes an ApiError", async () => {
|
||||
const { fn } = fakeFetch(new Response("<html>Bad gateway</html>", { status: 502 }));
|
||||
const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("http_502");
|
||||
expect(err.status).toBe(502);
|
||||
});
|
||||
|
||||
test("a network failure becomes an ApiError with code network", async () => {
|
||||
const { fn } = fakeFetch(() => {
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("network");
|
||||
expect(err.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatDateRange, formatPartialDate, formatRating, parsePartialDate, rollupLabel } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
expect(formatRating(90, "5half")).toBe("★★★★½");
|
||||
expect(formatRating(100, "5half")).toBe("★★★★★");
|
||||
expect(formatRating(80, "5half")).toBe("★★★★☆");
|
||||
expect(formatRating(0, "5half")).toBe("☆☆☆☆☆");
|
||||
});
|
||||
test("null is empty on every scale", () => {
|
||||
expect(formatRating(null, "5half")).toBe("");
|
||||
expect(formatRating(null, "10")).toBe("");
|
||||
expect(formatRating(undefined, "100")).toBe("");
|
||||
});
|
||||
test("whole stars, 10 and 100 point scales", () => {
|
||||
expect(formatRating(90, "5")).toBe("★★★★★");
|
||||
expect(formatRating(70, "5")).toBe("★★★★☆");
|
||||
expect(formatRating(90, "10")).toBe("9/10");
|
||||
expect(formatRating(85, "10")).toBe("8.5/10");
|
||||
expect(formatRating(73, "100")).toBe("73/100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPartialDate", () => {
|
||||
test("renders by precision", () => {
|
||||
expect(formatPartialDate({ value: "2025-03", precision: "month" })).toBe("Mar 2025");
|
||||
expect(formatPartialDate({ value: "2019", precision: "year" })).toBe("2019");
|
||||
expect(formatPartialDate({ value: "2026-09-14", precision: "day" })).toBe("14 Sep 2026");
|
||||
expect(formatPartialDate({ value: "2026-09-04T23:30:00-05:00", precision: "day" })).toBe("4 Sep 2026");
|
||||
expect(formatPartialDate({ value: "", precision: "unknown" })).toBe("");
|
||||
expect(formatPartialDate(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateRange", () => {
|
||||
test("flags dates that are not exact", () => {
|
||||
expect(formatDateRange(null, { value: "2025-03", precision: "month" })).toBe("Mar 2025 (no exact date)");
|
||||
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, { value: "2026-09-14", precision: "day" })).toBe(
|
||||
"2 Sep – 14 Sep 2026",
|
||||
);
|
||||
expect(formatDateRange({ value: "2025-12-30", precision: "day" }, { value: "2026-01-02", precision: "day" })).toBe(
|
||||
"30 Dec 2025 – 2 Jan 2026",
|
||||
);
|
||||
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, null)).toBe("Started 2 Sep 2026");
|
||||
expect(formatDateRange(null, null)).toBe("No dates");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePartialDate", () => {
|
||||
test("accepts year, month and day, rejects the rest", () => {
|
||||
expect(parsePartialDate("2019")).toEqual({ value: "2019", precision: "year" });
|
||||
expect(parsePartialDate(" 2025-03 ")).toEqual({ value: "2025-03", precision: "month" });
|
||||
expect(parsePartialDate("2024-02-29")).toEqual({ value: "2024-02-29", precision: "day" });
|
||||
expect(parsePartialDate("2023-02-29")).toBeNull();
|
||||
expect(parsePartialDate("03/04/2019")).toBeNull();
|
||||
expect(parsePartialDate("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollupLabel", () => {
|
||||
test("children roll up as 'season 3 of 5'", () => {
|
||||
expect(rollupLabel("show", { total: 5, completed: 3 })).toBe("season 3 of 5");
|
||||
expect(rollupLabel("show", { total: 5, completed: 0 })).toBe("5 seasons");
|
||||
expect(rollupLabel("podcast", { total: 200, completed: 200 })).toBe("all 200 episodes");
|
||||
expect(rollupLabel("book", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user