diff --git a/src/App.test.tsx b/src/App.test.tsx
index de5f00a..4aa1baf 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -111,6 +111,21 @@ describe("App", () => {
expect(screen.getByRole("option", { name: "The Left Hand of Darkness" })).toBeInTheDocument();
});
+ it("sorts by author and rating without making another API request", async () => {
+ const user = userEvent.setup();
+ withProviders();
+ await screen.findByRole("heading", { name: "A Wizard of Earthsea" });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Sort by" }), "author-asc");
+ expect(await screen.findByRole("heading", { name: "The Hobbit" })).toBeInTheDocument();
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Sort by" }), "rating-desc");
+ expect(
+ await screen.findByRole("heading", { name: "The Left Hand of Darkness" }),
+ ).toBeInTheDocument();
+ expect(getSearchRequestCount()).toBe(1);
+ });
+
it("fetches once more when the subject changes and resets to the first book", async () => {
const user = userEvent.setup();
withProviders();
@@ -140,15 +155,15 @@ describe("App", () => {
await waitFor(() => expect(getSearchRequestCount()).toBe(1));
});
- it("restores the selected sort and preserves the subject preference", async () => {
+ it("restores the selected rating sort and preserves the subject preference", async () => {
sessionStorage.setItem(
"t19.preferences",
- JSON.stringify({ subject: "mystery", sort: "newest" }),
+ JSON.stringify({ subject: "mystery", sort: "rating-desc" }),
);
withProviders();
expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("mystery");
- expect(screen.getByRole("combobox", { name: "Sort by" })).toHaveValue("newest");
+ expect(screen.getByRole("combobox", { name: "Sort by" })).toHaveValue("rating-desc");
expect(
await screen.findByRole("heading", { name: "The Left Hand of Darkness" }),
).toBeInTheDocument();
diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap
index 99d18d7..9ac41dc 100644
--- a/src/__snapshots__/App.test.tsx.snap
+++ b/src/__snapshots__/App.test.tsx.snap
@@ -79,6 +79,16 @@ exports[`App > matches the snapshot 1`] = `
>
Title Z-A
+
+
+
+ Rating:
+ 4.8
+ / 5
+ (20 ratings)
+
{
title: "A Book",
authors: ["Jane Doe"],
firstPublishYear: 2001,
+ averageRating: 4.5,
+ ratingsCount: 23,
coverId: 123,
subjects: ["fantasy"],
});
@@ -45,6 +49,8 @@ describe("searchBooksBySubject", () => {
title: "Bare Bones",
authors: [],
firstPublishYear: null,
+ averageRating: null,
+ ratingsCount: null,
coverId: null,
subjects: [],
});
@@ -58,6 +64,8 @@ describe("searchBooksBySubject", () => {
expect(url.searchParams.get("subject")).toBe("history");
expect(url.searchParams.get("limit")).toBe(String(BOOKS_PER_SUBJECT));
expect(url.searchParams.get("fields")).toContain("title");
+ expect(url.searchParams.get("fields")).toContain("ratings_average");
+ expect(url.searchParams.get("fields")).toContain("ratings_count");
});
it("throws on a failed response", async () => {
diff --git a/src/api/openLibrary.ts b/src/api/openLibrary.ts
index eafb841..707ba1c 100644
--- a/src/api/openLibrary.ts
+++ b/src/api/openLibrary.ts
@@ -5,7 +5,8 @@ const COVER_URL = "https://covers.openlibrary.org/b/id";
export const BOOKS_PER_SUBJECT = 10;
-const BOOK_FIELDS = "key,title,author_name,first_publish_year,cover_i,subject";
+const BOOK_FIELDS =
+ "key,title,author_name,first_publish_year,ratings_average,ratings_count,cover_i,subject";
export async function searchBooksBySubject(
subject: string,
@@ -30,7 +31,13 @@ function toBook(doc: OpenLibrarySearchDoc): Book {
title: doc.title,
authors: doc.author_name ?? [],
firstPublishYear: doc.first_publish_year ?? null,
+ averageRating: numberOrNull(doc.ratings_average),
+ ratingsCount: numberOrNull(doc.ratings_count),
coverId: doc.cover_i ?? null,
subjects: doc.subject ?? [],
};
}
+
+function numberOrNull(value: number | undefined): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
diff --git a/src/components/BookCard.css b/src/components/BookCard.css
index 3ab6294..93f4b43 100644
--- a/src/components/BookCard.css
+++ b/src/components/BookCard.css
@@ -48,6 +48,11 @@
font-weight: 600;
}
+.book-card-rating {
+ color: #243b53;
+ font-weight: 600;
+}
+
.book-card-subjects {
display: flex;
flex-wrap: wrap;
diff --git a/src/components/BookCard.test.tsx b/src/components/BookCard.test.tsx
index e7d853f..c2ea984 100644
--- a/src/components/BookCard.test.tsx
+++ b/src/components/BookCard.test.tsx
@@ -8,6 +8,8 @@ const fullBook: Book = {
title: "A Wizard of Earthsea",
authors: ["Ursula K. Le Guin", "Another Author"],
firstPublishYear: 1968,
+ averageRating: 4.2,
+ ratingsCount: 100,
coverId: 123,
subjects: ["Fantasy", "Magic"],
};
@@ -17,6 +19,8 @@ const minimalBook: Book = {
title: "Untitled Book",
authors: [],
firstPublishYear: null,
+ averageRating: null,
+ ratingsCount: null,
coverId: null,
subjects: [],
};
@@ -27,6 +31,7 @@ describe("BookCard", () => {
expect(screen.getByRole("heading", { name: fullBook.title })).toBeInTheDocument();
expect(screen.getByText("Ursula K. Le Guin, Another Author")).toBeInTheDocument();
+ expect(screen.getByText("Rating: 4.2 / 5 (100 ratings)")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Cover of A Wizard of Earthsea" })).toBeInTheDocument();
expect(screen.getByRole("list", { name: "Subjects, showing 2 of 2" })).toHaveTextContent(
"Fantasy",
@@ -39,6 +44,7 @@ describe("BookCard", () => {
expect(screen.getByRole("heading", { name: minimalBook.title })).toBeInTheDocument();
expect(screen.getByText("Unknown author")).toBeInTheDocument();
+ expect(screen.queryByText(/^Rating:/)).not.toBeInTheDocument();
expect(screen.getByText("No cover available")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(container).toMatchSnapshot();
diff --git a/src/components/BookCard.tsx b/src/components/BookCard.tsx
index 7518ce4..cb55999 100644
--- a/src/components/BookCard.tsx
+++ b/src/components/BookCard.tsx
@@ -30,6 +30,12 @@ export function BookCard({ book, isFavorite = false, onFavoriteToggle }: BookCar
{book.title}
{authors}
{book.firstPublishYear !== null && First published: {book.firstPublishYear}
}
+ {book.averageRating !== null && (
+
+ Rating: {book.averageRating.toFixed(1)} / 5
+ {book.ratingsCount !== null && ` (${book.ratingsCount} ratings)`}
+
+ )}
{book.subjects.length > 0 && (