From 0bb871cd1991402de8e11aecec702214a50b1596 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:25:41 +0100 Subject: [PATCH] feat(web): api client and display formatters Co-Authored-By: Claude Opus 5.5 --- apps/web/src/api.ts | 248 +++++++++++++++++++++++++++++++++++ apps/web/src/format.ts | 196 +++++++++++++++++++++++++++ apps/web/test/api.test.ts | 92 +++++++++++++ apps/web/test/format.test.ts | 68 ++++++++++ 4 files changed, 604 insertions(+) create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/format.ts create mode 100644 apps/web/test/api.test.ts create mode 100644 apps/web/test/format.test.ts diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..03c31a1 --- /dev/null +++ b/apps/web/src/api.ts @@ -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; + fetchImpl?: typeof fetch; +} + +export async function request(method: Method, path: string, opts: RequestOpts = {}): Promise { + 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 = { 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; + visibility: "private" | "friends" | "public"; + overrides: { title?: string; coverUrl?: string; year?: number | null }; + mediaTypeOverride: string | null; + fieldSources: Record; + 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 +>; + +export interface Page { + items: T[]; + nextCursor: string | null; +} + +export interface Engagement { + id: string; + entryId: string; + startedAt: PartialDate | null; + finishedAt: PartialDate | null; + progress: Record; + platform: string | null; + rating: number | null; + notes: string | null; + fieldSources: Record; + 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; + createdAt: string; +} + +export interface HistoryRecord { + id: string; + entity: string; + entityId: string; + before: Record; + after: Record; + 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("GET", "me"), + login: (username: string, password: string) => request("POST", "auth/login", { body: { username, password } }), + register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) => + request("POST", "auth/register", { body }), + logout: () => request("POST", "auth/logout"), + + entries: (query: EntryQuery) => request>("GET", "entries", { query: { ...query } }), + entry: (id: string) => request("GET", `entries/${id}`), + quickAdd: (title: string) => request("POST", "entries/quick", { body: { title } }), + patchEntry: (id: string, patch: EntryPatch) => request("PATCH", `entries/${id}`, { body: patch }), + moveEntry: (id: string, workId: string) => request("POST", `entries/${id}/move`, { body: { workId } }), + history: (id: string) => request("GET", `entries/${id}/history`), + restore: (historyId: string) => request("POST", `history/${historyId}/restore`), + + engagements: (entryId: string) => request("GET", `entries/${entryId}/engagements`), + addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) => + request("POST", `entries/${entryId}/engagements`, { body }), + deleteEngagement: (id: string) => request("DELETE", `engagements/${id}`), + + copies: (workId: string) => request("GET", "copies", { query: { workId } }), + addCopy: (body: Partial>) => request("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("POST", "attachments", { form }); + }, + + sources: () => request("GET", "sources"), + changes: () => request>("GET", "changes", { query: { limit: 200 } }), + customFields: (mediaType: string) => request("GET", "custom-fields", { query: { mediaType, level: "entry" } }), + + work: (id: string) => request("GET", `works/${id}`), + searchWorks: (q: string, mediaType?: string) => request("GET", "works/search", { query: { q, mediaType, limit: 8 } }), + suggestions: (workId: string) => request("GET", `works/${workId}/suggestions`), + acceptSuggestion: (id: string) => request("POST", `suggestions/${id}/accept`), + rejectSuggestion: (id: string) => request("POST", `suggestions/${id}/reject`), +}; diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts new file mode 100644 index 0000000..3f0686e --- /dev/null +++ b/apps/web/src/format.ts @@ -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 = { + 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 = { + 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:", for history rows. */ +export function actorConnectionId(actor: string): string | null { + const m = /^plugin:[^:]+:(.+)$/.exec(actor); + return m ? m[1]! : null; +} diff --git a/apps/web/test/api.test.ts b/apps/web/test/api.test.ts new file mode 100644 index 0000000..777f462 --- /dev/null +++ b/apps/web/test/api.test.ts @@ -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("Bad gateway", { 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); + }); +}); diff --git a/apps/web/test/format.test.ts b/apps/web/test/format.test.ts new file mode 100644 index 0000000..2ce44b6 --- /dev/null +++ b/apps/web/test/format.test.ts @@ -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(); + }); +});