From 61ec4d72c0a3bf2aec9a22ed0a50a80274b588ae Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:36:20 +0100 Subject: [PATCH 01/17] feat: add sorting by book title This also refactors the orderBy logic in searchBooks, since that started to become some delicious spaghetti --- backend/src/db/schema/books.ts | 2 + .../books/resolvers/Query/searchBooks.ts | 63 +++++++++++++------ backend/src/schema/books/schema.graphql | 1 + backend/src/schema/schema.generated.graphqls | 1 + backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 7 ++- frontend/app/api/bookSort.ts | 10 +++ frontend/app/api/types/graphql.generated.ts | 3 +- 8 files changed, 64 insertions(+), 25 deletions(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 881af931..8ee2b394 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -36,6 +36,8 @@ const books = pgTable( t.numRatings.desc().nullsFirst(), t.id, ), + // Matches the title sorting in searchBooks. Postgres can read it backwards for Z-A + index("books_title_id_idx").on(t.title, 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 f45396c6..e2fb7833 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -13,7 +13,38 @@ import { } from "drizzle-orm"; import { db } from "~/db"; import { bookGenres, books, bookToGenres } from "~/db/schema"; -import type { QueryResolvers } from "~/schema/types.generated"; +import type { + BookSortField, + QueryResolvers, + SortDirection, +} from "~/schema/types.generated"; + +const ORDER_BY: Record> = { + ID: { + ASC: [asc(books.id)], + DESC: [desc(books.id)], + }, + // Books without a rating are always shown at the end of the list. + // Sorting from highest to lowest rating is fast because we have a + // database index (books_rating_num_ratings_idx) sorted the same way. + RATING: { + ASC: [ + sql`${books.rating} asc nulls last`, + desc(books.numRatings), + asc(books.id), + ], + DESC: [ + sql`${books.rating} desc nulls last`, + desc(books.numRatings), + asc(books.id), + ], + }, + // Z-A is the exact reverse of A-Z, so both can use the index books_title_id_idx + TITLE: { + ASC: [asc(books.title), asc(books.id)], + DESC: [desc(books.title), desc(books.id)], + }, +}; export const searchBooks: NonNullable = async ( _parent, @@ -64,25 +95,17 @@ export const searchBooks: NonNullable = async ( // can be used both for fetching one page and for counting all matches. const filter = and(...conditions); - const orderBy = - args.sortBy === "RATING" - ? [ - // Books without a rating are always shown at the end of the list. - // Sorting from highest to lowest rating is fast because we have a - // database index (books_rating_num_ratings_idx) sorted the same way. - args.sortDirection === "DESC" - ? sql`${books.rating} desc nulls last` - : sql`${books.rating} asc nulls last`, - desc(books.numRatings), - 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 sortBy = args.sortBy ?? "ID"; + let orderBy = ORDER_BY[sortBy][args.sortDirection ?? "ASC"]; + + // The default order for a fuzzy search is the most similar titles first + if (sortBy === "ID" && args.fuzzy && args.title) { + orderBy = [ + // 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), + ]; + } const [foundBooks, totalCount] = await Promise.all([ db diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index fc36ebe8..6aa00613 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -20,6 +20,7 @@ type Book { enum BookSortField { ID RATING + TITLE } enum SortDirection { diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 30f11856..e6592732 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -25,6 +25,7 @@ type BookSearchResult { enum BookSortField { ID RATING + TITLE } type Mutation { diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 3f3eeb3b..86b9c260 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","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 + 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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}}]},{"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 29d54c4d..3d407c2a 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -43,7 +43,8 @@ export type BookSearchResult = { export type BookSortField = | 'ID' - | 'RATING'; + | 'RATING' + | 'TITLE'; export type Mutation = { __typename?: 'Mutation'; @@ -193,7 +194,7 @@ export type ResolversTypes = { Int: ResolverTypeWrapper; Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; - BookSortField: ResolverTypeWrapper<'ID' | 'RATING'>; + BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -236,7 +237,7 @@ export type BookSearchResultResolvers; }; -export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, RATING?: any }, ResolversTypes['BookSortField']>; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; export type MutationResolvers = { addFavoriteBook?: Resolver>; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts index 6d76be15..b4b041e2 100644 --- a/frontend/app/api/bookSort.ts +++ b/frontend/app/api/bookSort.ts @@ -11,6 +11,16 @@ const BOOK_SORTS = { sortBy: "RATING", sortDirection: "ASC", }, + title_asc: { + label: "Title: A-Z", + sortBy: "TITLE", + sortDirection: "ASC", + }, + title_desc: { + label: "Title: Z-A", + sortBy: "TITLE", + sortDirection: "DESC", + }, } as const; type BookSortKey = keyof typeof BOOK_SORTS; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index af2915fa..43b4cdd2 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -4,7 +4,8 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; export type BookSortField = | 'ID' - | 'RATING'; + | 'RATING' + | 'TITLE'; export type SortDirection = | 'ASC' From f318d99eccf9c53eff424da1d28a55ebf711d734 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:36:33 +0100 Subject: [PATCH 02/17] chore: add db migration for new book title index --- .../migration.sql | 1 + .../snapshot.json | 1297 +++++++++++++++++ 2 files changed, 1298 insertions(+) create mode 100644 backend/drizzle/20261109232213_title_sort_index/migration.sql create mode 100644 backend/drizzle/20261109232213_title_sort_index/snapshot.json diff --git a/backend/drizzle/20261109232213_title_sort_index/migration.sql b/backend/drizzle/20261109232213_title_sort_index/migration.sql new file mode 100644 index 00000000..e197c3eb --- /dev/null +++ b/backend/drizzle/20261109232213_title_sort_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "books_title_id_idx" ON "books" ("title","id"); \ No newline at end of file diff --git a/backend/drizzle/20261109232213_title_sort_index/snapshot.json b/backend/drizzle/20261109232213_title_sort_index/snapshot.json new file mode 100644 index 00000000..ba1a453b --- /dev/null +++ b/backend/drizzle/20261109232213_title_sort_index/snapshot.json @@ -0,0 +1,1297 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "f3c264c0-d9a3-4248-ada4-b62d738156d7", + "prevIds": [ + "edf6a9ab-7ae7-4b5b-a04a-1f5e3204758b" + ], + "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": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_title_id_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 00366516bab1896e398ec4cd5b35c351c5fc97a2 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:36:54 +0100 Subject: [PATCH 03/17] test: adjust wording of text some tests look for --- frontend/app/routes/books.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx index 3a7150c8..0617cbc1 100644 --- a/frontend/app/routes/books.test.tsx +++ b/frontend/app/routes/books.test.tsx @@ -124,7 +124,7 @@ describe("BooksPage", () => { ).toBeInTheDocument(); expect( screen.getByText( - "No books found for this search. However, here are some books with similar titles:", + "No books found for this search. However, we found some books with similar titles.", ), ).toBeInTheDocument(); expect(await findBookLinks()).toHaveLength(3); @@ -203,7 +203,7 @@ describe("BooksPage", () => { ).toBeInTheDocument(); expect( screen.getByText( - "No books found for this search. However, here are some top-rated books matching your filters:", + "No books found for this search. However, we found some top-rated books matching your filters.", ), ).toBeInTheDocument(); expect(await findBookLinks()).toHaveLength(3); @@ -242,7 +242,7 @@ describe("BooksPage", () => { ).toBeInTheDocument(); expect( screen.getByText( - "No books found for this search. However, here are some top-rated books you might like:", + "No books found for this search. However, we found some top-rated books you might like.", ), ).toBeInTheDocument(); expect(await findBookLinks()).toHaveLength(3); From f19f51a17ea6cbe70513ec60f2ec6ce763defec5 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:44:30 +0100 Subject: [PATCH 04/17] feat: add sorting by most ratings --- backend/src/db/schema/books.ts | 2 ++ backend/src/schema/books/resolvers/Query/searchBooks.ts | 6 ++++++ backend/src/schema/books/schema.graphql | 1 + backend/src/schema/schema.generated.graphqls | 1 + backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 5 +++-- frontend/app/api/bookSort.ts | 5 +++++ frontend/app/api/types/graphql.generated.ts | 1 + 8 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 8ee2b394..57aa4640 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -38,6 +38,8 @@ const books = pgTable( ), // Matches the title sorting in searchBooks. Postgres can read it backwards for Z-A index("books_title_id_idx").on(t.title, t.id), + // Matches the number of ratings sorting in searchBooks + index("books_num_ratings_id_idx").on(t.numRatings.desc().nullsLast(), 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 e2fb7833..22e0e50b 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -44,6 +44,12 @@ const ORDER_BY: Record> = { ASC: [asc(books.title), asc(books.id)], DESC: [desc(books.title), desc(books.id)], }, + // Books without ratings (null) count as having no ratings. ASC is the exact reverse + // of DESC, so both can use the index books_num_ratings_id_idx + NUM_RATINGS: { + ASC: [sql`${books.numRatings} asc nulls first`, desc(books.id)], + DESC: [sql`${books.numRatings} desc nulls last`, asc(books.id)], + }, }; export const searchBooks: NonNullable = async ( diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 6aa00613..73996119 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -21,6 +21,7 @@ enum BookSortField { ID RATING TITLE + NUM_RATINGS } enum SortDirection { diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index e6592732..b09761aa 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -24,6 +24,7 @@ type BookSearchResult { enum BookSortField { ID + NUM_RATINGS RATING TITLE } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 86b9c260..1d9db981 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","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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}}]},{"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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}}]},{"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 3d407c2a..2a2d304f 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -43,6 +43,7 @@ export type BookSearchResult = { export type BookSortField = | 'ID' + | 'NUM_RATINGS' | 'RATING' | 'TITLE'; @@ -194,7 +195,7 @@ export type ResolversTypes = { Int: ResolverTypeWrapper; Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; - BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE'>; + BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -237,7 +238,7 @@ export type BookSearchResultResolvers; }; -export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; export type MutationResolvers = { addFavoriteBook?: Resolver>; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts index b4b041e2..c32365bf 100644 --- a/frontend/app/api/bookSort.ts +++ b/frontend/app/api/bookSort.ts @@ -1,6 +1,11 @@ // The sorting options users can choose between. The key is what we store in the // URL (?sort=rating_desc), and the other fields are what we send to the API. const BOOK_SORTS = { + num_ratings_desc: { + label: "Most ratings", + sortBy: "NUM_RATINGS", + sortDirection: "DESC", + }, rating_desc: { label: "Rating: high to low", sortBy: "RATING", diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 43b4cdd2..e3e2662e 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -4,6 +4,7 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; export type BookSortField = | 'ID' + | 'NUM_RATINGS' | 'RATING' | 'TITLE'; From 11ccb60f311e59f1df99bcb09767cd193196c12f Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:44:46 +0100 Subject: [PATCH 05/17] chore: add db migration for book ratings index --- .../migration.sql | 1 + .../snapshot.json | 1325 +++++++++++++++++ 2 files changed, 1326 insertions(+) create mode 100644 backend/drizzle/20261109234045_num_ratings_sort_index/migration.sql create mode 100644 backend/drizzle/20261109234045_num_ratings_sort_index/snapshot.json diff --git a/backend/drizzle/20261109234045_num_ratings_sort_index/migration.sql b/backend/drizzle/20261109234045_num_ratings_sort_index/migration.sql new file mode 100644 index 00000000..bc5f5d2a --- /dev/null +++ b/backend/drizzle/20261109234045_num_ratings_sort_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "books_num_ratings_id_idx" ON "books" ("num_ratings" DESC NULLS LAST,"id"); \ No newline at end of file diff --git a/backend/drizzle/20261109234045_num_ratings_sort_index/snapshot.json b/backend/drizzle/20261109234045_num_ratings_sort_index/snapshot.json new file mode 100644 index 00000000..378de985 --- /dev/null +++ b/backend/drizzle/20261109234045_num_ratings_sort_index/snapshot.json @@ -0,0 +1,1325 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "75acdfd7-21fd-422f-9f86-efc2f0ec191d", + "prevIds": [ + "f3c264c0-d9a3-4248-ada4-b62d738156d7" + ], + "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": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_title_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_num_ratings_id_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 87626c7f4a864b682f8b025c0bb9488ffe7f748a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:47:19 +0100 Subject: [PATCH 06/17] feat: add sorting by page count --- backend/src/db/schema/books.ts | 2 ++ .../src/schema/books/resolvers/Query/searchBooks.ts | 6 ++++++ backend/src/schema/books/schema.graphql | 1 + backend/src/schema/schema.generated.graphqls | 1 + backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 5 +++-- frontend/app/api/bookSort.ts | 10 ++++++++++ frontend/app/api/types/graphql.generated.ts | 1 + 8 files changed, 25 insertions(+), 3 deletions(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 57aa4640..b4579a50 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -40,6 +40,8 @@ const books = pgTable( index("books_title_id_idx").on(t.title, t.id), // Matches the number of ratings sorting in searchBooks index("books_num_ratings_id_idx").on(t.numRatings.desc().nullsLast(), t.id), + // Matches the pages sorting in searchBooks + index("books_pages_id_idx").on(t.pages, 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 22e0e50b..14adfa7f 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -50,6 +50,12 @@ const ORDER_BY: Record> = { ASC: [sql`${books.numRatings} asc nulls first`, desc(books.id)], DESC: [sql`${books.numRatings} desc nulls last`, asc(books.id)], }, + // Long to short is the exact reverse of short to long, so both can use the + // index books_pages_id_idx + PAGES: { + ASC: [asc(books.pages), asc(books.id)], + DESC: [desc(books.pages), desc(books.id)], + }, }; export const searchBooks: NonNullable = async ( diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 73996119..f50d5b80 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -22,6 +22,7 @@ enum BookSortField { RATING TITLE NUM_RATINGS + PAGES } enum SortDirection { diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index b09761aa..a7fe9fce 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -25,6 +25,7 @@ type BookSearchResult { enum BookSortField { ID NUM_RATINGS + PAGES RATING TITLE } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 1d9db981..3d424751 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","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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}}]},{"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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}}]},{"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 2a2d304f..b0f168da 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -44,6 +44,7 @@ export type BookSearchResult = { export type BookSortField = | 'ID' | 'NUM_RATINGS' + | 'PAGES' | 'RATING' | 'TITLE'; @@ -195,7 +196,7 @@ export type ResolversTypes = { Int: ResolverTypeWrapper; Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; - BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS'>; + BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS' | 'PAGES'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -238,7 +239,7 @@ export type BookSearchResultResolvers; }; -export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, PAGES?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; export type MutationResolvers = { addFavoriteBook?: Resolver>; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts index c32365bf..302e25b0 100644 --- a/frontend/app/api/bookSort.ts +++ b/frontend/app/api/bookSort.ts @@ -26,6 +26,16 @@ const BOOK_SORTS = { sortBy: "TITLE", sortDirection: "DESC", }, + pages_asc: { + label: "Pages: short to long", + sortBy: "PAGES", + sortDirection: "ASC", + }, + pages_desc: { + label: "Pages: long to short", + sortBy: "PAGES", + sortDirection: "DESC", + }, } as const; type BookSortKey = keyof typeof BOOK_SORTS; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index e3e2662e..c95062bf 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -5,6 +5,7 @@ export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | export type BookSortField = | 'ID' | 'NUM_RATINGS' + | 'PAGES' | 'RATING' | 'TITLE'; From 292d5ed2e00b0ccc28c4bc28ea262a7ea6a28c9b Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:47:28 +0100 Subject: [PATCH 07/17] chore: add db migration for book pages index --- .../migration.sql | 1 + .../snapshot.json | 1353 +++++++++++++++++ 2 files changed, 1354 insertions(+) create mode 100644 backend/drizzle/20261109234518_pages_sort_index/migration.sql create mode 100644 backend/drizzle/20261109234518_pages_sort_index/snapshot.json diff --git a/backend/drizzle/20261109234518_pages_sort_index/migration.sql b/backend/drizzle/20261109234518_pages_sort_index/migration.sql new file mode 100644 index 00000000..1fc85871 --- /dev/null +++ b/backend/drizzle/20261109234518_pages_sort_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "books_pages_id_idx" ON "books" ("pages","id"); \ No newline at end of file diff --git a/backend/drizzle/20261109234518_pages_sort_index/snapshot.json b/backend/drizzle/20261109234518_pages_sort_index/snapshot.json new file mode 100644 index 00000000..26bd671f --- /dev/null +++ b/backend/drizzle/20261109234518_pages_sort_index/snapshot.json @@ -0,0 +1,1353 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "09cdfdb6-607c-4984-99fa-1232113b1e14", + "prevIds": [ + "75acdfd7-21fd-422f-9f86-efc2f0ec191d" + ], + "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": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_title_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_num_ratings_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pages", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_pages_id_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 b7f76cd3488c6167b0c97cbcf64ce88e7e44212e Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:15:30 +0100 Subject: [PATCH 08/17] feat: add sorting by publish year This adds a publish_year SQL column which we will fill out with values once. The frontend no longer needs to extract the publish year, since the backend does it --- backend/src/db/schema/books.ts | 7 ++++++ backend/src/db/seed/index.ts | 2 ++ backend/src/db/seed/parsePublishYear.ts | 25 +++++++++++++++++++ .../books/resolvers/Query/searchBooks.ts | 7 ++++++ backend/src/schema/books/schema.graphql | 2 ++ backend/src/schema/schema.generated.graphqls | 2 ++ backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 7 ++++-- frontend/app/api/bookSort.ts | 10 ++++++++ frontend/app/api/types/graphql.generated.ts | 7 +++--- frontend/app/api/yearFromPublishDate.ts | 10 -------- frontend/app/components/BookCard/BookCard.tsx | 2 +- frontend/app/hooks/useBook.ts | 6 ++--- frontend/app/hooks/useFavoriteBooks.ts | 6 ++--- frontend/app/hooks/useSearchBooks.ts | 6 ++--- frontend/app/routes/book/selectedBook.tsx | 2 +- frontend/app/test/fixtures/books.ts | 10 ++++---- 17 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 backend/src/db/seed/parsePublishYear.ts delete mode 100644 frontend/app/api/yearFromPublishDate.ts diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index b4579a50..dbe5dd58 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -23,6 +23,8 @@ const books = pgTable( pages: integer("pages").notNull(), publisher: text("publisher"), publishDate: text("publish_date"), + // Parsed from publishDate (once), which is free text, so books can be sorted by it + publishYear: integer("publish_year"), coverImgUrl: text("cover_img_url"), rating: real("rating"), numRatings: integer("num_ratings"), @@ -42,6 +44,11 @@ const books = pgTable( index("books_num_ratings_id_idx").on(t.numRatings.desc().nullsLast(), t.id), // Matches the pages sorting in searchBooks index("books_pages_id_idx").on(t.pages, t.id), + // Matches the newest first sorting in searchBooks + index("books_publish_year_id_idx").on( + t.publishYear.desc().nullsLast(), + 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/db/seed/index.ts b/backend/src/db/seed/index.ts index 704c9073..b6f9f543 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { parse } from "csv-parse"; import { db } from "~/db"; import { parseRating } from "~/db/seed/parseRatings"; +import { parsePublishYear } from "~/db/seed/parsePublishYear"; import { bookGenres, books, @@ -110,6 +111,7 @@ for await (const book of parser as AsyncIterable) { pages: book.pages, publisher: book.publisher?.trim() || null, publishDate: book.publishDate?.trim() || null, + publishYear: parsePublishYear(book.publishDate?.trim() || null), coverImgUrl: book.coverImg?.trim() || null, ...parseRating(book), }); diff --git a/backend/src/db/seed/parsePublishYear.ts b/backend/src/db/seed/parsePublishYear.ts new file mode 100644 index 00000000..ba3cd93b --- /dev/null +++ b/backend/src/db/seed/parsePublishYear.ts @@ -0,0 +1,25 @@ +// The dataset is from 2021, so two-digit years above 21 are from the 1900s +const TWO_DIGIT_YEAR_CUTOFF = 21; + +// publishDate is free text, e.g. "May 21st 2009", "December 2005", or "01/07/08". +// These rules should match the SQL in the publish_year migration. +function parsePublishYear(publishDate: string | null): number | null { + if (!publishDate) { + return null; + } + + const fullYear = /\b(\d{4})\b/.exec(publishDate); + if (fullYear) { + return Number(fullYear[1]); + } + + const shortDate = /^\d{2}\/\d{2}\/(\d{2})$/.exec(publishDate); + if (shortDate) { + const year = Number(shortDate[1]); + return year > TWO_DIGIT_YEAR_CUTOFF ? 1900 + year : 2000 + year; + } + + return null; +} + +export { parsePublishYear }; diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 14adfa7f..4816e0c7 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -56,6 +56,13 @@ const ORDER_BY: Record> = { ASC: [asc(books.pages), asc(books.id)], DESC: [desc(books.pages), desc(books.id)], }, + // Books without a year are always shown at the end of the list. Only newest + // first can use the index books_publish_year_id_idx. Reading it backwards + // places books without a year first, so oldest first can't use it + PUBLISH_YEAR: { + ASC: [sql`${books.publishYear} asc nulls last`, asc(books.id)], + DESC: [sql`${books.publishYear} desc nulls last`, asc(books.id)], + }, }; export const searchBooks: NonNullable = async ( diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index f50d5b80..ae63996c 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -11,6 +11,7 @@ type Book { pages: Int! publisher: String publishDate: String + publishYear: Int coverImgUrl: String genres: [String!]! rating: Float @@ -23,6 +24,7 @@ enum BookSortField { TITLE NUM_RATINGS PAGES + PUBLISH_YEAR } enum SortDirection { diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index a7fe9fce..39c71587 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -11,6 +11,7 @@ type Book { numRatings: Int pages: Int! publishDate: String + publishYear: Int publisher: String rating: Float series: String @@ -26,6 +27,7 @@ enum BookSortField { ID NUM_RATINGS PAGES + PUBLISH_YEAR RATING TITLE } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 3d424751..26e0b556 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","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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}}]},{"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":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"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 b0f168da..0823df95 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -29,6 +29,7 @@ export type Book = { numRatings?: Maybe; pages: Scalars['Int']['output']; publishDate?: Maybe; + publishYear?: Maybe; publisher?: Maybe; rating?: Maybe; series?: Maybe; @@ -45,6 +46,7 @@ export type BookSortField = | 'ID' | 'NUM_RATINGS' | 'PAGES' + | 'PUBLISH_YEAR' | 'RATING' | 'TITLE'; @@ -196,7 +198,7 @@ export type ResolversTypes = { Int: ResolverTypeWrapper; Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; - BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS' | 'PAGES'>; + BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS' | 'PAGES' | 'PUBLISH_YEAR'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -228,6 +230,7 @@ export type BookResolvers, ParentType, ContextType>; pages?: Resolver; publishDate?: Resolver, ParentType, ContextType>; + publishYear?: Resolver, ParentType, ContextType>; publisher?: Resolver, ParentType, ContextType>; rating?: Resolver, ParentType, ContextType>; series?: Resolver, ParentType, ContextType>; @@ -239,7 +242,7 @@ export type BookSearchResultResolvers; }; -export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, PAGES?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, PAGES?: any, PUBLISH_YEAR?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; export type MutationResolvers = { addFavoriteBook?: Resolver>; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts index 302e25b0..2718dd9d 100644 --- a/frontend/app/api/bookSort.ts +++ b/frontend/app/api/bookSort.ts @@ -26,6 +26,16 @@ const BOOK_SORTS = { sortBy: "TITLE", sortDirection: "DESC", }, + publish_year_desc: { + label: "Publish date: newest first", + sortBy: "PUBLISH_YEAR", + sortDirection: "DESC", + }, + publish_year_asc: { + label: "Publish date: oldest first", + sortBy: "PUBLISH_YEAR", + sortDirection: "ASC", + }, pages_asc: { label: "Pages: short to long", sortBy: "PAGES", diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index c95062bf..c8a3e297 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -6,6 +6,7 @@ export type BookSortField = | 'ID' | 'NUM_RATINGS' | 'PAGES' + | 'PUBLISH_YEAR' | 'RATING' | 'TITLE'; @@ -18,7 +19,7 @@ export type BookQueryVariables = Exact<{ }>; -export type BookQuery = { book: { __typename: 'Book', id: number, isbn: string, title: string, author: string, language: string | null, publishDate: string | null, coverImgUrl: string | null, description: string | null, rating: number | null, numRatings: number | null } | null }; +export type BookQuery = { book: { __typename: 'Book', id: number, isbn: string, title: string, author: string, language: string | null, publishYear: number | null, coverImgUrl: string | null, description: string | null, rating: number | null, numRatings: number | null } | null }; export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>; @@ -31,7 +32,7 @@ export type FavoriteBooksQueryVariables = Exact<{ }>; -export type FavoriteBooksQuery = { favoriteGenres: Array, favoriteBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, rating: number | null, numRatings: number | null }> } }; +export type FavoriteBooksQuery = { favoriteGenres: Array, favoriteBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishYear: number | null, coverImgUrl: string | null, rating: number | null, numRatings: number | null }> } }; export type FavoriteBookIdsQueryVariables = Exact<{ [key: string]: never; }>; @@ -68,4 +69,4 @@ export type SearchBooksQueryVariables = Exact<{ }>; -export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, rating: number | null, numRatings: number | null }> } }; +export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishYear: number | null, coverImgUrl: string | null, rating: number | null, numRatings: number | null }> } }; diff --git a/frontend/app/api/yearFromPublishDate.ts b/frontend/app/api/yearFromPublishDate.ts deleted file mode 100644 index 4601c2fa..00000000 --- a/frontend/app/api/yearFromPublishDate.ts +++ /dev/null @@ -1,10 +0,0 @@ -function yearFromPublishDate(publishDate?: string | null): number | null { - if (!publishDate) { - return null; - } - - const match = RegExp(/\d{4}/).exec(publishDate); - return match ? Number(match[0]) : null; -} - -export { yearFromPublishDate }; diff --git a/frontend/app/components/BookCard/BookCard.tsx b/frontend/app/components/BookCard/BookCard.tsx index a8cc8388..31372082 100644 --- a/frontend/app/components/BookCard/BookCard.tsx +++ b/frontend/app/components/BookCard/BookCard.tsx @@ -41,7 +41,7 @@ function BookCard({ book, searchListParams }: BookCardProps) {

{book.authors.join(", ")}

-

{book.publishedYear ?? "Unknown"}

+

{book.publishYear ?? "Unknown"}

{book.rating == null ? ( "No ratings yet" diff --git a/frontend/app/hooks/useBook.ts b/frontend/app/hooks/useBook.ts index 3c3b5e90..825c68b4 100644 --- a/frontend/app/hooks/useBook.ts +++ b/frontend/app/hooks/useBook.ts @@ -4,7 +4,6 @@ import type { BookQuery, BookQueryVariables, } from "~/api/types/graphql.generated"; -import { yearFromPublishDate } from "~/api/yearFromPublishDate"; const BOOK: TypedDocumentNode = gql` query Book($id: Int!) { @@ -14,7 +13,7 @@ const BOOK: TypedDocumentNode = gql` title author language - publishDate + publishYear coverImgUrl description rating @@ -44,13 +43,12 @@ function useBook({ id }: { id: string }) { }; } - const { id: bookId, author, publishDate, ...rest } = book; + const { id: bookId, author, ...rest } = book; return { book: { id: String(bookId), authors: author.split(", "), - publishedYear: yearFromPublishDate(publishDate), ...rest, }, loading, diff --git a/frontend/app/hooks/useFavoriteBooks.ts b/frontend/app/hooks/useFavoriteBooks.ts index 987a62e9..f300fd9a 100644 --- a/frontend/app/hooks/useFavoriteBooks.ts +++ b/frontend/app/hooks/useFavoriteBooks.ts @@ -10,7 +10,6 @@ import type { RemoveFavoriteBookMutation, RemoveFavoriteBookMutationVariables, } from "~/api/types/graphql.generated"; -import { yearFromPublishDate } from "~/api/yearFromPublishDate"; import { BOOK_PAGE_LIMIT } from "~/lib/constants"; const FAVORITE_BOOKS: TypedDocumentNode< @@ -24,7 +23,7 @@ const FAVORITE_BOOKS: TypedDocumentNode< id title author - publishDate + publishYear coverImgUrl rating numRatings @@ -79,12 +78,11 @@ function useFavoriteBooks({ const books = data?.favoriteBooks.books.map((book) => { // Extract only the fields we modify, keep the rest in ...rest - const { id, author, publishDate, ...rest } = book; + const { id, author, ...rest } = book; return { id: String(id), authors: author.split(", "), - publishedYear: yearFromPublishDate(publishDate), ...rest, }; }); diff --git a/frontend/app/hooks/useSearchBooks.ts b/frontend/app/hooks/useSearchBooks.ts index 5802873f..94997f9f 100644 --- a/frontend/app/hooks/useSearchBooks.ts +++ b/frontend/app/hooks/useSearchBooks.ts @@ -6,7 +6,6 @@ import type { SearchBooksQuery, SearchBooksQueryVariables, } from "~/api/types/graphql.generated"; -import { yearFromPublishDate } from "~/api/yearFromPublishDate"; const SEARCH_BOOKS: TypedDocumentNode< SearchBooksQuery, @@ -45,7 +44,7 @@ const SEARCH_BOOKS: TypedDocumentNode< id title author - publishDate + publishYear coverImgUrl rating numRatings @@ -95,12 +94,11 @@ function useSearchBooks({ const books = data?.searchBooks.books.map((book) => { // Extract only the fields we modify, keep the rest in ...rest - const { id, author, publishDate, ...rest } = book; + const { id, author, ...rest } = book; return { id: String(id), authors: author.split(", "), - publishedYear: yearFromPublishDate(publishDate), ...rest, }; }); diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index db380fbc..bcedffc7 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -162,7 +162,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {

{book.authors.join(", ")}

-

Published: {book.publishedYear ?? "Unknown"}

+

Published: {book.publishYear ?? "Unknown"}

Language: {book.language ?? "Unknown"}

diff --git a/frontend/app/test/fixtures/books.ts b/frontend/app/test/fixtures/books.ts index 867e8a01..7a2d7020 100644 --- a/frontend/app/test/fixtures/books.ts +++ b/frontend/app/test/fixtures/books.ts @@ -15,7 +15,7 @@ const bookWithCover: Book = { rating: null, numRatings: null, authors: ["Joe Abercrombie"], - publishedYear: 2001, + publishYear: 2001, coverImgUrl: "https://covers.openlibrary.org/b/id/14543422-M.jpg?default=false", }; @@ -29,7 +29,7 @@ const apiSearchBooks: ApiSearchBook[] = [ id: 1, title: "The Blade Itself", author: "Joe Abercrombie", - publishDate: "2006-05-04", + publishYear: 2006, coverImgUrl: null, rating: 4.15, numRatings: 312505, @@ -39,7 +39,7 @@ const apiSearchBooks: ApiSearchBook[] = [ id: 2, title: "Before They Are Hanged", author: "Joe Abercrombie", - publishDate: "2007-03-15", + publishYear: 2007, coverImgUrl: null, rating: 4.27, numRatings: 125020, @@ -49,7 +49,7 @@ const apiSearchBooks: ApiSearchBook[] = [ id: 3, title: "Good Omens", author: "Terry Pratchett, Neil Gaiman", - publishDate: "1990", + publishYear: 1990, coverImgUrl: null, rating: null, numRatings: null, @@ -64,7 +64,7 @@ const apiBook: NonNullable = { title: "The Blade Itself", author: "Joe Abercrombie", language: "English", - publishDate: "2006-05-04", + publishYear: 2006, coverImgUrl: null, description: "Logen Ninefingers, infamous barbarian, has finally run out of luck.", From 32f00a39facb9dd169c902ee5b3fe4cedee93f16 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:16:25 +0100 Subject: [PATCH 09/17] chore: add db migration for book publish years --- .../20261109235407_publish_year/migration.sql | 12 + .../20261109235407_publish_year/snapshot.json | 1394 +++++++++++++++++ 2 files changed, 1406 insertions(+) create mode 100644 backend/drizzle/20261109235407_publish_year/migration.sql create mode 100644 backend/drizzle/20261109235407_publish_year/snapshot.json diff --git a/backend/drizzle/20261109235407_publish_year/migration.sql b/backend/drizzle/20261109235407_publish_year/migration.sql new file mode 100644 index 00000000..d3614a92 --- /dev/null +++ b/backend/drizzle/20261109235407_publish_year/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "books" ADD COLUMN "publish_year" integer;--> statement-breakpoint +CREATE INDEX "books_publish_year_id_idx" ON "books" ("publish_year" DESC NULLS LAST,"id");--> statement-breakpoint + +-- Add publish_year to books that already exist. Same rules as parsePublishYear in the seed script +UPDATE "books" SET "publish_year" = CASE + WHEN "publish_date" ~ '\m\d{4}\M' THEN substring("publish_date" from '\m(\d{4})\M')::integer + WHEN "publish_date" ~ '^\d{2}/\d{2}/\d{2}$' THEN + CASE WHEN right("publish_date", 2)::integer > 21 + THEN 1900 + right("publish_date", 2)::integer + ELSE 2000 + right("publish_date", 2)::integer + END +END; diff --git a/backend/drizzle/20261109235407_publish_year/snapshot.json b/backend/drizzle/20261109235407_publish_year/snapshot.json new file mode 100644 index 00000000..0ecfca6d --- /dev/null +++ b/backend/drizzle/20261109235407_publish_year/snapshot.json @@ -0,0 +1,1394 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "2900fee7-72ca-42c9-9fc0-fa4bbc363ef4", + "prevIds": [ + "09cdfdb6-607c-4984-99fa-1232113b1e14" + ], + "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": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_year", + "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": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_title_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_num_ratings_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pages", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_pages_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "publish_year", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_publish_year_id_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 469b46fa35b4ed2539fd876a9b84651c0bb1279b Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:26:00 +0100 Subject: [PATCH 10/17] chore: add db migration for oldest first publish year sorting index --- .../migration.sql | 1 + .../snapshot.json | 30 ++++++++++++++++++- backend/src/db/schema/books.ts | 5 ++++ .../books/resolvers/Query/searchBooks.ts | 6 ++-- 4 files changed, 38 insertions(+), 4 deletions(-) rename backend/drizzle/{20261109235407_publish_year => 20261110002431_publish_year}/migration.sql (86%) rename backend/drizzle/{20261109235407_publish_year => 20261110002431_publish_year}/snapshot.json (97%) diff --git a/backend/drizzle/20261109235407_publish_year/migration.sql b/backend/drizzle/20261110002431_publish_year/migration.sql similarity index 86% rename from backend/drizzle/20261109235407_publish_year/migration.sql rename to backend/drizzle/20261110002431_publish_year/migration.sql index d3614a92..55b7be8c 100644 --- a/backend/drizzle/20261109235407_publish_year/migration.sql +++ b/backend/drizzle/20261110002431_publish_year/migration.sql @@ -1,5 +1,6 @@ ALTER TABLE "books" ADD COLUMN "publish_year" integer;--> statement-breakpoint CREATE INDEX "books_publish_year_id_idx" ON "books" ("publish_year" DESC NULLS LAST,"id");--> statement-breakpoint +CREATE INDEX "books_publish_year_asc_id_idx" ON "books" ("publish_year","id");--> statement-breakpoint -- Add publish_year to books that already exist. Same rules as parsePublishYear in the seed script UPDATE "books" SET "publish_year" = CASE diff --git a/backend/drizzle/20261109235407_publish_year/snapshot.json b/backend/drizzle/20261110002431_publish_year/snapshot.json similarity index 97% rename from backend/drizzle/20261109235407_publish_year/snapshot.json rename to backend/drizzle/20261110002431_publish_year/snapshot.json index 0ecfca6d..2db91ff1 100644 --- a/backend/drizzle/20261109235407_publish_year/snapshot.json +++ b/backend/drizzle/20261110002431_publish_year/snapshot.json @@ -1,7 +1,7 @@ { "version": "8", "dialect": "postgres", - "id": "2900fee7-72ca-42c9-9fc0-fa4bbc363ef4", + "id": "cc1b05d5-b0a3-484c-bc2c-f2a57c417942", "prevIds": [ "09cdfdb6-607c-4984-99fa-1232113b1e14" ], @@ -1099,6 +1099,34 @@ "schema": "public", "table": "books" }, + { + "nameExplicit": true, + "columns": [ + { + "value": "publish_year", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_publish_year_asc_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, { "nameExplicit": true, "columns": [ diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index dbe5dd58..a4d5c523 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -49,6 +49,11 @@ const books = pgTable( t.publishYear.desc().nullsLast(), t.id, ), + // Oldest first needs its own index, since it also keeps books without a year last + index("books_publish_year_asc_id_idx").on( + t.publishYear.asc().nullsLast(), + 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 4816e0c7..5d1a672e 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -56,9 +56,9 @@ const ORDER_BY: Record> = { ASC: [asc(books.pages), asc(books.id)], DESC: [desc(books.pages), desc(books.id)], }, - // Books without a year are always shown at the end of the list. Only newest - // first can use the index books_publish_year_id_idx. Reading it backwards - // places books without a year first, so oldest first can't use it + // Books without a year are always shown at the end of the list. Newest first + // uses the index books_publish_year_id_idx and oldest first uses + // books_publish_year_asc_id_idx PUBLISH_YEAR: { ASC: [sql`${books.publishYear} asc nulls last`, asc(books.id)], DESC: [sql`${books.publishYear} desc nulls last`, asc(books.id)], From b7ef2dbd012b588b27f880336a23d379da80c385 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:34:33 +0100 Subject: [PATCH 11/17] feat: minimum ratings when sorting by best rating --- .../books/SortSelect/SortSelect.test.tsx | 69 +++++++++++++++++++ .../books/SortSelect/SortSelect.tsx | 8 ++- .../home/ForYouSection/ForYouSection.tsx | 6 +- .../home/TopRatedSection/TopRatedSection.tsx | 4 +- frontend/app/hooks/useSuggestedBooks.ts | 6 +- frontend/app/lib/constants.ts | 7 +- frontend/app/routes/home.test.tsx | 12 ++-- frontend/app/routes/home.tsx | 4 +- 8 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 frontend/app/components/books/SortSelect/SortSelect.test.tsx diff --git a/frontend/app/components/books/SortSelect/SortSelect.test.tsx b/frontend/app/components/books/SortSelect/SortSelect.test.tsx new file mode 100644 index 00000000..9d9bbf9d --- /dev/null +++ b/frontend/app/components/books/SortSelect/SortSelect.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub, useSearchParams } from "react-router"; +import { SortSelect } from "~/components/books/SortSelect/SortSelect"; + +// Shows the current params so tests can assert what was written to the URL. +function SearchParamProbe() { + const [searchParams] = useSearchParams(); + return ( + <> +

{searchParams.get("sort")}

+

{searchParams.get("minRatings")}

+ + ); +} + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + <> + + + + ), + }, +]); + +async function selectSort(name: string) { + const user = userEvent.setup(); + await user.click(screen.getByLabelText("Sort by")); + await user.click(await screen.findByRole("option", { name })); +} + +describe("SortSelect", () => { + it("sets a minimum number of ratings when sorting by highest rating", async () => { + render(); + + await selectSort("Rating: high to low"); + + await waitFor(() => + expect(screen.getByTestId("sort")).toHaveTextContent("rating_desc"), + ); + expect(screen.getByTestId("minRatings")).toHaveTextContent("10000"); + }); + + it("keeps a higher minimum number of ratings", async () => { + render(); + + await selectSort("Rating: high to low"); + + await waitFor(() => + expect(screen.getByTestId("sort")).toHaveTextContent("rating_desc"), + ); + expect(screen.getByTestId("minRatings")).toHaveTextContent("100000"); + }); + + it("does not set a minimum number of ratings for other sorts", async () => { + render(); + + await selectSort("Title: A-Z"); + + await waitFor(() => + expect(screen.getByTestId("sort")).toHaveTextContent("title_asc"), + ); + expect(screen.getByTestId("minRatings")).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/app/components/books/SortSelect/SortSelect.tsx b/frontend/app/components/books/SortSelect/SortSelect.tsx index 093ef337..f81cfaef 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 { MIN_RATINGS } from "~/lib/constants"; import { cn } from "~/lib/utils"; import { Select, @@ -14,7 +15,7 @@ const DEFAULT_SORT = "default"; function SortSelect({ className }: { className?: string }) { const [, setSearchParams] = useSearchParams(); - const { sort } = useBookSearchParams(); + const { sort, filters } = useBookSearchParams(); const items = [ { value: DEFAULT_SORT, label: "Default order" }, @@ -43,6 +44,11 @@ function SortSelect({ className }: { className?: string }) { } else { searchParams.delete("sort"); } + // Only show books with MIN_RATINGS or more by default, + // but if the user has already set another minRatings filter, don't overwrite it + if (newSort === "rating_desc" && filters.minRatings === undefined) { + searchParams.set("minRatings", String(MIN_RATINGS)); + } searchParams.delete("page"); return searchParams; }); diff --git a/frontend/app/components/home/ForYouSection/ForYouSection.tsx b/frontend/app/components/home/ForYouSection/ForYouSection.tsx index d9782892..38cd1054 100644 --- a/frontend/app/components/home/ForYouSection/ForYouSection.tsx +++ b/frontend/app/components/home/ForYouSection/ForYouSection.tsx @@ -7,7 +7,7 @@ import { Skeleton } from "~/components/ui/Skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/Tabs"; import { useFavoriteBooks, useFavoriteStatus } from "~/hooks/useFavoriteBooks"; import { useSearchBooks } from "~/hooks/useSearchBooks"; -import { HOME_MIN_RATINGS } from "~/lib/constants"; +import { MIN_RATINGS } from "~/lib/constants"; const RECOMMENDATION_LIMIT = 4; const BOOK_GRID = @@ -38,7 +38,7 @@ function ForYouSection() { const { books, loading } = useSearchBooks({ filters: { genres: genre ? [genre] : undefined, - minRatings: HOME_MIN_RATINGS, + minRatings: MIN_RATINGS, }, title: "", // Fetch extra, since books the user has already saved are left out. @@ -60,7 +60,7 @@ function ForYouSection() { const genreSearch = new URLSearchParams({ genres: genre, sort: "rating_desc", - minRatings: String(HOME_MIN_RATINGS), + minRatings: String(MIN_RATINGS), }).toString(); const recommendationList = loading ? ( diff --git a/frontend/app/components/home/TopRatedSection/TopRatedSection.tsx b/frontend/app/components/home/TopRatedSection/TopRatedSection.tsx index 2d40bca7..da59e3cf 100644 --- a/frontend/app/components/home/TopRatedSection/TopRatedSection.tsx +++ b/frontend/app/components/home/TopRatedSection/TopRatedSection.tsx @@ -4,10 +4,10 @@ import { BookCard } from "~/components/BookCard/BookCard"; import { RevealSection } from "~/components/home/RevealSection/RevealSection"; import { Skeleton } from "~/components/ui/Skeleton"; import type { SearchBook } from "~/hooks/useSearchBooks"; -import { HOME_MIN_RATINGS } from "~/lib/constants"; +import { MIN_RATINGS } from "~/lib/constants"; // The same books on the books page, for "See all" and for browsing from a book. -const TOP_RATED_SEARCH = `?sort=rating_desc&minRatings=${HOME_MIN_RATINGS}`; +const TOP_RATED_SEARCH = `?sort=rating_desc&minRatings=${MIN_RATINGS}`; function TopRatedSection({ books, diff --git a/frontend/app/hooks/useSuggestedBooks.ts b/frontend/app/hooks/useSuggestedBooks.ts index dbcb9c1f..618b6916 100644 --- a/frontend/app/hooks/useSuggestedBooks.ts +++ b/frontend/app/hooks/useSuggestedBooks.ts @@ -1,9 +1,7 @@ import type { BookFilters } from "~/api/types"; import type { BookSortKey } from "~/api/bookSort"; import { useSearchBooks } from "~/hooks/useSearchBooks"; -import { BOOK_PAGE_LIMIT } from "~/lib/constants"; - -const MIN_SUGGESTION_RATINGS = 10_000; +import { BOOK_PAGE_LIMIT, MIN_RATINGS } from "~/lib/constants"; /** * Suggested books when a search has no results. @@ -45,7 +43,7 @@ function useSuggestedBooks({ const topRated = useSearchBooks({ filters: { ...keptFilters, - minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_SUGGESTION_RATINGS), + minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_RATINGS), }, title: "", limit: BOOK_PAGE_LIMIT, diff --git a/frontend/app/lib/constants.ts b/frontend/app/lib/constants.ts index 93da2a52..f11ec9bd 100644 --- a/frontend/app/lib/constants.ts +++ b/frontend/app/lib/constants.ts @@ -4,7 +4,8 @@ const MIN_PASSWORD_LENGTH = 8; // Default number of books shown on a page const BOOK_PAGE_LIMIT = 12; -// The minimum number of ratings of books on the home page. -const HOME_MIN_RATINGS = 10_000; +// The minimum number of ratings for a book to not be considered obscure. +// Used for top-rated books and when sorting by highest rating +const MIN_RATINGS = 10_000; -export { MIN_PASSWORD_LENGTH, BOOK_PAGE_LIMIT, HOME_MIN_RATINGS }; +export { MIN_PASSWORD_LENGTH, BOOK_PAGE_LIMIT, MIN_RATINGS }; diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx index 8083575d..aa9c42db 100644 --- a/frontend/app/routes/home.test.tsx +++ b/frontend/app/routes/home.test.tsx @@ -14,7 +14,7 @@ import { bookFiltersMock, searchBooksMock } from "~/test/mocks"; import { renderRoutes } from "~/test/renderRoutes"; import { apiSearchBooks } from "~/test/fixtures/books"; import { loggedIn, loggedOut } from "~/test/session"; -import { HOME_MIN_RATINGS } from "~/lib/constants"; +import { MIN_RATINGS } from "~/lib/constants"; vi.mock("~/api/auth", () => ({ authClient: { useSession: vi.fn(() => loggedOut) }, @@ -57,7 +57,7 @@ const recommendationsMock = (genre: string) => searchBooksMock({ title: "", genres: [genre], - minRatings: HOME_MIN_RATINGS, + minRatings: MIN_RATINGS, sortBy: "RATING", sortDirection: "DESC", limit: 12, @@ -66,7 +66,7 @@ const recommendationsMock = (genre: string) => const topRatedMock = searchBooksMock({ title: "", - minRatings: HOME_MIN_RATINGS, + minRatings: MIN_RATINGS, sortBy: "RATING", sortDirection: "DESC", limit: 8, @@ -94,7 +94,7 @@ describe("HomePage", () => { screen.getByRole("link", { name: "View details for The Blade Itself" }), ).toHaveAttribute( "href", - `/book/1?sort=rating_desc&minRatings=${HOME_MIN_RATINGS}`, + `/book/1?sort=rating_desc&minRatings=${MIN_RATINGS}`, ); }); @@ -208,7 +208,7 @@ describe("HomePage", () => { }), ).toHaveAttribute( "href", - `/book/3?genres=Fantasy&sort=rating_desc&minRatings=${HOME_MIN_RATINGS}`, + `/book/3?genres=Fantasy&sort=rating_desc&minRatings=${MIN_RATINGS}`, ); expect( screen.queryByRole("region", { name: "Start your reading list" }), @@ -265,7 +265,7 @@ describe("HomePage", () => { expect(romance).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("link", { name: /More Romance/ })).toHaveAttribute( "href", - `/books?genres=Romance&sort=rating_desc&minRatings=${HOME_MIN_RATINGS}`, + `/books?genres=Romance&sort=rating_desc&minRatings=${MIN_RATINGS}`, ); }); diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 3c257f31..498d4274 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -7,7 +7,7 @@ import { SignUpBanner } from "~/components/home/SignUpBanner/SignUpBanner"; import { StatsSection } from "~/components/home/StatsSection/StatsSection"; import { TopRatedSection } from "~/components/home/TopRatedSection/TopRatedSection"; import { useSearchBooks } from "~/hooks/useSearchBooks"; -import { HOME_MIN_RATINGS } from "~/lib/constants"; +import { MIN_RATINGS } from "~/lib/constants"; const TOP_RATED_LIMIT = 8; @@ -22,7 +22,7 @@ export default function HomePage() { const { data: session, isPending } = authClient.useSession(); const { books, loading } = useSearchBooks({ - filters: { minRatings: HOME_MIN_RATINGS }, + filters: { minRatings: MIN_RATINGS }, title: "", limit: TOP_RATED_LIMIT, offset: 0, From 3e0be130d2e202e255e9f1b3fc81774cb7abaf24 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:38:38 +0100 Subject: [PATCH 12/17] feat: show toast when setting the number of minimum ratings for the user --- .../books/SortSelect/SortSelect.test.tsx | 17 +++++++++++++---- .../components/books/SortSelect/SortSelect.tsx | 17 ++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/frontend/app/components/books/SortSelect/SortSelect.test.tsx b/frontend/app/components/books/SortSelect/SortSelect.test.tsx index 9d9bbf9d..c0b17558 100644 --- a/frontend/app/components/books/SortSelect/SortSelect.test.tsx +++ b/frontend/app/components/books/SortSelect/SortSelect.test.tsx @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRoutesStub, useSearchParams } from "react-router"; import { SortSelect } from "~/components/books/SortSelect/SortSelect"; +import { toast } from "~/components/ui/Toast"; // Shows the current params so tests can assert what was written to the URL. function SearchParamProbe() { @@ -35,6 +36,7 @@ async function selectSort(name: string) { describe("SortSelect", () => { it("sets a minimum number of ratings when sorting by highest rating", async () => { + const addToast = vi.spyOn(toast, "add"); render(); await selectSort("Rating: high to low"); @@ -43,20 +45,26 @@ describe("SortSelect", () => { expect(screen.getByTestId("sort")).toHaveTextContent("rating_desc"), ); expect(screen.getByTestId("minRatings")).toHaveTextContent("10000"); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ type: "info" }), + ); }); - it("keeps a higher minimum number of ratings", async () => { - render(); + it("keeps a minimum number of ratings the user has chosen", async () => { + const addToast = vi.spyOn(toast, "add"); + render(); await selectSort("Rating: high to low"); await waitFor(() => expect(screen.getByTestId("sort")).toHaveTextContent("rating_desc"), ); - expect(screen.getByTestId("minRatings")).toHaveTextContent("100000"); + expect(screen.getByTestId("minRatings")).toHaveTextContent("100"); + expect(addToast).not.toHaveBeenCalled(); }); it("does not set a minimum number of ratings for other sorts", async () => { + const addToast = vi.spyOn(toast, "add"); render(); await selectSort("Title: A-Z"); @@ -65,5 +73,6 @@ describe("SortSelect", () => { expect(screen.getByTestId("sort")).toHaveTextContent("title_asc"), ); expect(screen.getByTestId("minRatings")).toBeEmptyDOMElement(); + expect(addToast).not.toHaveBeenCalled(); }); }); diff --git a/frontend/app/components/books/SortSelect/SortSelect.tsx b/frontend/app/components/books/SortSelect/SortSelect.tsx index f81cfaef..0e6c8630 100644 --- a/frontend/app/components/books/SortSelect/SortSelect.tsx +++ b/frontend/app/components/books/SortSelect/SortSelect.tsx @@ -2,6 +2,7 @@ import { useSearchParams } from "react-router"; import { BOOK_SORTS, parseBookSort } from "~/api/bookSort"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; import { MIN_RATINGS } from "~/lib/constants"; +import { toast } from "~/components/ui/Toast"; import { cn } from "~/lib/utils"; import { Select, @@ -38,20 +39,30 @@ function SortSelect({ className }: { className?: string }) { value={sort ?? DEFAULT_SORT} onValueChange={(value) => { const newSort = parseBookSort(value); + // Only show books with MIN_RATINGS or more by default, + // but if the user has already set another minRatings filter, don't overwrite it + const setMinRatings = + newSort === "rating_desc" && filters.minRatings === undefined; + setSearchParams((searchParams) => { if (newSort) { searchParams.set("sort", newSort); } else { searchParams.delete("sort"); } - // Only show books with MIN_RATINGS or more by default, - // but if the user has already set another minRatings filter, don't overwrite it - if (newSort === "rating_desc" && filters.minRatings === undefined) { + if (setMinRatings) { searchParams.set("minRatings", String(MIN_RATINGS)); } searchParams.delete("page"); return searchParams; }); + + if (setMinRatings) { + toast.add({ + description: `Showing books with ${MIN_RATINGS.toLocaleString("en")}+ ratings. You can change this anytime in the filters.`, + type: "info", + }); + } }} > From f757617e7fdbb719bf26e6f4d583f17e4a420637 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 01:57:02 +0100 Subject: [PATCH 13/17] feat: sort by relevant books first This sorts for books with most ratings first instead of sorting by id. This is done with sorting by most ratings first, and fuzzy matching is still used when no books match a query --- .../books/resolvers/Query/searchBooks.ts | 19 +++++++--- backend/src/schema/books/schema.graphql | 3 +- backend/src/schema/schema.generated.graphqls | 3 +- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 5 +-- frontend/app/api/bookSort.ts | 5 --- frontend/app/api/types/graphql.generated.ts | 1 + .../books/SortSelect/SortSelect.test.tsx | 19 ++++++++++ .../books/SortSelect/SortSelect.tsx | 35 +++++++++++++++++-- 9 files changed, 76 insertions(+), 16 deletions(-) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 5d1a672e..49659b45 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -19,7 +19,18 @@ import type { SortDirection, } from "~/schema/types.generated"; +const MOST_RATINGS_FIRST = [ + sql`${books.numRatings} desc nulls last`, + asc(books.id), +]; + const ORDER_BY: Record> = { + // The default order. Relevance has no direction. A fuzzy title search is + // sorted by similarity instead, see below + RELEVANCE: { + ASC: MOST_RATINGS_FIRST, + DESC: MOST_RATINGS_FIRST, + }, ID: { ASC: [asc(books.id)], DESC: [desc(books.id)], @@ -48,7 +59,7 @@ const ORDER_BY: Record> = { // of DESC, so both can use the index books_num_ratings_id_idx NUM_RATINGS: { ASC: [sql`${books.numRatings} asc nulls first`, desc(books.id)], - DESC: [sql`${books.numRatings} desc nulls last`, asc(books.id)], + DESC: MOST_RATINGS_FIRST, }, // Long to short is the exact reverse of short to long, so both can use the // index books_pages_id_idx @@ -114,11 +125,11 @@ export const searchBooks: NonNullable = async ( // can be used both for fetching one page and for counting all matches. const filter = and(...conditions); - const sortBy = args.sortBy ?? "ID"; + const sortBy = args.sortBy ?? "RELEVANCE"; let orderBy = ORDER_BY[sortBy][args.sortDirection ?? "ASC"]; - // The default order for a fuzzy search is the most similar titles first - if (sortBy === "ID" && args.fuzzy && args.title) { + // The most relevant books for a fuzzy search are the most similar titles + if (sortBy === "RELEVANCE" && args.fuzzy && args.title) { orderBy = [ // Safe from SQL injection. Values are passed as parameters to the query by Drizzle. sql`word_similarity(${args.title}, ${books.title}) desc`, diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index ae63996c..2640b3eb 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -19,6 +19,7 @@ type Book { } enum BookSortField { + RELEVANCE ID RATING TITLE @@ -53,7 +54,7 @@ extend type Query { minRatings: Int limit: Int = 12 offset: Int = 0 - sortBy: BookSortField = ID + sortBy: BookSortField = RELEVANCE sortDirection: SortDirection = ASC ): BookSearchResult! } diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 39c71587..bea99b98 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -29,6 +29,7 @@ enum BookSortField { PAGES PUBLISH_YEAR RATING + RELEVANCE TITLE } @@ -70,7 +71,7 @@ type Query { offset: Int = 0 publisher: String series: String - sortBy: BookSortField = ID + sortBy: BookSortField = RELEVANCE sortDirection: SortDirection = ASC title: String ): BookSearchResult! diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 26e0b556..9b319551 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","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":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"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":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"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":"RELEVANCE"}},{"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":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"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":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"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 0823df95..2932db9e 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -48,6 +48,7 @@ export type BookSortField = | 'PAGES' | 'PUBLISH_YEAR' | 'RATING' + | 'RELEVANCE' | 'TITLE'; export type Mutation = { @@ -198,7 +199,7 @@ export type ResolversTypes = { Int: ResolverTypeWrapper; Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; - BookSortField: ResolverTypeWrapper<'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS' | 'PAGES' | 'PUBLISH_YEAR'>; + BookSortField: ResolverTypeWrapper<'RELEVANCE' | 'ID' | 'RATING' | 'TITLE' | 'NUM_RATINGS' | 'PAGES' | 'PUBLISH_YEAR'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -242,7 +243,7 @@ export type BookSearchResultResolvers; }; -export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, PAGES?: any, PUBLISH_YEAR?: any, RATING?: any, TITLE?: any }, ResolversTypes['BookSortField']>; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATINGS?: any, PAGES?: any, PUBLISH_YEAR?: any, RATING?: any, RELEVANCE?: any, TITLE?: any }, ResolversTypes['BookSortField']>; export type MutationResolvers = { addFavoriteBook?: Resolver>; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts index 2718dd9d..ec73e988 100644 --- a/frontend/app/api/bookSort.ts +++ b/frontend/app/api/bookSort.ts @@ -1,11 +1,6 @@ // The sorting options users can choose between. The key is what we store in the // URL (?sort=rating_desc), and the other fields are what we send to the API. const BOOK_SORTS = { - num_ratings_desc: { - label: "Most ratings", - sortBy: "NUM_RATINGS", - sortDirection: "DESC", - }, rating_desc: { label: "Rating: high to low", sortBy: "RATING", diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index c8a3e297..3ec42280 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -8,6 +8,7 @@ export type BookSortField = | 'PAGES' | 'PUBLISH_YEAR' | 'RATING' + | 'RELEVANCE' | 'TITLE'; export type SortDirection = diff --git a/frontend/app/components/books/SortSelect/SortSelect.test.tsx b/frontend/app/components/books/SortSelect/SortSelect.test.tsx index c0b17558..9e5e532e 100644 --- a/frontend/app/components/books/SortSelect/SortSelect.test.tsx +++ b/frontend/app/components/books/SortSelect/SortSelect.test.tsx @@ -35,6 +35,25 @@ async function selectSort(name: string) { } describe("SortSelect", () => { + it("uses relevance when the URL has no sort", () => { + render(); + + expect(screen.getByLabelText("Sort by")).toHaveTextContent("Relevance"); + }); + + it("explains relevance", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "What does relevance mean?" }), + ); + + expect( + await screen.findByText(/the books with the most ratings first/), + ).toBeInTheDocument(); + }); + it("sets a minimum number of ratings when sorting by highest rating", async () => { const addToast = vi.spyOn(toast, "add"); render(); diff --git a/frontend/app/components/books/SortSelect/SortSelect.tsx b/frontend/app/components/books/SortSelect/SortSelect.tsx index 0e6c8630..539f1914 100644 --- a/frontend/app/components/books/SortSelect/SortSelect.tsx +++ b/frontend/app/components/books/SortSelect/SortSelect.tsx @@ -1,4 +1,11 @@ import { useSearchParams } from "react-router"; +import { InfoIcon } from "lucide-react"; +import { Button } from "~/components/ui/Button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/Popover"; import { BOOK_SORTS, parseBookSort } from "~/api/bookSort"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; import { MIN_RATINGS } from "~/lib/constants"; @@ -12,14 +19,14 @@ import { SelectValue, } from "~/components/ui/Select"; -const DEFAULT_SORT = "default"; +const DEFAULT_SORT = "relevance"; function SortSelect({ className }: { className?: string }) { const [, setSearchParams] = useSearchParams(); const { sort, filters } = useBookSearchParams(); const items = [ - { value: DEFAULT_SORT, label: "Default order" }, + { value: DEFAULT_SORT, label: "Relevance" }, ...Object.entries(BOOK_SORTS).map(([key, { label }]) => ({ value: key, label, @@ -76,6 +83,30 @@ function SortSelect({ className }: { className?: string }) { ))} + {/* Undefined sort is the default sorting (relevance) */} + {sort === undefined && ( + + + } + > + + +

+ Relevance shows the books with the most ratings first. When we + show books with similar titles to your search, the closest match + comes first. +

+
+
+ )} ); } From 9e705de3a6afd04da7ae180c7ef5162c3c612aae Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 20:32:26 +0100 Subject: [PATCH 14/17] style: always write SQL code in uppercase --- .../schema/books/resolvers/Query/searchBooks.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 49659b45..7b408401 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -20,7 +20,7 @@ import type { } from "~/schema/types.generated"; const MOST_RATINGS_FIRST = [ - sql`${books.numRatings} desc nulls last`, + sql`${books.numRatings} DESC NULLS LAST`, asc(books.id), ]; @@ -40,12 +40,12 @@ const ORDER_BY: Record> = { // database index (books_rating_num_ratings_idx) sorted the same way. RATING: { ASC: [ - sql`${books.rating} asc nulls last`, + sql`${books.rating} ASC NULLS LAST`, desc(books.numRatings), asc(books.id), ], DESC: [ - sql`${books.rating} desc nulls last`, + sql`${books.rating} DESC NULLS LAST`, desc(books.numRatings), asc(books.id), ], @@ -58,7 +58,7 @@ const ORDER_BY: Record> = { // Books without ratings (null) count as having no ratings. ASC is the exact reverse // of DESC, so both can use the index books_num_ratings_id_idx NUM_RATINGS: { - ASC: [sql`${books.numRatings} asc nulls first`, desc(books.id)], + ASC: [sql`${books.numRatings} ASC NULLS FIRST`, desc(books.id)], DESC: MOST_RATINGS_FIRST, }, // Long to short is the exact reverse of short to long, so both can use the @@ -71,8 +71,8 @@ const ORDER_BY: Record> = { // uses the index books_publish_year_id_idx and oldest first uses // books_publish_year_asc_id_idx PUBLISH_YEAR: { - ASC: [sql`${books.publishYear} asc nulls last`, asc(books.id)], - DESC: [sql`${books.publishYear} desc nulls last`, asc(books.id)], + ASC: [sql`${books.publishYear} ASC NULLS LAST`, asc(books.id)], + DESC: [sql`${books.publishYear} DESC NULLS LAST`, asc(books.id)], }, }; @@ -132,7 +132,7 @@ export const searchBooks: NonNullable = async ( if (sortBy === "RELEVANCE" && args.fuzzy && args.title) { orderBy = [ // Safe from SQL injection. Values are passed as parameters to the query by Drizzle. - sql`word_similarity(${args.title}, ${books.title}) desc`, + sql`word_similarity(${args.title}, ${books.title}) DESC`, asc(books.id), ]; } From e6ca11e0e96b4fde26ed244b03399cbae1fffdc7 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:40:02 +0100 Subject: [PATCH 15/17] fix: eslint errors in seed and searchBooks --- backend/src/db/seed/index.ts | 2 +- backend/src/schema/books/resolvers/Query/searchBooks.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 993d582d..c28479ee 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -118,7 +118,7 @@ for await (const book of parser as AsyncIterable) { pages: book.pages, publisher: emptyToNull(book.publisher), publishDate: emptyToNull(book.publishDate), - publishYear: parsePublishYear(book.publishDate?.trim() || null), + publishYear: parsePublishYear(emptyToNull(book.publishDate)), coverImgUrl: emptyToNull(book.coverImg), ...parseRating(book), }); diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 0df3eb5a..b536cd6f 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -125,8 +125,8 @@ export const searchBooks: NonNullable = async ( // can be used both for fetching one page and for counting all matches. const filter = and(...conditions); - const sortBy = args.sortBy ?? "RELEVANCE"; - let orderBy = ORDER_BY[sortBy][args.sortDirection ?? "ASC"]; + const sortBy = args.sortBy; + let orderBy = ORDER_BY[sortBy][args.sortDirection]; // The most relevant books for a fuzzy search are the most similar titles if (sortBy === "RELEVANCE" && args.fuzzy && args.title) { From 3aea14ab1e14d25b3b6cbb709f2c40496f6a5631 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:42:21 +0100 Subject: [PATCH 16/17] fix: make sortBy and sortDirection strictly non-null in schema --- backend/src/schema/books/schema.graphql | 4 ++-- backend/src/schema/schema.generated.graphqls | 4 ++-- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 2640b3eb..76df77b5 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -54,8 +54,8 @@ extend type Query { minRatings: Int limit: Int = 12 offset: Int = 0 - sortBy: BookSortField = RELEVANCE - sortDirection: SortDirection = ASC + sortBy: BookSortField! = RELEVANCE + sortDirection: SortDirection! = ASC ): BookSearchResult! } diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index bea99b98..e1c78b95 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -71,8 +71,8 @@ type Query { offset: Int = 0 publisher: String series: String - sortBy: BookSortField = RELEVANCE - sortDirection: SortDirection = ASC + sortBy: BookSortField! = RELEVANCE + sortDirection: SortDirection! = ASC title: String ): BookSearchResult! } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 9b319551..fa3c9100 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","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":"RELEVANCE"}},{"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":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"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":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"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":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"RELEVANCE"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","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":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"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":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"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 2932db9e..4559a5af 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -112,8 +112,8 @@ export type QuerysearchBooksArgs = { offset?: InputMaybe; publisher?: InputMaybe; series?: InputMaybe; - sortBy?: InputMaybe; - sortDirection?: InputMaybe; + sortBy?: BookSortField; + sortDirection?: SortDirection; title?: InputMaybe; }; From 3cce04e3d812c742b114009aedf52458c49c1d1c Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 12 Nov 2026 15:37:02 +0100 Subject: [PATCH 17/17] feat: double the sorting toast timeout Co-authored-by: Johannes Horn --- frontend/app/components/books/SortSelect/SortSelect.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/components/books/SortSelect/SortSelect.tsx b/frontend/app/components/books/SortSelect/SortSelect.tsx index 539f1914..b7e9f2f6 100644 --- a/frontend/app/components/books/SortSelect/SortSelect.tsx +++ b/frontend/app/components/books/SortSelect/SortSelect.tsx @@ -68,6 +68,7 @@ function SortSelect({ className }: { className?: string }) { toast.add({ description: `Showing books with ${MIN_RATINGS.toLocaleString("en")}+ ratings. You can change this anytime in the filters.`, type: "info", + timeout: 10000, }); } }}