Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# For a backend running locally through SSH tunnel, use 127.0.0.1:5433 instead.
# Never put real passwords or secrets in this file or commit server/.env.
DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@localhost:5432/tapfinder_dev
PORT=4000
PORT=3001
# Keep the API local until a reverse proxy (for example, Nginx) is configured.
HOST=127.0.0.1

Expand Down
7 changes: 7 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ Tests use fixed data and mocked hooks rather than external requests. The
snapshot protects the header's rendered HTML structure; it is not a pixel-level
visual regression test for CSS. Visual screenshot tests should use one fixed
browser environment to avoid differences between operating systems.

## GraphQL during local development

The frontend requests venue data from `/project2/api/graphql`. During local
development, Vite proxies that path to the backend on `127.0.0.1:3001`. Start
the backend separately before running `npm run dev`; no database credentials
are exposed to the browser.
7 changes: 7 additions & 0 deletions client/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -819,3 +819,10 @@ main {
margin-top: 0.3rem;
font-size: 0.8rem;
}

.site-footer .source-attribution {
margin-top: 0.75rem;
font-family: system-ui, sans-serif;
font-size: 0.75rem;
font-style: normal;
}
129 changes: 129 additions & 0 deletions client/src/api/places.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchAllPlaces, fetchPlaces } from './places'

const weekdays = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
]

function venue(overrides: Record<string, unknown> = {}) {
return {
id: '00000000-0000-0000-0000-000000000001',
name: 'Testpuben',
type: 'PUB',
area: 'Midtbyen',
address: 'Testgata 1',
latitude: 63.43,
longitude: 10.39,
rating: null,
reviewCount: 0,
description: null,
openingHours: weekdays.map((day) => ({
day,
opens: '16:00',
closes: '23:00',
closed: false,
})),
tags: ['pub'],
websiteUrl: null,
imageUrl: null,
...overrides,
}
}

afterEach(() => vi.unstubAllGlobals())

describe('GraphQL places API', () => {
it('requests one page and maps nullable backend fields for the UI', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
venues: { items: [venue()], totalCount: 51 },
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)

const page = await fetchPlaces(10, 10)

expect(page.totalCount).toBe(51)
expect(page.items[0]).toMatchObject({
name: 'Testpuben',
type: 'Pub',
rating: 0,
description: 'Ingen beskrivelse tilgjengelig.',
openingHours: '16:00–23:00',
websiteUrl: '',
})
expect(fetchMock).toHaveBeenCalledOnce()
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/project2/api/graphql')
expect(JSON.parse(options.body)).toMatchObject({
variables: { offset: 10, limit: 10 },
})
})

it('reports GraphQL errors to the existing loading and error states', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ errors: [{ message: 'Database nede' }] }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
)

await expect(fetchPlaces(0, 10)).rejects.toThrow('Database nede')
})

it('loads additional pages for views that currently need the full catalog', async () => {
const firstItems = Array.from({ length: 100 }, (_, index) =>
venue({ id: `venue-${index}`, name: `Sted ${index}` }),
)
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: { venues: { items: firstItems, totalCount: 101 } },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
venues: {
items: [venue({ id: 'venue-100', name: 'Siste sted' })],
totalCount: 101,
},
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)

const places = await fetchAllPlaces()

expect(places).toHaveLength(101)
expect(places.at(-1)?.name).toBe('Siste sted')
expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({
offset: 100,
limit: 100,
})
})
})
159 changes: 137 additions & 22 deletions client/src/api/places.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,148 @@
import { mockPlaces } from '../data/mockPlaces'
import type { Place, PlacesPage } from '../types/place'

/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */
const LATENCY_MS = 200

/**
* Henter en side med utesteder.
*
* Dette er det eneste stedet appen henter data fra. Når GraphQL-backenden er
* klar, byttes innholdet her ut med et kall mot serveren (med samme signatur),
* og resten av appen trenger ikke endres.
*/
import type { Place, PlacesPage, VenueType } from '../types/place'

const GRAPHQL_ENDPOINT = '/project2/api/graphql'
const MAX_PAGE_SIZE = 100

const VENUES_QUERY = `
query Venues($offset: Int!, $limit: Int!) {
venues(offset: $offset, limit: $limit) {
totalCount
items {
id
name
type
area
address
latitude
longitude
rating
reviewCount
description
openingHours {
day
opens
closes
closed
}
tags
websiteUrl
imageUrl
}
}
}
`

type GraphqlOpeningHours = {
day: string
opens: string | null
closes: string | null
closed: boolean
}

type GraphqlVenue = Omit<
Place,
'type' | 'openingHours' | 'rating' | 'description' | 'websiteUrl'
> & {
type: string
openingHours: GraphqlOpeningHours[]
rating: number | null
description: string | null
websiteUrl: string | null
}

