From 96a3c89f45e4a96ae0315a258312c2f05f89a378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?= Date: Mon, 9 Nov 2026 13:11:37 +0100 Subject: [PATCH 1/2] Paginate venues --- apps/backend/src/schema/schema.ts | 124 ++++++++++++- apps/backend/tests/e2e/api.spec.ts | 168 ++++++++++++++++++ .../src/graphql/__tests__/venues.test.ts | 93 ++++++++++ apps/frontend/src/graphql/venues.ts | 87 ++++++++- .../src/routes/$cityId/venues/index.tsx | 87 +++++++-- 5 files changed, 542 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/graphql/__tests__/venues.test.ts diff --git a/apps/backend/src/schema/schema.ts b/apps/backend/src/schema/schema.ts index 4a55898..b3deae6 100644 --- a/apps/backend/src/schema/schema.ts +++ b/apps/backend/src/schema/schema.ts @@ -1,5 +1,6 @@ import { GlassSize, Prisma, prisma } from '@t09-project-2/db' import type { OpeningHours, PriceSighting } from '@t09-project-2/db' +import { GraphQLError } from 'graphql' import { builder } from '../builder.js' import { latestPriceSightings } from './price-sightings.js' @@ -132,10 +133,12 @@ type SearchVenueRow = { average_rating: number | null review_count: number price_sighting_id: string | null + total_count?: number } type VenueFilters = { term: string | null + cityId: string | null lat: number | null lon: number | null maxDistanceKm: number | null @@ -148,6 +151,29 @@ type VenueFilters = { type VenueQueryOptions = { orderByDistance?: boolean limit?: number | null + cursor?: string | null + withTotalCount?: boolean +} + +/** + * A cursor is the id of the last venue on the previous page. We look up that + * venue to recover its sort key, then continue after the `(sort key, id)` pair + * in Postgres. This keeps the existing default name ordering intact and gives + * venues with equal names a deterministic order. + */ +async function findCursorVenue(cursor: string) { + const venue = await prisma.venue.findUnique({ + where: { id: cursor }, + select: { id: true, name: true, lat: true, lon: true }, + }) + + if (!venue) { + throw new GraphQLError('Venue cursor does not exist', { + extensions: { code: 'BAD_USER_INPUT' }, + }) + } + + return venue } /** @@ -183,6 +209,10 @@ async function findFilteredVenues( ) } + if (filters.cityId) { + conditions.push(Prisma.sql`v.city_id = ${filters.cityId}::uuid`) + } + if ( filters.lat != null && filters.lon != null && @@ -222,15 +252,33 @@ async function findFilteredVenues( ? Prisma.sql`WHERE ${Prisma.join(priceConditions, ' AND ')}` : Prisma.empty + const orderByDistance = + options.orderByDistance === true && + filters.lat != null && + filters.lon != null + const distanceExpression = filters.lat != null && filters.lon != null ? Prisma.sql`haversine_distance(${filters.lat}::double precision, ${filters.lon}::double precision, v.lat, v.lon) / 1000` : Prisma.sql`NULL::double precision` - const orderBy = - options.orderByDistance && filters.lat != null && filters.lon != null - ? Prisma.sql`distance_km ASC, v.name ASC` - : Prisma.sql`v.name ASC` + if (options.cursor) { + const cursorVenue = await findCursorVenue(options.cursor) + + conditions.push( + orderByDistance && filters.lat != null && filters.lon != null + ? Prisma.sql`(${distanceExpression}, v.name, v.id) > ( + haversine_distance(${filters.lat}::double precision, ${filters.lon}::double precision, ${cursorVenue.lat}, ${cursorVenue.lon}) / 1000, + ${cursorVenue.name}, + ${cursorVenue.id}::uuid + )` + : Prisma.sql`(v.name, v.id) > (${cursorVenue.name}, ${cursorVenue.id}::uuid)`, + ) + } + + const orderBy = orderByDistance + ? Prisma.sql`distance_km ASC, v.name ASC, v.id ASC` + : Prisma.sql`v.name ASC, v.id ASC` const limit = options.limit == null ? null : Math.max(0, options.limit) return prisma.$queryRaw(Prisma.sql` @@ -268,6 +316,11 @@ async function findFilteredVenues( ratings.average_rating, COALESCE(ratings.review_count, 0) AS review_count, matching_prices.id AS price_sighting_id + ${ + options.withTotalCount + ? Prisma.sql`, COUNT(*) OVER ()::integer AS total_count` + : Prisma.empty + } FROM venue v LEFT JOIN ratings ON ratings.venue_id = v.id LEFT JOIN matching_prices ON matching_prices.venue_id = v.id @@ -281,17 +334,34 @@ async function findFilteredVenues( `) } +/** Counts the same filtered set the venue query would return. */ +async function countFilteredVenues(filters: VenueFilters) { + const rows = await findFilteredVenues(filters, { + limit: 1, + withTotalCount: true, + }) + + return rows.at(0)?.total_count ?? 0 +} + builder.queryType({ fields: (t) => ({ venue: t.prismaField({ type: ['Venue'], args: { + cityId: t.arg.string({ + description: 'Only return venues in this city.', + }), lat: t.arg.float(), lon: t.arg.float(), limit: t.arg.int({ description: 'Return at most this many venues. Omit to return every venue.', }), + cursor: t.arg.string({ + description: + 'Return venues after this cursor, i.e. the id of the last venue from the previous page. Use the same filters and location as the previous page.', + }), maxDistanceKm: t.arg.float({ description: 'Only return venues within this distance in kilometres of lat/lon.', @@ -318,6 +388,7 @@ builder.queryType({ const rows = await findFilteredVenues( { term: null, + cityId: args.cityId ?? null, lat: args.lat ?? null, lon: args.lon ?? null, maxDistanceKm: args.maxDistanceKm ?? null, @@ -329,6 +400,7 @@ builder.queryType({ { orderByDistance: args.lat != null && args.lon != null, limit: args.limit, + cursor: args.cursor, }, ) @@ -349,6 +421,49 @@ builder.queryType({ return sorted }, }), + venueCount: t.field({ + type: 'Int', + args: { + cityId: t.arg.string({ + description: 'Only count venues in this city.', + }), + lat: t.arg.float(), + lon: t.arg.float(), + maxDistanceKm: t.arg.float({ + description: + 'Only count venues within this distance in kilometres of lat/lon.', + }), + minPrice: t.arg.float({ + description: + 'Only count venues with a latest regular price of at least this amount (NOK).', + }), + maxPrice: t.arg.float({ + description: + 'Only count venues with a latest regular price of at most this amount (NOK).', + }), + sizes: t.arg({ + type: [glassSizeRef], + description: + 'Only count venues with a latest regular price for at least one of these serving sizes.', + }), + minRating: t.arg.float({ + description: + 'Only count venues whose average review rating is at least this value.', + }), + }, + resolve: (_parent, args) => + countFilteredVenues({ + term: null, + cityId: args.cityId ?? null, + lat: args.lat ?? null, + lon: args.lon ?? null, + maxDistanceKm: args.maxDistanceKm ?? null, + minPrice: args.minPrice ?? null, + maxPrice: args.maxPrice ?? null, + sizes: args.sizes ?? [], + minRating: args.minRating ?? null, + }), + }), city: t.prismaField({ type: ['City'], args: { @@ -411,6 +526,7 @@ builder.queryType({ const [rows, cities] = await Promise.all([ findFilteredVenues({ term: args.term, + cityId: null, lat: args.lat ?? null, lon: args.lon ?? null, maxDistanceKm: args.maxDistanceKm ?? null, diff --git a/apps/backend/tests/e2e/api.spec.ts b/apps/backend/tests/e2e/api.spec.ts index 9165e06..2455eb9 100644 --- a/apps/backend/tests/e2e/api.spec.ts +++ b/apps/backend/tests/e2e/api.spec.ts @@ -114,6 +114,174 @@ test('venue query returns its related city, hours, prices, and reviews', async ( expect(seededVenue.reviews.length).toBeGreaterThan(0) }) +test('venue query paginates with a cursor without changing its sort order', async ({ + request, +}) => { + type VenuePage = { venue: { id: string; name: string }[] } + + const venuePage = ` + query Venues($limit: Int, $cursor: String, $lat: Float, $lon: Float) { + venue(limit: $limit, cursor: $cursor, lat: $lat, lon: $lon) { + id + name + } + } + ` + + const allByName = await query(request, venuePage) + const firstByName = await query(request, venuePage, { limit: 2 }) + const secondByName = await query(request, venuePage, { + limit: 2, + cursor: firstByName.venue.at(-1)?.id, + }) + + expect(allByName.venue.length).toBeGreaterThanOrEqual(4) + expect( + [...firstByName.venue, ...secondByName.venue].map(({ id }) => id), + ).toEqual(allByName.venue.slice(0, 4).map(({ id }) => id)) + + const location = { lat: 63.4305, lon: 10.3951 } + const allByDistance = await query(request, venuePage, location) + const firstByDistance = await query(request, venuePage, { + ...location, + limit: 2, + }) + const secondByDistance = await query(request, venuePage, { + ...location, + limit: 2, + cursor: firstByDistance.venue.at(-1)?.id, + }) + + expect( + [...firstByDistance.venue, ...secondByDistance.venue].map(({ id }) => id), + ).toEqual(allByDistance.venue.slice(0, 4).map(({ id }) => id)) +}) + +test('venue query filters and paginates within a city', async ({ request }) => { + const { city } = await query<{ city: { id: string; name: string }[] }>( + request, + ` + query { + city(name: "Trondheim") { + id + name + } + } + `, + ) + const trondheim = city.find(({ name }) => name === 'Trondheim') + + expect(trondheim).toBeDefined() + + if (!trondheim) { + throw new Error('Seeded city "Trondheim" was not returned') + } + + const { venue: firstPage } = await query<{ + venue: { id: string; city: { id: string } }[] + }>( + request, + ` + query TrondheimVenues($cityId: String!, $limit: Int, $cursor: String) { + venue(cityId: $cityId, limit: $limit, cursor: $cursor) { + id + city { + id + } + } + } + `, + { cityId: trondheim.id, limit: 2 }, + ) + + expect(firstPage).toHaveLength(2) + expect(firstPage.every((venue) => venue.city.id === trondheim.id)).toBe(true) + + const { venue: secondPage } = await query<{ + venue: { id: string; city: { id: string } }[] + }>( + request, + ` + query TrondheimVenues($cityId: String!, $limit: Int, $cursor: String) { + venue(cityId: $cityId, limit: $limit, cursor: $cursor) { + id + city { + id + } + } + } + `, + { cityId: trondheim.id, limit: 2, cursor: firstPage.at(-1)?.id }, + ) + + expect(secondPage.every((venue) => venue.city.id === trondheim.id)).toBe(true) + expect(secondPage.map(({ id }) => id)).not.toContain(firstPage[0]?.id) +}) + +test('venueCount returns the total number of venues for a city', async ({ + request, +}) => { + const { city } = await query<{ city: { id: string }[] }>( + request, + ` + query { + city(name: "Trondheim") { + id + } + } + `, + ) + const cityId = city[0]?.id + + expect(cityId).toBeDefined() + + if (!cityId) { + throw new Error('Seeded city "Trondheim" was not returned') + } + + const [{ venueCount }, { venue }] = await Promise.all([ + query<{ venueCount: number }>( + request, + ` + query VenueCount($cityId: String!) { + venueCount(cityId: $cityId) + } + `, + { cityId }, + ), + query<{ venue: { id: string }[] }>( + request, + ` + query Venues($cityId: String!) { + venue(cityId: $cityId) { + id + } + } + `, + { cityId }, + ), + ]) + + expect(venueCount).toBe(venue.length) +}) + +test('venue query rejects an unknown cursor', async ({ request }) => { + const response = await request.post('/', { + data: { + query: ` + query { + venue(cursor: "00000000-0000-0000-0000-000000000000") { + id + } + } + `, + }, + }) + const body: GraphQLResponse = await response.json() + + expect(body.errors?.[0]?.message).toBe('Venue cursor does not exist') +}) + test('createReview returns and persists a review for its venue', async ({ request, }) => { diff --git a/apps/frontend/src/graphql/__tests__/venues.test.ts b/apps/frontend/src/graphql/__tests__/venues.test.ts new file mode 100644 index 0000000..e6303f5 --- /dev/null +++ b/apps/frontend/src/graphql/__tests__/venues.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { makeVenue } from '#/test/fixtures' +import type { venue } from '#/types/venue' +import { graphqlFetch } from '../client' +import { + CITY_VENUES_PAGE_SIZE, + GET_CITY_VENUES, + cityVenuesQueryOptions, +} from '../venues' +import type { CityVenuesPage } from '../venues' + +vi.mock('../client', () => ({ + graphqlFetch: vi.fn(), +})) + +const mockedFetch = vi.mocked(graphqlFetch) + +function queryFn( + options: ReturnType, +): (context: { pageParam: string | null }) => Promise { + return options.queryFn as unknown as (context: { + pageParam: string | null + }) => Promise +} + +function nextPageParam( + options: ReturnType, +): (lastPage: { venue: venue[] }) => string | undefined { + return options.getNextPageParam as unknown as (lastPage: { + venue: venue[] + }) => string | undefined +} + +function makePage(count: number): CityVenuesPage { + return { + venue: Array.from({ length: count }, (_, index) => + makeVenue({ id: `venue-${index}`, name: `Venue ${index}` }), + ), + venueCount: count, + } +} + +describe('cityVenuesQueryOptions', () => { + beforeEach(() => { + mockedFetch.mockReset() + }) + + it('requests the first page without a cursor', async () => { + const options = cityVenuesQueryOptions('city-1') + const page = makePage(1) + + mockedFetch.mockResolvedValue(page) + + await expect(queryFn(options)({ pageParam: null })).resolves.toBe(page) + expect(mockedFetch).toHaveBeenCalledWith(GET_CITY_VENUES, { + cityId: 'city-1', + limit: CITY_VENUES_PAGE_SIZE, + cursor: null, + includeCount: true, + }) + }) + + it('does not request the total count for later pages', async () => { + const options = cityVenuesQueryOptions('city-1') + const page = makePage(1) + + mockedFetch.mockResolvedValue(page) + + await queryFn(options)({ pageParam: 'venue-9' }) + expect(mockedFetch).toHaveBeenCalledWith(GET_CITY_VENUES, { + cityId: 'city-1', + limit: CITY_VENUES_PAGE_SIZE, + cursor: 'venue-9', + includeCount: false, + }) + }) + + it('uses the last venue id as the cursor when a page is full', () => { + const options = cityVenuesQueryOptions('city-1') + const page = makePage(CITY_VENUES_PAGE_SIZE) + + expect(nextPageParam(options)(page)).toBe( + `venue-${CITY_VENUES_PAGE_SIZE - 1}`, + ) + }) + + it('stops when the final page is not full', () => { + const options = cityVenuesQueryOptions('city-1') + + expect(nextPageParam(options)(makePage(1))).toBeUndefined() + }) +}) diff --git a/apps/frontend/src/graphql/venues.ts b/apps/frontend/src/graphql/venues.ts index 1f33d51..99b06ff 100644 --- a/apps/frontend/src/graphql/venues.ts +++ b/apps/frontend/src/graphql/venues.ts @@ -1,3 +1,5 @@ +import { infiniteQueryOptions } from '@tanstack/react-query' + import type { venue } from '#/types/venue' import { graphqlFetch } from './client' @@ -52,10 +54,89 @@ export const GET_VENUE = ` } ` +export const CITY_VENUES_PAGE_SIZE = 10 + +export const GET_CITY_VENUES = ` + query CityVenues( + $cityId: String! + $limit: Int! + $cursor: String + $includeCount: Boolean! + ) { + venue(cityId: $cityId, limit: $limit, cursor: $cursor) { + id + name + address + lat + lon + city { + id + name + lat + lon + } + openingHours { + days + opening + closing + } + pricesSighted { + price + size + sizeMl + Limitations + timeStart + timeEnd + timeSighted + } + reviews { + id + username + body + rating + timeCreated + } + } + venueCount(cityId: $cityId) @include(if: $includeCount) + } +` + +export interface CityVenuesPage { + venue: venue[] + venueCount?: number +} + +/** + * Infinite, cursor-paginated venues for a single city. The backend returns + * venues sorted by name and uses the last venue id as the next cursor. The + * total count is only requested for the first page. + */ +export const cityVenuesQueryOptions = (cityId: string) => + infiniteQueryOptions({ + queryKey: ['cityVenues', cityId], + queryFn: ({ pageParam }) => + graphqlFetch(GET_CITY_VENUES, { + cityId, + limit: CITY_VENUES_PAGE_SIZE, + cursor: pageParam, + includeCount: pageParam === null, + }), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => { + const lastVenue = lastPage.venue.at(-1) + + if (!lastVenue || lastPage.venue.length < CITY_VENUES_PAGE_SIZE) { + return undefined + } + + return lastVenue.id + }, + staleTime: 5 * 60 * 1000, + }) + /** - * Shared by the city and venue routes. Both render from the same list, so the - * options live here to keep the cache key and staleness in sync; the route - * loaders prefetch it during SSR so the first paint already has the content. + * Shared by the venue detail route. The detail page needs to look up a single + * venue by id, so it still uses the unpaginated list. */ export const venuesQueryOptions = { queryKey: ['venues'], diff --git a/apps/frontend/src/routes/$cityId/venues/index.tsx b/apps/frontend/src/routes/$cityId/venues/index.tsx index 0424e92..c78b9ae 100644 --- a/apps/frontend/src/routes/$cityId/venues/index.tsx +++ b/apps/frontend/src/routes/$cityId/venues/index.tsx @@ -1,22 +1,62 @@ import type { ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useInfiniteQuery } from '@tanstack/react-query' import { Link, createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef } from 'react' import Breadcrumbs from '#/components/Breadcrumbs' import VenueCard from '#/components/VenueCard' -import { venuesQueryOptions } from '#/graphql/venues' +import { cityVenuesQueryOptions } from '#/graphql/venues' export const Route = createFileRoute('/$cityId/venues/')({ // Prefetch during SSR so the list is in the first paint. Rendering a loading // shell instead makes the page grow once the data lands, which shifts the // footer by a sixth of the viewport. - loader: ({ context }) => - context.queryClient.prefetchQuery(venuesQueryOptions), + loader: ({ context, params }) => + context.queryClient.prefetchInfiniteQuery( + cityVenuesQueryOptions(params.cityId), + ), component: CityVenuesPage, }) function CityVenuesPage() { const { cityId } = Route.useParams() - const { data, isPending, isError } = useQuery(venuesQueryOptions) + const { + data, + isPending, + isError, + isFetchNextPageError, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteQuery(cityVenuesQueryOptions(cityId)) + const sentinelRef = useRef(null) + + useEffect(() => { + const sentinel = sentinelRef.current + + if ( + !sentinel || + !hasNextPage || + typeof IntersectionObserver === 'undefined' + ) { + return + } + + const observer = new IntersectionObserver( + (entries) => { + if ( + entries.some((entry) => entry.isIntersecting) && + !isFetchingNextPage + ) { + void fetchNextPage() + } + }, + { rootMargin: '600px 0px' }, + ) + + observer.observe(sentinel) + + return () => observer.disconnect() + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) if (isPending) { return ( @@ -26,7 +66,7 @@ function CityVenuesPage() { ) } - if (isError) { + if (isError && !data) { return (

Could not load venues.

@@ -34,9 +74,8 @@ function CityVenuesPage() { ) } - const venues = data.venue - .filter((venue) => venue.city.id === cityId) - .sort((a, b) => a.name.localeCompare(b.name)) + const venues = data.pages.flatMap((page) => page.venue) + const totalVenues = data.pages[0]?.venueCount ?? venues.length const cityName = venues[0]?.city.name if (!cityName) { @@ -58,7 +97,9 @@ function CityVenuesPage() {

{cityName}

- {venues.length} {venues.length === 1 ? 'venue' : 'venues'} + {hasNextPage + ? `${venues.length} of ${totalVenues} venues loaded` + : `${totalVenues} ${totalVenues === 1 ? 'venue' : 'venues'}`}

@@ -66,6 +107,32 @@ function CityVenuesPage() { ))}
+ + {isFetchNextPageError && ( +

+ Could not load more venues. Please try again. +

+ )} + + {hasNextPage && ( +
+ + + )} + + {!hasNextPage && venues.length > 0 && ( +

+ You have reached the end of the list. +

+ )} ) } From b005edb0f83f76cdba69cd4cdefca2c7063d86b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?= Date: Mon, 9 Nov 2026 13:16:12 +0100 Subject: [PATCH 2/2] test: run backend e2e against dedicated server port --- apps/backend/playwright.config.ts | 9 +++++++-- apps/backend/src/index.ts | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/backend/playwright.config.ts b/apps/backend/playwright.config.ts index d618eb7..64966ec 100644 --- a/apps/backend/playwright.config.ts +++ b/apps/backend/playwright.config.ts @@ -1,8 +1,12 @@ import { defineConfig } from '@playwright/test' import { fileURLToPath } from 'node:url' +// Use a dedicated port for the test server. The self-hosted CI runner also +// hosts the deployed backend on 3001, and reusing it makes the tests run +// against stale code. +const testServerPort = Number(process.env.TEST_SERVER_PORT ?? 3002) const apiBaseURL = ( - process.env.API_BASE_URL ?? 'http://127.0.0.1:3001' + process.env.API_BASE_URL ?? `http://127.0.0.1:${testServerPort}` ).replace(/\/+$/, '') const managesLocalServer = process.env.API_BASE_URL === undefined @@ -20,8 +24,9 @@ export default defineConfig({ webServer: { command: 'pnpm run start', cwd: fileURLToPath(new URL('.', import.meta.url)), + env: { PORT: String(testServerPort) }, url: `${apiBaseURL}/?query=%7B__typename%7D`, - reuseExistingServer: true, + reuseExistingServer: !process.env.CI, timeout: 120_000, }, } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 1fa77c7..4b013ce 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -9,8 +9,10 @@ const server = new ApolloServer({ schema: builder.toSchema(), }) +const port = Number(process.env.PORT ?? 3001) + const { url } = await startStandaloneServer(server, { - listen: { port: 3001 }, + listen: { port }, context: async () => createContext(), })