From 3fba6476793c5a314e11ab55c3a31ece355e09e4 Mon Sep 17 00:00:00 2001
From: Martin Alexander Kaminski
Date: Thu, 5 Nov 2026 21:07:03 +0100
Subject: [PATCH 1/5] feat: add fuzzy matching on book title to backend
---
backend/src/db/schema/books.ts | 3 +++
.../books/resolvers/Query/searchBooks.ts | 16 ++++++++++++--
backend/src/schema/books/schema.graphql | 2 ++
backend/src/schema/schema.generated.graphqls | 22 ++++++++++++++++++-
backend/src/schema/typeDefs.generated.ts | 2 +-
backend/src/schema/types.generated.ts | 3 ++-
6 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts
index b667c0b..881af93 100644
--- a/backend/src/db/schema/books.ts
+++ b/backend/src/db/schema/books.ts
@@ -36,6 +36,9 @@ const books = pgTable(
t.numRatings.desc().nullsFirst(),
t.id,
),
+ // Index for fuzzy title search (using the pg_trgm extension).
+ // Also speeds up the ILIKE '%...%' title search
+ index("books_title_trgm_idx").using("gin", t.title.op("gin_trgm_ops")),
],
);
diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts
index 5cb360a..f45396c 100644
--- a/backend/src/schema/books/resolvers/Query/searchBooks.ts
+++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts
@@ -22,7 +22,13 @@ export const searchBooks: NonNullable = async (
) => {
const conditions: SQL[] = [];
- if (args.title) conditions.push(ilike(books.title, `%${args.title}%`));
+ if (args.title && args.fuzzy) {
+ // <% is the word similarity operator using the pg_trgm extension
+ conditions.push(sql`${args.title} <% ${books.title}`);
+ } else if (args.title) {
+ conditions.push(ilike(books.title, `%${args.title}%`));
+ }
+
if (args.series) conditions.push(ilike(books.series, `%${args.series}%`));
if (args.author) conditions.push(ilike(books.author, `%${args.author}%`));
if (args.language) conditions.push(eq(books.language, args.language));
@@ -70,7 +76,13 @@ export const searchBooks: NonNullable = async (
desc(books.numRatings),
asc(books.id),
]
- : [asc(books.id)];
+ : args.fuzzy && args.title
+ ? [
+ // Safe from SQL injection. Values are passed as parameters to the query by Drizzle.
+ sql`word_similarity(${args.title}, ${books.title}) desc`,
+ asc(books.id),
+ ]
+ : [asc(books.id)];
const [foundBooks, totalCount] = await Promise.all([
db
diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql
index 454d2b3..fc36ebe 100644
--- a/backend/src/schema/books/schema.graphql
+++ b/backend/src/schema/books/schema.graphql
@@ -35,6 +35,8 @@ extend type Query {
searchBooks(
title: String
+ "Match titles that are similar to the title, for example with typos, ordered by similarity"
+ fuzzy: Boolean = false
series: String
author: String
language: String
diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls
index 492345f..30f1185 100644
--- a/backend/src/schema/schema.generated.graphqls
+++ b/backend/src/schema/schema.generated.graphqls
@@ -48,7 +48,27 @@ type Query {
favoriteGenres(limit: Int = 3): [String!]!
genres: [String!]!
languages: [String!]!
- searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, maxPages: Int, minPages: Int, minRatings: Int, offset: Int = 0, publisher: String, series: String, sortBy: BookSortField = ID, sortDirection: SortDirection = ASC, title: String): BookSearchResult!
+ searchBooks(
+ author: String
+ bookFormat: String
+
+ """
+ Match titles that are similar to the title, for example with typos, ordered by similarity
+ """
+ fuzzy: Boolean = false
+ genres: [String!]
+ language: String
+ limit: Int = 12
+ maxPages: Int
+ minPages: Int
+ minRatings: Int
+ offset: Int = 0
+ publisher: String
+ series: String
+ sortBy: BookSortField = ID
+ sortDirection: SortDirection = ASC
+ title: String
+ ): BookSearchResult!
}
enum SortDirection {
diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts
index 98c2373..3f3eeb3 100644
--- a/backend/src/schema/typeDefs.generated.ts
+++ b/backend/src/schema/typeDefs.generated.ts
@@ -1,2 +1,2 @@
import type { DocumentNode } from 'graphql';
- export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode
\ No newline at end of file
+ export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode
\ No newline at end of file
diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts
index f7e2400..29d54c4 100644
--- a/backend/src/schema/types.generated.ts
+++ b/backend/src/schema/types.generated.ts
@@ -96,6 +96,7 @@ export type QueryfavoriteGenresArgs = {
export type QuerysearchBooksArgs = {
author?: InputMaybe;
bookFormat?: InputMaybe;
+ fuzzy?: InputMaybe;
genres?: InputMaybe>;
language?: InputMaybe;
limit?: InputMaybe;
@@ -250,7 +251,7 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>;
genres?: Resolver, ParentType, ContextType>;
languages?: Resolver, ParentType, ContextType>;
- searchBooks?: Resolver>;
+ searchBooks?: Resolver>;
};
export type SortDirectionResolvers = EnumResolverSignature<{ ASC?: any, DESC?: any }, ResolversTypes['SortDirection']>;
From 2cda967536dd47b2c1d7917dfa0d950893009e86 Mon Sep 17 00:00:00 2001
From: Martin Alexander Kaminski
Date: Thu, 5 Nov 2026 21:07:13 +0100
Subject: [PATCH 2/5] chore: add DB migration for fuzzy matching
---
.../migration.sql | 2 +
.../snapshot.json | 1269 +++++++++++++++++
2 files changed, 1271 insertions(+)
create mode 100644 backend/drizzle/20261105200512_fuzzy_matching/migration.sql
create mode 100644 backend/drizzle/20261105200512_fuzzy_matching/snapshot.json
diff --git a/backend/drizzle/20261105200512_fuzzy_matching/migration.sql b/backend/drizzle/20261105200512_fuzzy_matching/migration.sql
new file mode 100644
index 0000000..aaf7654
--- /dev/null
+++ b/backend/drizzle/20261105200512_fuzzy_matching/migration.sql
@@ -0,0 +1,2 @@
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+CREATE INDEX "books_title_trgm_idx" ON "books" USING gin ("title" gin_trgm_ops);
diff --git a/backend/drizzle/20261105200512_fuzzy_matching/snapshot.json b/backend/drizzle/20261105200512_fuzzy_matching/snapshot.json
new file mode 100644
index 0000000..b5b1e1f
--- /dev/null
+++ b/backend/drizzle/20261105200512_fuzzy_matching/snapshot.json
@@ -0,0 +1,1269 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "edf6a9ab-7ae7-4b5b-a04a-1f5e3204758b",
+ "prevIds": [
+ "a2eb0ac0-f104-4c7c-ad88-bf21be3f5d29"
+ ],
+ "ddl": [
+ {
+ "isRlsEnabled": false,
+ "name": "account",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "session",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "user",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "verification",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "book_genres",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "book_to_genres",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "books",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "favorite_books",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "provider_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "access_token_expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "refresh_token_expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "scope",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "password",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ip_address",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_agent",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "email",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "email_verified",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "image",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "bio",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "identifier",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "value",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": {
+ "type": "always",
+ "name": "book_genres_id_seq",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": 1,
+ "cycle": false
+ },
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "book_genres"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "genre",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "book_genres"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "book_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "genre_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": {
+ "type": "always",
+ "name": "books_id_seq",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": 1,
+ "cycle": false
+ },
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "varchar(13)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "isbn",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "title",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "series",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "author",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "description",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "language",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "book_format",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "edition",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "pages",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "publisher",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "publish_date",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "cover_img_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "real",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "rating",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "num_ratings",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "book_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "account_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "session_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "verification_identifier_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "verification"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "genre_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "book_to_genres_genre_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "language",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "books_language_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "book_format",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "books_book_format_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "rating",
+ "isExpression": false,
+ "asc": false,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "num_ratings",
+ "isExpression": false,
+ "asc": false,
+ "nullsFirst": true,
+ "opclass": null
+ },
+ {
+ "value": "id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "books_rating_num_ratings_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "title",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": {
+ "name": "gin_trgm_ops",
+ "default": false
+ }
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "gin",
+ "concurrently": false,
+ "name": "books_title_trgm_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "favorite_books_user_id_created_at_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "favorite_books_book_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "account_user_id_user_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "account"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "session_user_id_user_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "session"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "book_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "books",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "book_to_genres_book_id_books_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "genre_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "book_genres",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "book_to_genres_genre_id_book_genres_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "favorite_books_user_id_user_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "book_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "books",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "favorite_books_book_id_books_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "columns": [
+ "book_id",
+ "genre_id"
+ ],
+ "nameExplicit": false,
+ "name": "book_to_genres_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "book_to_genres"
+ },
+ {
+ "columns": [
+ "user_id",
+ "book_id"
+ ],
+ "nameExplicit": false,
+ "name": "favorite_books_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "favorite_books"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "account_pkey",
+ "schema": "public",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "session_pkey",
+ "schema": "public",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "user_pkey",
+ "schema": "public",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "verification_pkey",
+ "schema": "public",
+ "table": "verification",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "book_genres_pkey",
+ "schema": "public",
+ "table": "book_genres",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "books_pkey",
+ "schema": "public",
+ "table": "books",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false,
+ "name": "session_token_key",
+ "schema": "public",
+ "table": "session",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false,
+ "name": "user_email_key",
+ "schema": "public",
+ "table": "user",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "genre"
+ ],
+ "nullsNotDistinct": false,
+ "name": "book_genres_genre_key",
+ "schema": "public",
+ "table": "book_genres",
+ "entityType": "uniques"
+ }
+ ],
+ "renames": []
+}
\ No newline at end of file
From da465d22cb7b93bc229387e343a3c9982ebe5317 Mon Sep 17 00:00:00 2001
From: Martin Alexander Kaminski
Date: Thu, 5 Nov 2026 21:21:55 +0100
Subject: [PATCH 3/5] feat: show similar books using fuzzy search when no
results
---
frontend/app/api/types/graphql.generated.ts | 1 +
frontend/app/hooks/useSearchBooks.ts | 5 +++
frontend/app/hooks/useSuggestedBooks.ts | 26 ++++++++++-
frontend/app/routes/books.test.tsx | 48 +++++++++++++++++++++
frontend/app/routes/books.tsx | 12 +++---
5 files changed, 85 insertions(+), 7 deletions(-)
diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts
index acd6fdc..af2915f 100644
--- a/frontend/app/api/types/graphql.generated.ts
+++ b/frontend/app/api/types/graphql.generated.ts
@@ -51,6 +51,7 @@ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean };
export type SearchBooksQueryVariables = Exact<{
title?: string | null | undefined;
+ fuzzy?: boolean | null | undefined;
genres?: Array | string | null | undefined;
language?: string | null | undefined;
bookFormat?: string | null | undefined;
diff --git a/frontend/app/hooks/useSearchBooks.ts b/frontend/app/hooks/useSearchBooks.ts
index f04e9a5..5802873 100644
--- a/frontend/app/hooks/useSearchBooks.ts
+++ b/frontend/app/hooks/useSearchBooks.ts
@@ -14,6 +14,7 @@ const SEARCH_BOOKS: TypedDocumentNode<
> = gql`
query SearchBooks(
$title: String
+ $fuzzy: Boolean
$genres: [String!]
$language: String
$bookFormat: String
@@ -27,6 +28,7 @@ const SEARCH_BOOKS: TypedDocumentNode<
) {
searchBooks(
title: $title
+ fuzzy: $fuzzy
genres: $genres
language: $language
bookFormat: $bookFormat
@@ -55,6 +57,7 @@ const SEARCH_BOOKS: TypedDocumentNode<
function useSearchBooks({
filters,
title,
+ fuzzy,
limit,
offset,
sort,
@@ -62,6 +65,7 @@ function useSearchBooks({
}: {
filters: BookFilters;
title: string;
+ fuzzy?: boolean;
limit: number;
offset: number;
sort?: BookSortKey;
@@ -73,6 +77,7 @@ function useSearchBooks({
? {
variables: {
title,
+ fuzzy,
genres: filters.genres,
language: filters.language,
bookFormat: filters.bookFormat,
diff --git a/frontend/app/hooks/useSuggestedBooks.ts b/frontend/app/hooks/useSuggestedBooks.ts
index 4207d51..aa4178d 100644
--- a/frontend/app/hooks/useSuggestedBooks.ts
+++ b/frontend/app/hooks/useSuggestedBooks.ts
@@ -4,6 +4,14 @@ import { BOOK_PAGE_LIMIT } from "~/lib/constants";
const MIN_SUGGESTION_RATINGS = 10_000;
+/**
+ * Suggested books when a search has no results.
+ *
+ * The suggested books are books with a similar title to the search query.
+ * If none are found, we use top-rated books instead.
+ * For top-rated books, we remove the filters if there is no search query,
+ * since that means the filters are the reason there are no results.
+ */
function useSuggestedBooks({
query,
filters,
@@ -13,9 +21,19 @@ function useSuggestedBooks({
filters: BookFilters;
enabled: boolean;
}) {
+ const similar = useSearchBooks({
+ filters,
+ title: query,
+ fuzzy: true,
+ limit: BOOK_PAGE_LIMIT,
+ offset: 0,
+ enabled: enabled && query !== "",
+ });
+
+ const hasSimilar = similar.books && similar.books.length > 0;
const keptFilters = query ? filters : {};
- return useSearchBooks({
+ const topRated = useSearchBooks({
filters: {
...keptFilters,
minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_SUGGESTION_RATINGS),
@@ -24,8 +42,12 @@ function useSuggestedBooks({
limit: BOOK_PAGE_LIMIT,
offset: 0,
sort: "rating_desc",
- enabled,
+ enabled: enabled && !similar.loading && !hasSimilar,
});
+
+ return hasSimilar
+ ? { books: similar.books, similarTitles: true }
+ : { books: topRated.books, similarTitles: false };
}
export { useSuggestedBooks };
diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx
index 52b17c0..73cfc0f 100644
--- a/frontend/app/routes/books.test.tsx
+++ b/frontend/app/routes/books.test.tsx
@@ -81,6 +81,10 @@ describe("BooksPage", () => {
initialEntries: ["/books?q=zzzz"],
mocks: [
searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []),
+ searchBooksMock(
+ { title: "zzzz", fuzzy: true, limit: 12, offset: 0 },
+ [],
+ ),
searchBooksMock(
{
title: "",
@@ -101,6 +105,26 @@ describe("BooksPage", () => {
expect(screen.getByText("0 results")).toBeInTheDocument();
});
+ it("suggests books with similar titles when the search has a typo", async () => {
+ renderRoutes(routes, {
+ initialEntries: ["/books?q=blade+itslef"],
+ mocks: [
+ searchBooksMock({ title: "blade itslef", limit: 12, offset: 0 }, []),
+ searchBooksMock({
+ title: "blade itslef",
+ fuzzy: true,
+ limit: 12,
+ offset: 0,
+ }),
+ ],
+ });
+
+ expect(
+ await screen.findByRole("heading", { name: "Books with similar titles" }),
+ ).toBeInTheDocument();
+ expect(await findBookLinks()).toHaveLength(3);
+ });
+
it("suggests top-rated books with the same filters when the search has no results", async () => {
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz&genres=Fantasy"],
@@ -109,6 +133,16 @@ describe("BooksPage", () => {
{ title: "zzzz", genres: ["Fantasy"], limit: 12, offset: 0 },
[],
),
+ searchBooksMock(
+ {
+ title: "zzzz",
+ fuzzy: true,
+ genres: ["Fantasy"],
+ limit: 12,
+ offset: 0,
+ },
+ [],
+ ),
searchBooksMock({
title: "",
genres: ["Fantasy"],
@@ -171,6 +205,16 @@ describe("BooksPage", () => {
{ title: "zzzz", minRatings: 50000, limit: 12, offset: 0 },
[],
),
+ searchBooksMock(
+ {
+ title: "zzzz",
+ fuzzy: true,
+ minRatings: 50000,
+ limit: 12,
+ offset: 0,
+ },
+ [],
+ ),
searchBooksMock({
title: "",
minRatings: 50000,
@@ -192,6 +236,10 @@ describe("BooksPage", () => {
// The suggestions are reused from Apollo's cache on the details page.
mocks: [
searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []),
+ searchBooksMock(
+ { title: "zzzz", fuzzy: true, limit: 12, offset: 0 },
+ [],
+ ),
searchBooksMock({
title: "",
minRatings: 10000,
diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx
index 942c83c..2911f01 100644
--- a/frontend/app/routes/books.tsx
+++ b/frontend/app/routes/books.tsx
@@ -49,7 +49,7 @@ export default function BooksPage() {
(value) => value !== undefined,
);
- const { books: suggestions } = useSuggestedBooks({
+ const { books: suggestions, similarTitles } = useSuggestedBooks({
query,
filters,
enabled: !loading && !error && totalCount === 0,
@@ -101,15 +101,17 @@ export default function BooksPage() {
No books found for this search
- In the meantime, here are some popular books you might like
+ In the meantime, here are some books you might like
- {query && hasFilters
- ? "Top-rated books matching your filters"
- : "Top-rated books"}
+ {similarTitles
+ ? "Books with similar titles"
+ : query && hasFilters
+ ? "Top-rated books matching your filters"
+ : "Top-rated books"}
Date: Sat, 7 Nov 2026 17:14:16 +0100
Subject: [PATCH 4/5] feat: remove unnecessary heading when showing suggestions
---
frontend/app/routes/books.test.tsx | 22 ++++++++++++++++------
frontend/app/routes/books.tsx | 26 +++++++++++++-------------
2 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx
index 73cfc0f..7230267 100644
--- a/frontend/app/routes/books.test.tsx
+++ b/frontend/app/routes/books.test.tsx
@@ -120,7 +120,12 @@ describe("BooksPage", () => {
});
expect(
- await screen.findByRole("heading", { name: "Books with similar titles" }),
+ await screen.findByRole("heading", { name: "Suggested books" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "No books found for this search. However, here are some books with similar titles:",
+ ),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
});
@@ -156,12 +161,12 @@ describe("BooksPage", () => {
});
expect(
- await screen.findByRole("heading", {
- name: "Top-rated books matching your filters",
- }),
+ await screen.findByRole("heading", { name: "Suggested books" }),
).toBeInTheDocument();
expect(
- screen.getByText("No books found for this search"),
+ screen.getByText(
+ "No books found for this search. However, here are some top-rated books matching your filters:",
+ ),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
});
@@ -192,7 +197,12 @@ describe("BooksPage", () => {
});
expect(
- await screen.findByRole("heading", { name: "Top-rated books" }),
+ await screen.findByRole("heading", { name: "Suggested books" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "No books found for this search. However, here are some top-rated books you might like:",
+ ),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
});
diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx
index 2911f01..d3ed964 100644
--- a/frontend/app/routes/books.tsx
+++ b/frontend/app/routes/books.tsx
@@ -97,22 +97,22 @@ export default function BooksPage() {
aria-labelledby="suggestions-heading"
className="flex flex-col gap-2 pt-10"
>
-
- No books found for this search
-
-
- In the meantime, here are some books you might like
-
-
+ Suggested books
+
+
+ No books found for this search. However, here are some{" "}
{similarTitles
- ? "Books with similar titles"
+ ? "books with similar titles"
: query && hasFilters
- ? "Top-rated books matching your filters"
- : "Top-rated books"}
-
+ ? "top-rated books matching your filters"
+ : "top-rated books you might like"}
+ :
+
Date: Sat, 7 Nov 2026 17:35:52 +0100
Subject: [PATCH 5/5] feat: show clearer info about fuzzy matching and
suggestions
---
.../books/SortSelect/SortSelect.tsx | 10 +++-
frontend/app/hooks/useSuggestedBooks.ts | 27 +++++++++--
frontend/app/routes/book/selectedBook.tsx | 2 +
frontend/app/routes/books.test.tsx | 41 +++++++++++++++++
frontend/app/routes/books.tsx | 46 +++++++++++--------
5 files changed, 100 insertions(+), 26 deletions(-)
diff --git a/frontend/app/components/books/SortSelect/SortSelect.tsx b/frontend/app/components/books/SortSelect/SortSelect.tsx
index 45bb6d6..093ef33 100644
--- a/frontend/app/components/books/SortSelect/SortSelect.tsx
+++ b/frontend/app/components/books/SortSelect/SortSelect.tsx
@@ -1,6 +1,7 @@
import { useSearchParams } from "react-router";
import { BOOK_SORTS, parseBookSort } from "~/api/bookSort";
import { useBookSearchParams } from "~/hooks/useBookSearchParams";
+import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
@@ -11,7 +12,7 @@ import {
const DEFAULT_SORT = "default";
-function SortSelect() {
+function SortSelect({ className }: { className?: string }) {
const [, setSearchParams] = useSearchParams();
const { sort } = useBookSearchParams();
@@ -24,7 +25,12 @@ function SortSelect() {
];
return (
-
+
Sort by
0;
+ const hasSimilar = similar.totalCount > 0;
const keptFilters = query ? filters : {};
const topRated = useSearchBooks({
@@ -40,14 +49,22 @@ function useSuggestedBooks({
},
title: "",
limit: BOOK_PAGE_LIMIT,
- offset: 0,
+ offset,
sort: "rating_desc",
enabled: enabled && !similar.loading && !hasSimilar,
});
return hasSimilar
- ? { books: similar.books, similarTitles: true }
- : { books: topRated.books, similarTitles: false };
+ ? {
+ books: similar.books,
+ totalCount: similar.totalCount,
+ similarTitles: true,
+ }
+ : {
+ books: topRated.books,
+ totalCount: topRated.totalCount,
+ similarTitles: false,
+ };
}
export { useSuggestedBooks };
diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx
index 6b5ab16..769da55 100644
--- a/frontend/app/routes/book/selectedBook.tsx
+++ b/frontend/app/routes/book/selectedBook.tsx
@@ -49,6 +49,8 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {
const { books: suggestedBooks } = useSuggestedBooks({
query,
filters,
+ page,
+ sort,
enabled: origin === "suggestions",
});
diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx
index 7230267..3a7150c 100644
--- a/frontend/app/routes/books.test.tsx
+++ b/frontend/app/routes/books.test.tsx
@@ -130,6 +130,44 @@ describe("BooksPage", () => {
expect(await findBookLinks()).toHaveLength(3);
});
+ it("pages and sorts the similar titles", async () => {
+ renderRoutes(routes, {
+ initialEntries: ["/books?q=blade+itslef&sort=rating_desc&page=2"],
+ mocks: [
+ searchBooksMock(
+ {
+ title: "blade itslef",
+ sortBy: "RATING",
+ sortDirection: "DESC",
+ limit: 12,
+ offset: 12,
+ },
+ [],
+ ),
+ searchBooksMock(
+ {
+ title: "blade itslef",
+ fuzzy: true,
+ sortBy: "RATING",
+ sortDirection: "DESC",
+ limit: 12,
+ offset: 12,
+ },
+ undefined,
+ 15,
+ ),
+ ],
+ });
+
+ expect(await findBookLinks()).toHaveLength(3);
+ expect(
+ screen.getByRole("combobox", { name: "Sort by" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("navigation", { name: "pagination" }),
+ ).toBeInTheDocument();
+ });
+
it("suggests top-rated books with the same filters when the search has no results", async () => {
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz&genres=Fantasy"],
@@ -169,6 +207,9 @@ describe("BooksPage", () => {
),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
+ expect(
+ screen.queryByRole("combobox", { name: "Sort by" }),
+ ).not.toBeInTheDocument();
});
it("suggests top-rated books without filters when the filters have no results", async () => {
diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx
index d3ed964..8561c69 100644
--- a/frontend/app/routes/books.tsx
+++ b/frontend/app/routes/books.tsx
@@ -49,9 +49,15 @@ export default function BooksPage() {
(value) => value !== undefined,
);
- const { books: suggestions, similarTitles } = useSuggestedBooks({
+ const {
+ books: suggestions,
+ totalCount: suggestionCount,
+ similarTitles,
+ } = useSuggestedBooks({
query,
filters,
+ page,
+ sort,
enabled: !loading && !error && totalCount === 0,
});
@@ -95,28 +101,31 @@ export default function BooksPage() {
- {/* Hidden heading for screen readers */}
-
- Suggested books
-
-
- No books found for this search. However, here are some{" "}
- {similarTitles
- ? "books with similar titles"
- : query && hasFilters
- ? "top-rated books matching your filters"
- : "top-rated books you might like"}
- :
-
+
+ {/* Hidden heading for screen readers */}
+
+ Suggested books
+
+
+ No books found for this search. However, we found some{" "}
+ {similarTitles
+ ? "books with similar titles"
+ : query && hasFilters
+ ? "top-rated books matching your filters"
+ : "top-rated books you might like"}
+ .
+
+ {similarTitles && }
+
+
);
@@ -138,7 +147,6 @@ export default function BooksPage() {
return (
-