type VenuesResponse = {
data?: {
venues: {
items: GraphqlVenue[]
totalCount: number
}
}
errors?: { message: string }[]
}

const weekdayNames = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
]

function formatOpeningHours(hours: GraphqlOpeningHours[], now = new Date()) {
const today = hours.filter(({ day }) => day === weekdayNames[now.getDay()])
if (!today.length) return 'Åpningstider ikke oppgitt'
if (today.every(({ closed }) => closed)) return 'Stengt'

const intervals = today.flatMap(({ opens, closes, closed }) =>
!closed && opens && closes ? [`${opens}–${closes}`] : [],
)
return intervals.length ? intervals.join(', ') : 'Åpningstider ikke oppgitt'
}

function toVenueType(type: string): VenueType {
const venueTypes: Record<string, VenueType> = {
BAR: 'Bar',
PUB: 'Pub',
NIGHTCLUB: 'Nattklubb',
}
const venueType = venueTypes[type]
if (!venueType) throw new Error(`Ukjent stedstype fra GraphQL: ${type}`)
return venueType
}

function toPlace(venue: GraphqlVenue): Place {
return {
...venue,
type: toVenueType(venue.type),
openingHours: formatOpeningHours(venue.openingHours),
rating: venue.rating ?? 0,
description: venue.description ?? 'Ingen beskrivelse tilgjengelig.',
websiteUrl: venue.websiteUrl ?? '',
}
}

/** Henter én avgrenset side med utesteder fra vår GraphQL-backend. */
export async function fetchPlaces(
offset: number,
limit: number,
): Promise<PlacesPage> {
await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))
const response = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: VENUES_QUERY,
variables: { offset, limit },
}),
})

if (!response.ok) {
throw new Error(`GraphQL svarte med HTTP ${response.status}.`)
}

const payload = (await response.json()) as VenuesResponse
if (payload.errors?.length) {
throw new Error(payload.errors[0].message)
}
if (!payload.data) throw new Error('GraphQL-responsen mangler data.')

return {
items: mockPlaces.slice(offset, offset + limit),
totalCount: mockPlaces.length,
items: payload.data.venues.items.map(toPlace),
totalCount: payload.data.venues.totalCount,
}
}

/** Så mange steder hentes når en side trenger hele listen (kart og hjul). */
const ALL_LIMIT = 500

/** Henter alle steder. Byttes ut med egne spørringer når backenden er klar. */
/** Henter alle sider til visninger som foreløpig trenger hele datasettet. */
export async function fetchAllPlaces(): Promise<Place[]> {
const page = await fetchPlaces(0, ALL_LIMIT)
return page.items
const firstPage = await fetchPlaces(0, MAX_PAGE_SIZE)
const places = [...firstPage.items]

while (places.length < firstPage.totalCount) {
const page = await fetchPlaces(places.length, MAX_PAGE_SIZE)
if (!page.items.length) break
places.push(...page.items)
}

return places
}
32 changes: 20 additions & 12 deletions client/src/components/FeaturedPlace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,28 @@ export function FeaturedPlace({

<header className="featured-header">
<h2 id="featured-name">{place.name}</h2>
<a
className="visit-link"
href={place.websiteUrl}
target="_blank"
rel="noopener noreferrer"
title="Besøk nettside"
aria-label={`Besøk nettsiden til ${place.name} (åpnes i ny fane)`}
>
<CompassIcon />
</a>
{place.websiteUrl && (
<a
className="visit-link"
href={place.websiteUrl}
target="_blank"
rel="noopener noreferrer"
title="Besøk nettside"
aria-label={`Besøk nettsiden til ${place.name} (åpnes i ny fane)`}
>
<CompassIcon />
</a>
)}
<p className="rating">
<span className="visually-hidden">Vurdering: </span>
<span aria-hidden="true">★</span> {place.rating.toFixed(1)}{' '}
<small>({place.reviewCount})</small>
{place.reviewCount > 0 ? (
<>
<span aria-hidden="true">★</span> {place.rating.toFixed(1)}{' '}
<small>({place.reviewCount})</small>
</>
) : (
'Ingen vurderinger'
)}
</p>
</header>

Expand Down
14 changes: 14 additions & 0 deletions client/src/components/PageFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export function PageFooter() {
</blockquote>
<figcaption>– {author}</figcaption>
</figure>
<p className="source-attribution">
Powered by{' '}
<a href="https://www.geoapify.com/" target="_blank" rel="noreferrer">
Geoapify
</a>{' '}
· ©{' '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noreferrer"
>
OpenStreetMap contributors
</a>
</p>
</footer>
)
}
Loading