diff --git a/AGENTS.md b/AGENTS.md
index 6e2887f..53c4160 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
- **Name:** UFOund it
- **What it is:** UFOund it, is a webapp that displays UFO sightings.
- **Dataset:** [NUFORC UFO Sightings (Kaggle)](https://www.kaggle.com/datasets/NUFORC/ufo-sightings), CSV, approx 80,000 reports, approx 15mb.
-- **Current state:** The frontend runs on hand-written mock data in `frontend/src/data/`. The backend is an Apollo Server with only a placeholder `health` query. The frontend reaches it through Apollo Client, but only for a temporary backend status check in the footer. No database yet.
+- **Current state:** The backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search page fetches sightings through Apollo Client, with search, sort and "load more" pagination done in the database. The detail page still looks sightings up in the hand-written mock data in `frontend/src/data/`. A temporary footer shows whether the backend is reachable.
- **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable.
## Tech stack
@@ -67,10 +67,10 @@ frontend/
components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap, BackendStatus)
pages/ Route-level components (SearchPage, SightingDetailPage, SubmitSightingPage)
hooks/ Custom hooks (debounce, sighting search, single sighting)
- utils/ Pure helpers (filtering, formatting)
+ utils/ Pure helpers (formatting)
types/ Shared TypeScript types
- data/ Mock sightings (to be replaced by GraphQL)
- graphql/ Apollo Client setup (client.ts), queries.ts and mutations.ts
+ data/ Mock sightings, still used by the detail page and as test fixtures
+ graphql/ Apollo Client setup and pagination cache rules (client.ts), queries.ts and mutations.ts
backend/ Node + TypeScript GraphQL server (Apollo Server 5)
src/
index.ts Starts the server on PORT (default 3001)
diff --git a/frontend/src/components/SightingList.css b/frontend/src/components/SightingList.css
index a5d59c1..77aa3de 100644
--- a/frontend/src/components/SightingList.css
+++ b/frontend/src/components/SightingList.css
@@ -26,6 +26,10 @@
list-style: none;
margin: 0;
padding: 0;
+
+ &[aria-busy='true'] {
+ opacity: 0.6;
+ }
}
.sighting-list__item {
@@ -116,11 +120,38 @@
max-width: 76ch;
}
-.sighting-results__empty {
+.sighting-results__message {
border-bottom: 1px solid var(--border);
padding: 32px 0;
}
+.sighting-results__more {
+ display: grid;
+ justify-items: center;
+ gap: 12px;
+ padding: 32px 0;
+}
+
+.sighting-results__button {
+ margin-top: 16px;
+ border: 0;
+ border-radius: 4px;
+ padding: 11px 18px;
+ color: var(--text-h);
+ background: var(--border);
+ font: inherit;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 3px solid var(--accent-border);
+ outline-offset: 2px;
+ }
+
+ &[aria-disabled='true'] {
+ cursor: progress;
+ }
+}
+
@media (max-width: 640px) {
.sighting-results {
margin-top: 32px;
diff --git a/frontend/src/components/SightingList.tsx b/frontend/src/components/SightingList.tsx
index 341112c..6ecd891 100644
--- a/frontend/src/components/SightingList.tsx
+++ b/frontend/src/components/SightingList.tsx
@@ -4,33 +4,78 @@ import {
formatDate,
formatDuration,
formatLocation,
+ formatShape,
} from '../utils/format-sighting'
import SightingMap from './SightingMap'
import './SightingList.css'
interface SightingListProps {
sightings: UfoSighting[]
+ /** Matches across all pages, not just the sightings shown. */
+ totalCount: number
+ loading: boolean
+ error: boolean
+ onRetry: () => void
+ hasNextPage: boolean
+ loadingMore: boolean
+ loadMoreFailed: boolean
+ onLoadMore: () => void
}
-function SightingList({ sightings }: SightingListProps) {
+function SightingList({
+ sightings,
+ totalCount,
+ loading,
+ error,
+ onRetry,
+ hasNextPage,
+ loadingMore,
+ loadMoreFailed,
+ onLoadMore,
+}: SightingListProps) {
const location = useLocation()
+ // Nothing to show yet, as opposed to refreshing results already shown.
+ const initialLoad = loading && sightings.length === 0
+
+ const total = `${totalCount.toLocaleString('en')} ${totalCount === 1 ? 'result' : 'results'}`
+ // One live region for both the count and paging progress, so screen
+ // readers hear "Showing 40 of …" after "load more" and nothing twice.
+ let summary =
+ sightings.length < totalCount
+ ? `Showing ${sightings.length.toLocaleString('en')} of ${total}`
+ : total
+ if (error) summary = ''
+ else if (initialLoad) summary = 'Loading…'
return (
Sightings
-
- {sightings.length} {sightings.length === 1 ? 'result' : 'results'}
-
+
{summary}
- {sightings.length === 0 ? (
-
+ {error ? (
+
+
Could not load sightings. Check your connection and try again.
+
+ Try again
+
+
+ ) : initialLoad ? (
+ Loading sightings…
+ ) : sightings.length === 0 ? (
+
No sightings match your search. Try another city, shape or
description.
) : (
-
+ // aria-busy tells screen readers the list is about to change, and
+ // dims it visually while a new search loads.
+
{sightings.map((sighting) => (
@@ -46,26 +91,47 @@ function SightingList({ sightings }: SightingListProps) {
{formatLocation(sighting)}
-
+
{formatDate(sighting.datetime)}
Shape
- {sighting.shape}
+ {formatShape(sighting.shape)}
Duration
{formatDuration(sighting.durationSeconds)}
- {sighting.comments}
+ {sighting.comments && {sighting.comments}
}
))}
)}
+
+ {!error && hasNextPage && (
+
+ {loadMoreFailed &&
Could not load more sightings.
}
+ {/* aria-disabled rather than disabled, so keyboard focus stays on
+ the button while the next page loads. The hook ignores clicks
+ in the meantime. */}
+
+ {loadingMore
+ ? 'Loading…'
+ : loadMoreFailed
+ ? 'Try again'
+ : 'Load more'}
+
+
+ )}
)
}
diff --git a/frontend/src/components/SightingMap.css b/frontend/src/components/SightingMap.css
index 8a3eb6f..49b21a2 100644
--- a/frontend/src/components/SightingMap.css
+++ b/frontend/src/components/SightingMap.css
@@ -7,3 +7,11 @@
height: 100%;
width: 100%;
}
+
+.sighting-map--empty {
+ display: grid;
+ place-items: center;
+ background: var(--code-bg);
+ color: var(--text);
+ font-size: 15px;
+}
diff --git a/frontend/src/components/SightingMap.tsx b/frontend/src/components/SightingMap.tsx
index 9af6281..029c18d 100644
--- a/frontend/src/components/SightingMap.tsx
+++ b/frontend/src/components/SightingMap.tsx
@@ -35,12 +35,23 @@ function SightingMap({
zoom = 10,
className,
}: SightingMapProps) {
+ const classNames = ['sighting-map', className].filter(Boolean).join(' ')
+
+ // Leaflet throws on a null position, so show a placeholder instead.
+ if (sighting.latitude === null || sighting.longitude === null) {
+ return (
+
+ )
+ }
+
const position: [number, number] = [sighting.latitude, sighting.longitude]
return (
= gql`
health
}
`
+
+export interface SightingsQueryData {
+ sightings: {
+ edges: { cursor: string; node: UfoSighting }[]
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
+ totalCount: number
+ }
+}
+
+export interface SightingsQueryVariables {
+ search?: string
+ sort?: SightingSortEnum
+ first?: number
+ /** The `endCursor` of the previous page, to fetch the next one. */
+ after?: string | null
+}
+
+/**
+ * One page of sightings matching the search, sorted on the server.
+ * `totalCount` covers all pages, so the UI can show the number of matches
+ * while only downloading the sightings it displays.
+ */
+export const SIGHTINGS_QUERY: TypedDocumentNode<
+ SightingsQueryData,
+ SightingsQueryVariables
+> = gql`
+ query Sightings(
+ $search: String
+ $sort: SightingSort
+ $first: Int
+ $after: String
+ ) {
+ sightings(search: $search, sort: $sort, first: $first, after: $after) {
+ edges {
+ cursor
+ node {
+ id
+ datetime
+ city
+ state
+ country
+ shape
+ durationSeconds
+ comments
+ datePosted
+ latitude
+ longitude
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ totalCount
+ }
+ }
+`
diff --git a/frontend/src/hooks/use-debounced-value.ts b/frontend/src/hooks/use-debounced-value.ts
index 0d83317..6b1e219 100644
--- a/frontend/src/hooks/use-debounced-value.ts
+++ b/frontend/src/hooks/use-debounced-value.ts
@@ -2,8 +2,7 @@ import { useEffect, useState } from 'react'
/**
* Returns `value` only after it has stopped changing for `delayMs`.
- * Used for search input so filtering (and later, network requests)
- * doesn't run on every keystroke.
+ * Used for search input so a request isn't sent on every keystroke.
*/
export function useDebouncedValue(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
diff --git a/frontend/src/hooks/use-sighting-search.ts b/frontend/src/hooks/use-sighting-search.ts
index 75fb3f6..e55a62d 100644
--- a/frontend/src/hooks/use-sighting-search.ts
+++ b/frontend/src/hooks/use-sighting-search.ts
@@ -1,23 +1,26 @@
-import { useMemo } from 'react'
+import { NetworkStatus } from '@apollo/client'
+import { useQuery } from '@apollo/client/react'
+import { useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'
+import { SIGHTINGS_QUERY } from '../graphql/queries'
import {
isSightingSortOption,
+ sightingSortEnum,
type SightingSortOption,
} from '../types/ufo-sighting'
-import { searchSightings } from '../utils/filter-sightings'
import { useDebouncedValue } from './use-debounced-value'
-import { useSightings } from './use-sightings'
const SEARCH_DEBOUNCE_MS = 300
const DEFAULT_SORT: SightingSortOption = 'date-desc'
+const PAGE_SIZE = 20
/**
- * Owns the search query and sort order and derives the matching sightings.
- * Both live in the URL (?q=...&sort=...) so they survive navigating to a
- * detail page and back, a page refresh, and can be shared as a link.
+ * Owns the search query and sort order and fetches the matching sightings
+ * from the backend, which searches and sorts the whole dataset. Both live
+ * in the URL (?q=...&sort=...) so they survive navigating to a detail page
+ * and back, a page refresh, and can be shared as a link.
*/
export function useSightingSearch() {
- const sightings = useSightings()
const [searchParams, setSearchParams] = useSearchParams()
const query = searchParams.get('q') ?? ''
@@ -41,12 +44,74 @@ export function useSightingSearch() {
const setSort = (nextSort: SightingSortOption) =>
updateParam('sort', nextSort, DEFAULT_SORT)
- const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS)
+ // Trimmed before debouncing so "oslo" and "oslo " share one request and
+ // one cache entry. Sort changes are deliberate clicks, so they apply now.
+ const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS)
+
+ const {
+ data,
+ previousData,
+ loading,
+ error,
+ refetch,
+ fetchMore,
+ networkStatus,
+ } = useQuery(SIGHTINGS_QUERY, {
+ variables: {
+ search: debouncedQuery || undefined,
+ sort: sightingSortEnum[sort],
+ first: PAGE_SIZE,
+ },
+ })
+
+ // While a new search loads, keep showing the previous results instead of
+ // emptying the list, so the page doesn't flicker on every search.
+ const shownData = data ?? previousData
const results = useMemo(
- () => searchSightings(sightings, debouncedQuery, sort),
- [debouncedQuery, sightings, sort],
+ () => shownData?.sightings.edges.map((edge) => edge.node) ?? [],
+ [shownData],
)
- return { query, setQuery, sort, setSort, results }
+ // A failed refetch is shown through `error`, so the rejected promise
+ // needs no handling of its own.
+ const retry = () => {
+ refetch().catch(() => {})
+ }
+
+ // `loading` is also true while "load more" runs. Telling the two apart
+ // keeps the list from dimming when only a page is being added.
+ const loadingMore = networkStatus === NetworkStatus.fetchMore
+
+ // Paging uses `data`, not `previousData`, so a click while a new search
+ // loads can't fetch page 2 of the old search.
+ const pageInfo = data?.sightings.pageInfo
+
+ // Remembers which page failed to load. Comparing it with the current
+ // end cursor hides the message once the search changes or a retry works.
+ const [failedCursor, setFailedCursor] = useState(null)
+
+ const loadMore = () => {
+ const after = pageInfo?.endCursor
+ if (!after || loadingMore) return
+ setFailedCursor(null)
+ fetchMore({ variables: { after } }).catch(() => setFailedCursor(after))
+ }
+
+ return {
+ query,
+ setQuery,
+ sort,
+ setSort,
+ results,
+ totalCount: shownData?.sightings.totalCount ?? 0,
+ loading: loading && !loadingMore,
+ error,
+ retry,
+ hasNextPage: pageInfo?.hasNextPage ?? false,
+ loadMore,
+ loadingMore,
+ loadMoreFailed:
+ failedCursor !== null && failedCursor === pageInfo?.endCursor,
+ }
}
diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx
index 450ca9a..4b1ae49 100644
--- a/frontend/src/pages/SearchPage.tsx
+++ b/frontend/src/pages/SearchPage.tsx
@@ -3,7 +3,21 @@ import SightingList from '../components/SightingList'
import { useSightingSearch } from '../hooks/use-sighting-search'
function SearchPage() {
- const { query, setQuery, sort, setSort, results } = useSightingSearch()
+ const {
+ query,
+ setQuery,
+ sort,
+ setSort,
+ results,
+ totalCount,
+ loading,
+ error,
+ retry,
+ hasNextPage,
+ loadMore,
+ loadingMore,
+ loadMoreFailed,
+ } = useSightingSearch()
return (
<>
@@ -13,7 +27,17 @@ function SearchPage() {
sort={sort}
onSortChange={setSort}
/>
-
+
>
)
}
diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
index d77c0a1..bca0d57 100644
--- a/frontend/src/pages/SightingDetailPage.tsx
+++ b/frontend/src/pages/SightingDetailPage.tsx
@@ -68,7 +68,7 @@ function SightingDetailPage() {
{backLink}
{title}
-
+
{formatDate(sighting.datetime)}
diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx
index 6d7799c..9d32c26 100644
--- a/frontend/src/tests/SearchPage.test.tsx
+++ b/frontend/src/tests/SearchPage.test.tsx
@@ -1,97 +1,211 @@
-import { act, fireEvent, render, screen } from '@testing-library/react'
+import { ApolloClient, ApolloLink } from '@apollo/client'
+import { ApolloProvider } from '@apollo/client/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { of, throwError } from 'rxjs'
+import { describe, expect, it, vi } from 'vitest'
+import { mockSightings } from '../data/mock-sightings'
+import { createCache } from '../graphql/client'
+import type { SightingsQueryVariables } from '../graphql/queries'
import SearchPage from '../pages/SearchPage'
vi.mock('../components/SightingMap', () => ({
default: () =>
,
}))
-function renderSearchPage(initialEntry = '/') {
- return render(
-
-
- ,
+/**
+ * Renders the page with a fake backend that records the variables of every
+ * request, so the tests can check what is sent and how often. The search
+ * and sorting themselves happen in the database, so they are not tested
+ * here.
+ *
+ * Like the real backend, the cursor is the offset of the next row. Pages
+ * hold 5 sightings regardless of `first`, so the 12 mock sightings span
+ * three pages. `failRequest` makes that request (1 = the first) fail like
+ * an unreachable backend.
+ */
+const FAKE_PAGE_SIZE = 5
+
+function renderSearchPage(
+ initialEntry = '/',
+ { failRequest }: { failRequest?: number } = {},
+) {
+ const requests: SightingsQueryVariables[] = []
+ const link = new ApolloLink((operation) => {
+ const variables: SightingsQueryVariables = operation.variables
+ requests.push(variables)
+ if (requests.length === failRequest) {
+ return throwError(() => new Error('Network error'))
+ }
+ const search = (variables.search ?? '').toLowerCase()
+ const matches = mockSightings.filter(
+ (sighting) => !search || sighting.city?.toLowerCase().includes(search),
+ )
+ const offset = Number(variables.after ?? 0)
+ const page = matches.slice(offset, offset + FAKE_PAGE_SIZE)
+ const edges = page.map((node, i) => ({
+ __typename: 'SightingEdge',
+ cursor: String(offset + i + 1),
+ node: { __typename: 'Sighting', ...node },
+ }))
+ return of({
+ data: {
+ sightings: {
+ __typename: 'SightingConnection',
+ edges,
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: offset + page.length < matches.length,
+ endCursor: edges.at(-1)?.cursor ?? null,
+ },
+ totalCount: matches.length,
+ },
+ },
+ })
+ })
+ const client = new ApolloClient({ link, cache: createCache() })
+
+ render(
+
+
+
+
+ ,
)
+ return requests
}
+const DEBOUNCE_MS = 300
+
describe('SearchPage', () => {
- afterEach(() => {
- vi.useRealTimers()
+ it('requests the search from the URL with the API sort name', async () => {
+ const requests = renderSearchPage('/?q=oslo')
+
+ expect(screen.getByRole('searchbox')).toHaveValue('oslo')
+ expect(
+ await screen.findByRole('link', { name: 'Oslo, NO' }),
+ ).toBeInTheDocument()
+ expect(requests).toEqual([{ search: 'oslo', sort: 'DATE_DESC', first: 20 }])
+ })
+
+ it('sends one request after typing stops, not one per keystroke', async () => {
+ const requests = renderSearchPage()
+ await waitFor(() => expect(requests).toHaveLength(1))
+
+ const searchbox = screen.getByRole('searchbox')
+ for (const value of ['p', 'ph', 'pho', 'phoenix']) {
+ fireEvent.change(searchbox, { target: { value } })
+ }
+
+ await waitFor(() => expect(requests).toHaveLength(2))
+ // Wait out another debounce period to prove no later request follows.
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS + 100))
+
+ expect(requests).toHaveLength(2)
+ expect(screen.getAllByRole('link')).toHaveLength(1)
+ expect(requests[1]).toEqual({
+ search: 'phoenix',
+ sort: 'DATE_DESC',
+ first: 20,
+ })
})
- it('loads a query from the URL and filters after the debounce delay', () => {
- vi.useFakeTimers()
- renderSearchPage('/?q=Oslo')
+ it('does not send a new request for trailing whitespace', async () => {
+ const requests = renderSearchPage('/?q=oslo')
+ await waitFor(() => expect(requests).toHaveLength(1))
- expect(screen.getByRole('searchbox')).toHaveValue('Oslo')
- expect(screen.getByText('1 result')).toBeInTheDocument()
+ fireEvent.change(screen.getByRole('searchbox'), {
+ target: { value: 'oslo ' },
+ })
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS + 100))
+
+ expect(requests).toHaveLength(1)
+ })
+
+ it('sends the API sort name when the sort changes', async () => {
+ const requests = renderSearchPage()
+ await waitFor(() => expect(requests).toHaveLength(1))
- act(() => vi.advanceTimersByTime(300))
+ fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
+ target: { value: 'city-asc' },
+ })
- expect(screen.getByText('1 result')).toBeInTheDocument()
- expect(screen.getByRole('link', { name: 'Oslo, NO' })).toBeInTheDocument()
+ await waitFor(() => expect(requests).toHaveLength(2))
+ expect(requests[1]).toEqual({ sort: 'CITY_ASC', first: 20 })
})
- it('updates displayed results when the search and sort controls change', () => {
- vi.useFakeTimers()
- renderSearchPage()
+ it('shows an error when the backend fails and loads again on retry', async () => {
+ const requests = renderSearchPage('/?q=oslo', { failRequest: 1 })
- expect(screen.getByRole('combobox', { name: 'Sort by' })).toHaveValue(
- 'date-desc',
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Could not load sightings',
)
- expect(screen.getAllByRole('link')[0]).toHaveTextContent('Bergen, NO')
- fireEvent.change(screen.getByRole('searchbox'), {
- target: { value: 'Phoenix' },
- })
- act(() => vi.advanceTimersByTime(300))
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(
- screen.getByRole('link', { name: 'Phoenix, AZ, US' }),
+ await screen.findByRole('link', { name: 'Oslo, NO' }),
).toBeInTheDocument()
- expect(screen.getByText('1 result')).toBeInTheDocument()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(requests).toHaveLength(2)
+ })
- fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
- act(() => vi.advanceTimersByTime(300))
- fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
- target: { value: 'city-asc' },
- })
+ it('appends the next page on "load more" until the last page', async () => {
+ const requests = renderSearchPage()
+ expect(
+ await screen.findByText('Showing 5 of 12 results'),
+ ).toBeInTheDocument()
+ expect(screen.getAllByRole('link')).toHaveLength(5)
- const locations = screen
- .getAllByRole('link')
- .map((link) => link.textContent)
- expect(locations[0]).toBe('Bergen, NO')
- expect(locations[1]).toBe('Edna, TX, US')
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(
+ await screen.findByText('Showing 10 of 12 results'),
+ ).toBeInTheDocument()
+ expect(screen.getAllByRole('link')).toHaveLength(10)
+ expect(requests[1]).toEqual({ sort: 'DATE_DESC', first: 20, after: '5' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(await screen.findByText('12 results')).toBeInTheDocument()
+ expect(screen.getAllByRole('link')).toHaveLength(12)
+ expect(
+ screen.queryByRole('button', { name: 'Load more' }),
+ ).not.toBeInTheDocument()
+ expect(requests).toHaveLength(3)
})
- it('sorts results oldest first', () => {
- vi.useFakeTimers()
- renderSearchPage()
+ it('keeps loaded pages per search and starts fresh for a new one', async () => {
+ const requests = renderSearchPage()
+ await screen.findByText('Showing 5 of 12 results')
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ await screen.findByText('Showing 10 of 12 results')
- fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
- target: { value: 'date-asc' },
- })
+ const searchbox = screen.getByRole('searchbox')
+ fireEvent.change(searchbox, { target: { value: 'oslo' } })
+ expect(await screen.findByText('1 result')).toBeInTheDocument()
+ expect(screen.getAllByRole('link')).toHaveLength(1)
- const locations = screen
- .getAllByRole('link')
- .map((link) => link.textContent)
- expect(locations[0]).toBe('Phoenix, AZ, US')
- expect(locations[1]).toBe('San Marcos, TX, US')
+ // Back to the empty search: both loaded pages come from the cache.
+ fireEvent.change(searchbox, { target: { value: '' } })
+ expect(
+ await screen.findByText('Showing 10 of 12 results'),
+ ).toBeInTheDocument()
+ expect(requests).toHaveLength(3)
})
- it('sorts results newest first', () => {
- vi.useFakeTimers()
- renderSearchPage()
+ it('keeps loaded results and offers a retry when the next page fails', async () => {
+ renderSearchPage('/', { failRequest: 2 })
+ await screen.findByText('Showing 5 of 12 results')
- fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
- target: { value: 'date-desc' },
- })
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Could not load more sightings',
+ )
+ expect(screen.getAllByRole('link')).toHaveLength(5)
- const locations = screen
- .getAllByRole('link')
- .map((link) => link.textContent)
- expect(locations[0]).toBe('Bergen, NO')
- expect(locations[1]).toBe('Tromsø, NO')
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+ expect(
+ await screen.findByText('Showing 10 of 12 results'),
+ ).toBeInTheDocument()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
diff --git a/frontend/src/tests/SightingList.test.tsx b/frontend/src/tests/SightingList.test.tsx
index 9db7c21..dc64f9d 100644
--- a/frontend/src/tests/SightingList.test.tsx
+++ b/frontend/src/tests/SightingList.test.tsx
@@ -1,4 +1,5 @@
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import type { ComponentProps } from 'react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
import SightingList from '../components/SightingList'
@@ -8,13 +9,35 @@ vi.mock('../components/SightingMap', () => ({
default: () =>
,
}))
+type Props = ComponentProps
+
+// Defaults to loaded results, so each test only states what it changes.
+function renderList(props: Partial = {}) {
+ const sightings = props.sightings ?? [mockSightings[0]]
+ const onRetry = vi.fn()
+ const onLoadMore = vi.fn()
+ render(
+
+
+ ,
+ )
+ return { onRetry, onLoadMore }
+}
+
describe('SightingList', () => {
it('renders sightings, formatted fields, and detail links', () => {
- render(
-
-
- ,
- )
+ renderList()
expect(screen.getByText('1 result')).toBeInTheDocument()
expect(
@@ -22,20 +45,113 @@ describe('SightingList', () => {
).toHaveAttribute('href', '/sightings/1')
expect(screen.getByText('cylinder')).toBeInTheDocument()
expect(screen.getByText('45 min')).toBeInTheDocument()
- expect(screen.getByText(mockSightings[0].comments)).toBeInTheDocument()
+ expect(
+ screen.getByText(/occurred after a Boy Scout meeting/),
+ ).toBeInTheDocument()
expect(screen.getByTestId('sighting-map')).toBeInTheDocument()
})
- it('uses plural result text and explains when no sightings match', () => {
- render(
-
-
- ,
+ it('shows how many of the total matches are loaded', () => {
+ renderList({ totalCount: 80328 })
+
+ expect(screen.getByText('Showing 1 of 80,328 results')).toBeInTheDocument()
+ })
+
+ it('shows a load more button only when there is another page', () => {
+ const { onLoadMore } = renderList({ hasNextPage: true, totalCount: 40 })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(onLoadMore).toHaveBeenCalledOnce()
+ })
+
+ it('hides the load more button on the last page', () => {
+ renderList({ hasNextPage: false })
+
+ expect(
+ screen.queryByRole('button', { name: 'Load more' }),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps the button focusable but marked busy while the next page loads', () => {
+ renderList({ hasNextPage: true, loadingMore: true, totalCount: 40 })
+
+ const button = screen.getByRole('button', { name: 'Loading…' })
+ expect(button).toHaveAttribute('aria-disabled', 'true')
+ expect(button).not.toBeDisabled()
+ expect(screen.getByRole('list')).not.toHaveAttribute('aria-busy', 'true')
+ })
+
+ it('offers to try again when the next page fails', () => {
+ renderList({ hasNextPage: true, loadMoreFailed: true, totalCount: 40 })
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'Could not load more sightings',
)
+ expect(
+ screen.getByRole('button', { name: 'Try again' }),
+ ).toBeInTheDocument()
+ })
+
+ it('shows fallbacks for fields the API returns as null', () => {
+ renderList({
+ sightings: [
+ {
+ ...mockSightings[0],
+ datetime: null,
+ city: null,
+ state: null,
+ country: null,
+ shape: null,
+ durationSeconds: null,
+ comments: null,
+ },
+ ],
+ })
+
+ expect(
+ screen.getByRole('link', { name: 'Unknown location' }),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Unknown date')).toBeInTheDocument()
+ expect(screen.getAllByText('Unknown')).toHaveLength(2)
+ })
+
+ it('uses plural result text and explains when no sightings match', () => {
+ renderList({ sightings: [] })
expect(screen.getByText('0 results')).toBeInTheDocument()
expect(
screen.getByText(/No sightings match your search/),
).toBeInTheDocument()
})
+
+ it('shows a loading message before the first results arrive', () => {
+ renderList({ sightings: [], loading: true })
+
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ expect(screen.getByText('Loading sightings…')).toBeInTheDocument()
+ expect(
+ screen.queryByText(/No sightings match your search/),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps the previous results visible but busy while a new search loads', () => {
+ renderList({ loading: true })
+
+ expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true')
+ expect(
+ screen.getByRole('link', { name: 'San Marcos, TX, US' }),
+ ).toBeInTheDocument()
+ })
+
+ it('shows an error with a retry button instead of the results', () => {
+ const { onRetry } = renderList({ error: true })
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'Could not load sightings',
+ )
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+ expect(onRetry).toHaveBeenCalledOnce()
+ })
})
diff --git a/frontend/src/tests/SightingMap.test.tsx b/frontend/src/tests/SightingMap.test.tsx
index 1855544..e502d86 100644
--- a/frontend/src/tests/SightingMap.test.tsx
+++ b/frontend/src/tests/SightingMap.test.tsx
@@ -58,7 +58,9 @@ describe('SightingMap', () => {
const sighting = mockSightings[0]
render( )
- expect(screen.getByLabelText('Map showing San Marcos')).toBeInTheDocument()
+ expect(
+ screen.getByLabelText('Map showing San Marcos, TX, US'),
+ ).toBeInTheDocument()
expect(screen.getByTestId('map-container')).toHaveAttribute(
'data-center',
`${sighting.latitude},${sighting.longitude}`,
@@ -82,6 +84,17 @@ describe('SightingMap', () => {
expect(screen.getByText('San Marcos, TX, US')).toBeInTheDocument()
})
+ it('shows a placeholder instead of a map when coordinates are missing', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('No map location')).toBeInTheDocument()
+ expect(screen.queryByTestId('map-container')).not.toBeInTheDocument()
+ })
+
it('enables map controls when interactive', () => {
render( )
diff --git a/frontend/src/tests/filter-sightings.test.ts b/frontend/src/tests/filter-sightings.test.ts
deleted file mode 100644
index 5bb71a7..0000000
--- a/frontend/src/tests/filter-sightings.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { SightingSortOption, UfoSighting } from '../types/ufo-sighting'
-import {
- filterSightings,
- searchSightings,
- sortSightings,
-} from '../utils/filter-sightings'
-
-const sightings: UfoSighting[] = [
- {
- id: 'b',
- datetime: '2002-01-01T00:00:00',
- city: 'Oslo',
- state: '',
- country: 'no',
- shape: 'light',
- durationSeconds: 30,
- comments: 'A silent glow',
- datePosted: '2002-01-02',
- latitude: 59.9,
- longitude: 10.7,
- },
- {
- id: 'a',
- datetime: '2001-01-01T00:00:00',
- city: 'Bergen',
- state: '',
- country: 'no',
- shape: 'triangle',
- durationSeconds: 60,
- comments: 'Three lights',
- datePosted: '2001-01-02',
- latitude: 60.4,
- longitude: 5.3,
- },
-]
-
-describe('sighting filtering and sorting', () => {
- it('matches city, shape, and comments case-insensitively', () => {
- expect(filterSightings(sightings, 'OSLO').map(({ id }) => id)).toEqual([
- 'b',
- ])
- expect(filterSightings(sightings, 'TRIANGLE').map(({ id }) => id)).toEqual([
- 'a',
- ])
- expect(
- filterSightings(sightings, 'silent glow').map(({ id }) => id),
- ).toEqual(['b'])
- })
-
- it('treats a whitespace-only query as empty', () => {
- expect(filterSightings(sightings, ' ')).toBe(sightings)
- })
-
- it.each([
- ['date-desc', ['b', 'a']],
- ['date-asc', ['a', 'b']],
- ['city-asc', ['a', 'b']],
- ['shape-asc', ['b', 'a']],
- ] as [SightingSortOption, string[]][])(
- 'sorts by %s without mutating input',
- (sort, ids) => {
- const original = [...sightings]
- expect(sortSightings(sightings, sort).map(({ id }) => id)).toEqual(ids)
- expect(sightings).toEqual(original)
- },
- )
-
- it('filters before sorting in the combined search helper', () => {
- expect(
- searchSightings(sightings, 'lights', 'city-asc').map(({ id }) => id),
- ).toEqual(['a'])
- })
-})
diff --git a/frontend/src/tests/format-sighting.test.ts b/frontend/src/tests/format-sighting.test.ts
index e1771e0..10bf2a7 100644
--- a/frontend/src/tests/format-sighting.test.ts
+++ b/frontend/src/tests/format-sighting.test.ts
@@ -4,6 +4,7 @@ import {
formatDate,
formatDuration,
formatLocation,
+ formatShape,
} from '../utils/format-sighting'
const sighting: UfoSighting = {
@@ -33,6 +34,18 @@ describe('sighting formatters', () => {
expect(formatLocation({ ...sighting, state: '', country: '' })).toBe('Oslo')
})
+ it('falls back to readable text when the API returns null', () => {
+ expect(formatDate(null)).toBe('Unknown date')
+ expect(formatDuration(null)).toBe('Unknown')
+ expect(formatShape(null)).toBe('Unknown')
+ expect(formatLocation({ ...sighting, city: null })).toBe(
+ 'Unknown location, OS, NO',
+ )
+ expect(
+ formatLocation({ ...sighting, city: null, state: null, country: null }),
+ ).toBe('Unknown location')
+ })
+
it('formats dates using the English medium date style', () => {
expect(formatDate('2000-01-02T12:00:00')).toBe(
new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(
diff --git a/frontend/src/types/ufo-sighting.ts b/frontend/src/types/ufo-sighting.ts
index 3689dc3..eace27d 100644
--- a/frontend/src/types/ufo-sighting.ts
+++ b/frontend/src/types/ufo-sighting.ts
@@ -1,25 +1,25 @@
/**
- * A single UFO sighting report. Fields mirror the columns of the NUFORC
- * sightings CSV so the same shape can be reused once data comes from the
- * GraphQL backend instead of static mock data.
+ * A single UFO sighting report, matching `Sighting` in the GraphQL schema.
+ * Every field but `id` can be null because imported NUFORC rows are
+ * incomplete, e.g. about 1,900 have no shape.
*/
export interface UfoSighting {
id: string
- /** ISO 8601 timestamp of when the sighting happened. */
- datetime: string
- city: string
- /** State or region code, empty when the report has none. */
- state: string
- /** Country code, empty when the report has none. */
- country: string
- shape: string
- durationSeconds: number
+ /** Local time where it was seen, no timezone, e.g. 2005-01-01T00:30:00. */
+ datetime: string | null
+ city: string | null
+ /** State or region code. */
+ state: string | null
+ /** Country code. */
+ country: string | null
+ shape: string | null
+ durationSeconds: number | null
/** Free-text description written by the observer. */
- comments: string
- /** ISO 8601 date of when the report was posted. */
- datePosted: string
- latitude: number
- longitude: number
+ comments: string | null
+ /** When the report was posted, same format as `datetime`. */
+ datePosted: string | null
+ latitude: number | null
+ longitude: number | null
}
// Kept as a runtime list so values read from the URL can be validated.
@@ -37,3 +37,16 @@ export function isSightingSortOption(
): value is SightingSortOption {
return sightingSortOptions.some((option) => option === value)
}
+
+/** The `SightingSort` enum values in the GraphQL schema. */
+export type SightingSortEnum =
+ 'DATE_DESC' | 'DATE_ASC' | 'CITY_ASC' | 'SHAPE_ASC'
+
+// The URL keeps the readable lowercase names. A Record makes TypeScript
+// fail if a sort option is added without an API value.
+export const sightingSortEnum: Record = {
+ 'date-desc': 'DATE_DESC',
+ 'date-asc': 'DATE_ASC',
+ 'city-asc': 'CITY_ASC',
+ 'shape-asc': 'SHAPE_ASC',
+}
diff --git a/frontend/src/utils/filter-sightings.ts b/frontend/src/utils/filter-sightings.ts
deleted file mode 100644
index c59e2b9..0000000
--- a/frontend/src/utils/filter-sightings.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { SightingSortOption, UfoSighting } from '../types/ufo-sighting'
-
-/**
- * Keeps sightings whose city, shape or comment contains the query,
- * case-insensitively. An empty query matches everything.
- */
-export function filterSightings(
- sightings: UfoSighting[],
- query: string,
-): UfoSighting[] {
- const normalizedQuery = query.trim().toLowerCase()
- if (normalizedQuery === '') return sightings
-
- return sightings.filter((sighting) =>
- [sighting.city, sighting.shape, sighting.comments].some((field) =>
- field.toLowerCase().includes(normalizedQuery),
- ),
- )
-}
-
-const comparators: Record<
- SightingSortOption,
- (a: UfoSighting, b: UfoSighting) => number
-> = {
- // ISO 8601 strings sort chronologically when compared as strings.
- 'date-desc': (a, b) => b.datetime.localeCompare(a.datetime),
- 'date-asc': (a, b) => a.datetime.localeCompare(b.datetime),
- 'city-asc': (a, b) => a.city.localeCompare(b.city),
- 'shape-asc': (a, b) => a.shape.localeCompare(b.shape),
-}
-
-/** Returns a sorted copy so the caller's array is never mutated. */
-export function sortSightings(
- sightings: UfoSighting[],
- sort: SightingSortOption,
-): UfoSighting[] {
- return [...sightings].sort(comparators[sort])
-}
-
-export function searchSightings(
- sightings: UfoSighting[],
- query: string,
- sort: SightingSortOption,
-): UfoSighting[] {
- return sortSightings(filterSightings(sightings, query), sort)
-}
diff --git a/frontend/src/utils/format-sighting.ts b/frontend/src/utils/format-sighting.ts
index db18eec..c5a09d9 100644
--- a/frontend/src/utils/format-sighting.ts
+++ b/frontend/src/utils/format-sighting.ts
@@ -1,12 +1,15 @@
import type { UfoSighting } from '../types/ufo-sighting'
-export function formatDate(date: string) {
+export function formatDate(date: string | null) {
+ if (!date) return 'Unknown date'
+
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
}).format(new Date(date))
}
-export function formatDuration(seconds: number) {
+export function formatDuration(seconds: number | null) {
+ if (seconds === null) return 'Unknown'
if (seconds < 60) return `${seconds} sec`
const minutes = Math.floor(seconds / 60)
@@ -17,10 +20,15 @@ export function formatDuration(seconds: number) {
}
export function formatLocation(sighting: UfoSighting) {
+ const city = sighting.city ?? 'Unknown location'
const region = [sighting.state, sighting.country]
.filter(Boolean)
.join(', ')
.toUpperCase()
- return region ? `${sighting.city}, ${region}` : sighting.city
+ return region ? `${city}, ${region}` : city
+}
+
+export function formatShape(shape: string | null) {
+ return shape ?? 'Unknown'
}