From 7dd3af296e12dc0f482630ca1d7ac2a246bdf0a6 Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Mon, 12 Oct 2026 13:05:58 +0200
Subject: [PATCH 01/11] Feat: Fetch sighting details from the backend
The detail page now queries the sighting by id instead of reading
the mock data. Part of #35
---
frontend/src/graphql/queries.ts | 27 +++++++
frontend/src/hooks/use-sighting.ts | 23 ++++--
frontend/src/pages/SightingDetailPage.tsx | 21 +++++-
.../src/tests/SightingDetailPage.test.tsx | 74 +++++++++++++++----
frontend/src/tests/hooks.test.tsx | 15 ----
5 files changed, 121 insertions(+), 39 deletions(-)
diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts
index 74b4121..3d39a4a 100644
--- a/frontend/src/graphql/queries.ts
+++ b/frontend/src/graphql/queries.ts
@@ -71,3 +71,30 @@ export const SIGHTINGS_QUERY: TypedDocumentNode<
}
}
`
+
+interface SightingQueryData {
+ /** Null if no sighting has the id. */
+ sighting: UfoSighting | null
+}
+
+/** A single sighting by id, for the detail page. */
+export const SIGHTING_QUERY: TypedDocumentNode<
+ SightingQueryData,
+ { id: string }
+> = gql`
+ query Sighting($id: ID!) {
+ sighting(id: $id) {
+ id
+ datetime
+ city
+ state
+ country
+ shape
+ durationSeconds
+ comments
+ datePosted
+ latitude
+ longitude
+ }
+ }
+`
diff --git a/frontend/src/hooks/use-sighting.ts b/frontend/src/hooks/use-sighting.ts
index 16d5fd2..6f83ab8 100644
--- a/frontend/src/hooks/use-sighting.ts
+++ b/frontend/src/hooks/use-sighting.ts
@@ -1,11 +1,20 @@
-import type { UfoSighting } from '../types/ufo-sighting'
-import { useSightings } from './use-sightings'
+import { useQuery } from '@apollo/client/react'
+import { SIGHTING_QUERY } from '../graphql/queries'
/**
- * Looks up a single sighting by id. Wrapped in a hook so the detail page
- * won't need to change when this becomes a GraphQL query.
+ * Fetches one sighting. `sighting` is null once the load has finished and
+ * the backend has no sighting with that id, which is not an `error`.
*/
-export function useSighting(id: string | undefined): UfoSighting | undefined {
- const sightings = useSightings()
- return sightings.find((sighting) => sighting.id === id)
+export function useSighting(id: string) {
+ const { data, loading, error, refetch } = useQuery(SIGHTING_QUERY, {
+ variables: { id },
+ })
+
+ // A failed refetch is shown through `error`, so the rejected promise
+ // needs no handling of its own.
+ const retry = () => {
+ refetch().catch(() => {})
+ }
+
+ return { sighting: data?.sighting ?? null, loading, error, retry }
}
diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
index bca0d57..a69a9db 100644
--- a/frontend/src/pages/SightingDetailPage.tsx
+++ b/frontend/src/pages/SightingDetailPage.tsx
@@ -27,11 +27,15 @@ function getFromSearch(state: unknown): string {
}
function SightingDetailPage() {
- const { id } = useParams()
- const sighting = useSighting(id)
+ const { id = '' } = useParams()
+ const { sighting, loading, error, retry } = useSighting(id)
const location = useLocation()
const backTo = { pathname: '/', search: getFromSearch(location.state) }
- const title = sighting ? formatLocation(sighting) : 'Sighting not found'
+
+ let title = 'Sighting not found'
+ if (sighting) title = formatLocation(sighting)
+ else if (error) title = 'Could not load sighting'
+ else if (loading) title = 'Loading sighting…'
// Route changes keep the list's scroll position, which would open the
// detail page scrolled past its heading.
@@ -58,7 +62,16 @@ function SightingDetailPage() {
{backLink}
{title}
-
There is no sighting with id “{id}”.
+ {error ? (
+
+
Check your connection and try again.
+
+
+ ) : (
+ !loading &&
There is no sighting with id “{id}”.
+ )}
)
}
diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx
index 8cffd01..f6da6f9 100644
--- a/frontend/src/tests/SightingDetailPage.test.tsx
+++ b/frontend/src/tests/SightingDetailPage.test.tsx
@@ -1,7 +1,13 @@
-import { render, screen } from '@testing-library/react'
+import {
+ MockedProvider,
+ type MockedProviderProps,
+} from '@apollo/client/testing/react'
+import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SIGHTING_QUERY } from '../graphql/queries'
import SightingDetailPage from '../pages/SightingDetailPage'
+import { mockSightings } from './mock-sightings'
vi.mock('../components/SightingMap', () => ({
default: ({
@@ -19,13 +25,26 @@ vi.mock('../components/SightingMap', () => ({
),
}))
-function renderDetailPage(path: string, state?: unknown) {
+const found = {
+ request: { query: SIGHTING_QUERY, variables: { id: '1' } },
+ result: {
+ data: { sighting: { __typename: 'Sighting', ...mockSightings[0] } },
+ },
+}
+
+function renderDetailPage(
+ path: string,
+ mocks: MockedProviderProps['mocks'],
+ state?: unknown,
+) {
return render(
-
-
- } />
-
- ,
+
+
+
+ } />
+
+
+ ,
)
}
@@ -34,12 +53,15 @@ describe('SightingDetailPage', () => {
vi.restoreAllMocks()
})
- it('renders sighting details and preserves the originating search link', () => {
+ it('renders sighting details and preserves the originating search link', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
- renderDetailPage('/sightings/1', { fromSearch: '?q=San+Marcos' })
+ renderDetailPage('/sightings/1', [found], { fromSearch: '?q=San+Marcos' })
expect(
- screen.getByRole('heading', { name: 'San Marcos, TX, US' }),
+ screen.getByRole('heading', { name: 'Loading sighting…' }),
+ ).toBeInTheDocument()
+ expect(
+ await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
expect(screen.getByText('45 min')).toBeInTheDocument()
expect(screen.getByText(/This event took place/)).toBeInTheDocument()
@@ -54,12 +76,17 @@ describe('SightingDetailPage', () => {
expect(document.title).toBe('San Marcos, TX, US · UFOund it')
})
- it('handles missing sightings and links back to the unfiltered list', () => {
+ it('handles unknown ids and links back to the unfiltered list', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
- renderDetailPage('/sightings/missing')
+ renderDetailPage('/sightings/missing', [
+ {
+ request: { query: SIGHTING_QUERY, variables: { id: 'missing' } },
+ result: { data: { sighting: null } },
+ },
+ ])
expect(
- screen.getByRole('heading', { name: 'Sighting not found' }),
+ await screen.findByRole('heading', { name: 'Sighting not found' }),
).toBeInTheDocument()
expect(
screen.getByText('There is no sighting with id “missing”.'),
@@ -70,4 +97,25 @@ describe('SightingDetailPage', () => {
expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument()
expect(document.title).toBe('Sighting not found · UFOund it')
})
+
+ it('shows an error and lets the user retry', async () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ renderDetailPage('/sightings/1', [
+ { ...found, error: new Error('Network down') },
+ found,
+ ])
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Check your connection',
+ )
+ expect(
+ screen.getByRole('heading', { name: 'Could not load sighting' }),
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+
+ expect(
+ await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
+ ).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/tests/hooks.test.tsx b/frontend/src/tests/hooks.test.tsx
index 551b733..5998124 100644
--- a/frontend/src/tests/hooks.test.tsx
+++ b/frontend/src/tests/hooks.test.tsx
@@ -1,8 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { mockSightings } from '../data/mock-sightings'
import { useDebouncedValue } from '../hooks/use-debounced-value'
-import { useSighting } from '../hooks/use-sighting'
describe('sighting hooks', () => {
afterEach(() => {
@@ -23,17 +21,4 @@ describe('sighting hooks', () => {
act(() => vi.advanceTimersByTime(1))
expect(result.current).toBe('second')
})
-
- it('looks up an existing sighting and returns undefined for an unknown id', () => {
- const { result, rerender } = renderHook(({ id }) => useSighting(id), {
- initialProps: { id: mockSightings[0].id as string | undefined },
- })
- expect(result.current).toEqual(mockSightings[0])
-
- rerender({ id: 'not-a-sighting' })
- expect(result.current).toBeUndefined()
-
- rerender({ id: undefined })
- expect(result.current).toBeUndefined()
- })
})
From 05bb1588325c0d82b409c932656b7e709775d440 Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Mon, 12 Oct 2026 13:07:40 +0200
Subject: [PATCH 02/11] Refactor: Remove the mock sighting store
Nothing reads from the store now that the detail page uses GraphQL,
so SubmitSightingPage no longer adds sightings to it. The mock
sightings move to tests/ because the tests still use them as
fixtures.
---
frontend/src/data/sighting-store.ts | 24 -------------------
frontend/src/hooks/use-sightings.ts | 6 -----
frontend/src/pages/SubmitSightingPage.tsx | 21 ----------------
frontend/src/tests/SearchPage.test.tsx | 2 +-
frontend/src/tests/SightingList.test.tsx | 2 +-
frontend/src/tests/SightingMap.test.tsx | 2 +-
.../src/tests/SubmitSightingPage.test.tsx | 2 --
.../src/{data => tests}/mock-sightings.ts | 6 +----
8 files changed, 4 insertions(+), 61 deletions(-)
delete mode 100644 frontend/src/data/sighting-store.ts
delete mode 100644 frontend/src/hooks/use-sightings.ts
rename frontend/src/{data => tests}/mock-sightings.ts (95%)
diff --git a/frontend/src/data/sighting-store.ts b/frontend/src/data/sighting-store.ts
deleted file mode 100644
index 7b8c35b..0000000
--- a/frontend/src/data/sighting-store.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { mockSightings } from './mock-sightings'
-import type { UfoSighting } from '../types/ufo-sighting'
-
-let sightings = [...mockSightings]
-const listeners = new Set<() => void>()
-
-export function getSightings(): UfoSighting[] {
- return sightings
-}
-
-export function subscribeToSightings(listener: () => void): () => void {
- listeners.add(listener)
- return () => listeners.delete(listener)
-}
-
-/**
- * Adds a sighting the backend has already stored. The list and detail
- * pages still read from this store, so without it a new sighting would
- * not show up until those pages fetch from GraphQL.
- */
-export function addSighting(sighting: UfoSighting) {
- sightings = [sighting, ...sightings]
- listeners.forEach((listener) => listener())
-}
diff --git a/frontend/src/hooks/use-sightings.ts b/frontend/src/hooks/use-sightings.ts
deleted file mode 100644
index 96a532c..0000000
--- a/frontend/src/hooks/use-sightings.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useSyncExternalStore } from 'react'
-import { getSightings, subscribeToSightings } from '../data/sighting-store'
-
-export function useSightings() {
- return useSyncExternalStore(subscribeToSightings, getSightings, getSightings)
-}
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 920dfe8..f9b3f0b 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -2,10 +2,8 @@ import { CombinedGraphQLErrors } from '@apollo/client'
import { useMutation } from '@apollo/client/react'
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router'
-import { addSighting } from '../data/sighting-store'
import {
CREATE_SIGHTING_MUTATION,
- type CreatedSighting,
type SightingFieldError,
} from '../graphql/mutations'
import LocationPicker from '../components/LocationPicker'
@@ -91,24 +89,6 @@ function serverFieldErrors(error: unknown): FormErrors | null {
return errors
}
-// The API allows nulls for imported rows, but a submitted sighting always
-// has every field except state, so the fallbacks are only for type safety.
-function toUfoSighting(created: CreatedSighting) {
- return {
- id: created.id,
- datetime: created.datetime ?? '',
- city: created.city ?? '',
- state: created.state ?? '',
- country: created.country ?? '',
- shape: created.shape ?? '',
- durationSeconds: created.durationSeconds ?? 0,
- comments: created.comments ?? '',
- datePosted: created.datePosted ?? '',
- latitude: created.latitude ?? 0,
- longitude: created.longitude ?? 0,
- }
-}
-
function SubmitSightingPage() {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState({})
@@ -152,7 +132,6 @@ function SubmitSightingPage() {
},
})
if (!data) throw new Error('createSighting returned no data')
- addSighting(toUfoSighting(data.createSighting))
setSubmittedId(data.createSighting.id)
} catch (error) {
const fieldErrors = serverFieldErrors(error)
diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx
index 9d32c26..9e4715e 100644
--- a/frontend/src/tests/SearchPage.test.tsx
+++ b/frontend/src/tests/SearchPage.test.tsx
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { of, throwError } from 'rxjs'
import { describe, expect, it, vi } from 'vitest'
-import { mockSightings } from '../data/mock-sightings'
+import { mockSightings } from './mock-sightings'
import { createCache } from '../graphql/client'
import type { SightingsQueryVariables } from '../graphql/queries'
import SearchPage from '../pages/SearchPage'
diff --git a/frontend/src/tests/SightingList.test.tsx b/frontend/src/tests/SightingList.test.tsx
index dc64f9d..a72d2fd 100644
--- a/frontend/src/tests/SightingList.test.tsx
+++ b/frontend/src/tests/SightingList.test.tsx
@@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
import SightingList from '../components/SightingList'
-import { mockSightings } from '../data/mock-sightings'
+import { mockSightings } from './mock-sightings'
vi.mock('../components/SightingMap', () => ({
default: () => ,
diff --git a/frontend/src/tests/SightingMap.test.tsx b/frontend/src/tests/SightingMap.test.tsx
index e502d86..51e2f80 100644
--- a/frontend/src/tests/SightingMap.test.tsx
+++ b/frontend/src/tests/SightingMap.test.tsx
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SightingMap from '../components/SightingMap'
-import { mockSightings } from '../data/mock-sightings'
+import { mockSightings } from './mock-sightings'
type MapProps = {
children: ReactNode
diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx
index 3c9ba69..61fa75f 100644
--- a/frontend/src/tests/SubmitSightingPage.test.tsx
+++ b/frontend/src/tests/SubmitSightingPage.test.tsx
@@ -6,7 +6,6 @@ import { GraphQLError } from 'graphql'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
-import { getSightings } from '../data/sighting-store'
import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations'
import SubmitSightingPage from '../pages/SubmitSightingPage'
@@ -86,7 +85,6 @@ describe('SubmitSightingPage', () => {
expect(
screen.getByRole('link', { name: 'View your sighting' }),
).toHaveAttribute('href', '/sightings/90001')
- expect(getSightings()[0]).toMatchObject({ id: '90001', state: '' })
})
it('shows field errors returned by the server next to the field', async () => {
diff --git a/frontend/src/data/mock-sightings.ts b/frontend/src/tests/mock-sightings.ts
similarity index 95%
rename from frontend/src/data/mock-sightings.ts
rename to frontend/src/tests/mock-sightings.ts
index a020365..18200ac 100644
--- a/frontend/src/data/mock-sightings.ts
+++ b/frontend/src/tests/mock-sightings.ts
@@ -1,10 +1,6 @@
import type { UfoSighting } from '../types/ufo-sighting'
-/**
- * Hand-written sample sightings. The search page now reads from GraphQL,
- * but the detail page still looks sightings up here, and tests use them as
- * fixtures. Remove from the app once the detail page uses GraphQL.
- */
+/** Hand-written sample sightings, used as fixtures by the tests. */
export const mockSightings: UfoSighting[] = [
{
id: '1',
From 7a656c1c96860a23ce1eba8547f33f1a8c6a73d5 Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Mon, 12 Oct 2026 13:07:49 +0200
Subject: [PATCH 03/11] Docs: Update AGENTS.md for the GraphQL detail page
Closes #35
---
AGENTS.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 53c4160..c3e01a7 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 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.
+- **Current state:** The backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search and detail pages fetch sightings through Apollo Client, with search, sort and "load more" pagination done in the database. A temporary footer shows whether the backend is reachable.
- **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable.
## Tech stack
@@ -69,7 +69,7 @@ frontend/
hooks/ Custom hooks (debounce, sighting search, single sighting)
utils/ Pure helpers (formatting)
types/ Shared TypeScript types
- data/ Mock sightings, still used by the detail page and as test fixtures
+ tests/ Vitest tests and the mock sightings they use as 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/
From 3a35dbbff3915341f407cf7ae41d362a56e43d08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?=
Date: Mon, 12 Oct 2026 13:14:13 +0200
Subject: [PATCH 04/11] Test: Cover search page loading and empty states
Give the fake backend an optional latency so states that only exist
while waiting can be tested, and test the first load, a serach with
no matches and a new serach while old results are shown.
Refs #43
---
frontend/src/tests/SearchPage.test.tsx | 64 ++++++++++++++++++++++++--
1 file changed, 60 insertions(+), 4 deletions(-)
diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx
index 9d32c26..bd7a790 100644
--- a/frontend/src/tests/SearchPage.test.tsx
+++ b/frontend/src/tests/SearchPage.test.tsx
@@ -2,7 +2,7 @@ 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 { of, throwError } from 'rxjs'
+import { delay, of, throwError } from 'rxjs'
import { describe, expect, it, vi } from 'vitest'
import { mockSightings } from '../data/mock-sightings'
import { createCache } from '../graphql/client'
@@ -22,13 +22,17 @@ vi.mock('../components/SightingMap', () => ({
* 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.
+ * an unreachable backend. `latencyMs` delays every answer like a real
+ * network, so states that only exist while waiting can be seen.
*/
const FAKE_PAGE_SIZE = 5
function renderSearchPage(
initialEntry = '/',
- { failRequest }: { failRequest?: number } = {},
+ {
+ failRequest,
+ latencyMs = 0,
+ }: { failRequest?: number; latencyMs?: number } = {},
) {
const requests: SightingsQueryVariables[] = []
const link = new ApolloLink((operation) => {
@@ -61,7 +65,7 @@ function renderSearchPage(
totalCount: matches.length,
},
},
- })
+ }).pipe(delay(latencyMs))
})
const client = new ApolloClient({ link, cache: createCache() })
@@ -208,4 +212,56 @@ describe('SearchPage', () => {
).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
+
+ describe('result states with a slow backend', () => {
+ const LATENCY_MS = 100
+
+ it('shows a loading message until the first results arrive', async () => {
+ renderSearchPage('/', { latencyMs: LATENCY_MS })
+
+ expect(screen.getByText('Loading sightings…')).toBeInTheDocument()
+ // The header is a live region, so screen readers hear it too.
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ expect(screen.queryByText(/No sightings match/)).not.toBeInTheDocument()
+
+ expect(
+ await screen.findByText('Showing 5 of 12 results'),
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument()
+ })
+
+ it('explains when a search has no matches', async () => {
+ renderSearchPage('/?q=atlantis', { latencyMs: LATENCY_MS })
+
+ expect(
+ await screen.findByText(/No sightings match your search/),
+ ).toBeInTheDocument()
+ expect(screen.getByText('0 results')).toBeInTheDocument()
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Load more' }),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps the previous results visible but busy while a new search loads', async () => {
+ renderSearchPage('/', { latencyMs: LATENCY_MS })
+ await screen.findByText('Showing 5 of 12 results')
+
+ fireEvent.change(screen.getByRole('searchbox'), {
+ target: { value: 'oslo' },
+ })
+
+ // After the debounce the new request is in flight: the old list stays
+ // instead of flashing to a loading message.
+ await waitFor(() =>
+ expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'),
+ )
+ expect(screen.getAllByRole('link')).toHaveLength(5)
+ expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument()
+
+ expect(await screen.findByText('1 result')).toBeInTheDocument()
+ expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false')
+ expect(screen.getAllByRole('link')).toHaveLength(1)
+ })
+ })
})
From e28f9864e52af34bbdbfc8779942b0d672b3f34c Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Mon, 12 Oct 2026 13:31:42 +0200
Subject: [PATCH 05/11] Fix: Address review feedback on the detail page
Clear the cached sighting lists after a submit so the new sighting
shows up in the search results. Show "Unknown" for a missing shape
and hide empty comments. Return "Unknown date" for invalid dates
instead of throwing. Return not found for ids that are not plain
numbers, since MySQL reads '1abc' as 1.
Part of #35
---
backend/src/db/sightings.ts | 4 ++
frontend/src/pages/SightingDetailPage.tsx | 7 +++-
frontend/src/pages/SubmitSightingPage.tsx | 3 ++
.../src/tests/SightingDetailPage.test.tsx | 18 +++++++++
.../src/tests/SubmitSightingPage.test.tsx | 37 ++++++++++++++++++-
frontend/src/tests/format-sighting.test.ts | 1 +
frontend/src/utils/format-sighting.ts | 8 ++--
7 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/backend/src/db/sightings.ts b/backend/src/db/sightings.ts
index ca259a7..79e71e8 100644
--- a/backend/src/db/sightings.ts
+++ b/backend/src/db/sightings.ts
@@ -132,6 +132,10 @@ export async function findSightings(args: SightingsArgs) {
}
export async function findSightingById(id: string) {
+ // MySQL reads '1abc' as 1 when comparing it with a number column, which
+ // would show the wrong sighting instead of "not found".
+ if (!/^\d+$/.test(id)) return null
+
const [rows] = await pool.query(
`${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`,
[id],
diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
index a69a9db..b268539 100644
--- a/frontend/src/pages/SightingDetailPage.tsx
+++ b/frontend/src/pages/SightingDetailPage.tsx
@@ -6,6 +6,7 @@ import {
formatDate,
formatDuration,
formatLocation,
+ formatShape,
} from '../utils/format-sighting'
import './SightingDetailPage.css'
@@ -96,7 +97,7 @@ function SightingDetailPage() {
- Shape
- - {sighting.shape}
+ - {formatShape(sighting.shape)}
Duration
@@ -104,7 +105,9 @@ function SightingDetailPage() {
- {sighting.comments}
+ {sighting.comments && (
+ {sighting.comments}
+ )}
)
}
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index f9b3f0b..13de282 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -115,6 +115,9 @@ function SubmitSightingPage() {
try {
const { data } = await createSighting({
+ // The cached lists don't contain the new sighting. Dropping them
+ // makes the search page fetch fresh results when it is opened.
+ update: (cache) => cache.evict({ fieldName: 'sightings' }),
variables: {
input: {
// datetime-local gives local time without timezone, which is
diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx
index f6da6f9..7e9d76a 100644
--- a/frontend/src/tests/SightingDetailPage.test.tsx
+++ b/frontend/src/tests/SightingDetailPage.test.tsx
@@ -76,6 +76,24 @@ describe('SightingDetailPage', () => {
expect(document.title).toBe('San Marcos, TX, US · UFOund it')
})
+ it('shows fallbacks for a missing shape and hides missing comments', async () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ const sighting = {
+ ...found.result.data.sighting,
+ shape: null,
+ comments: null,
+ }
+ renderDetailPage('/sightings/1', [
+ { ...found, result: { data: { sighting } } },
+ ])
+
+ expect(
+ await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Unknown')).toBeInTheDocument()
+ expect(screen.queryByText(/This event took place/)).not.toBeInTheDocument()
+ })
+
it('handles unknown ids and links back to the unfiltered list', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/missing', [
diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx
index 61fa75f..4c84245 100644
--- a/frontend/src/tests/SubmitSightingPage.test.tsx
+++ b/frontend/src/tests/SubmitSightingPage.test.tsx
@@ -2,11 +2,13 @@ import {
MockedProvider,
type MockedProviderProps,
} from '@apollo/client/testing/react'
+import { InMemoryCache } from '@apollo/client'
import { GraphQLError } from 'graphql'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations'
+import { SIGHTINGS_QUERY } from '../graphql/queries'
import SubmitSightingPage from '../pages/SubmitSightingPage'
// Leaflet needs a real browser layout, and the map is not under test here.
@@ -41,9 +43,12 @@ const created = {
longitude: 10.39,
}
-function renderPage(mocks: MockedProviderProps['mocks']) {
+function renderPage(
+ mocks: MockedProviderProps['mocks'],
+ cache?: InMemoryCache,
+) {
return render(
-
+
@@ -87,6 +92,34 @@ describe('SubmitSightingPage', () => {
).toHaveAttribute('href', '/sightings/90001')
})
+ it('drops the cached sighting lists so the new one shows up', async () => {
+ const cache = new InMemoryCache()
+ cache.writeQuery({
+ query: SIGHTINGS_QUERY,
+ data: {
+ sightings: {
+ edges: [],
+ pageInfo: { hasNextPage: false, endCursor: null },
+ totalCount: 0,
+ },
+ },
+ })
+ renderPage(
+ [
+ {
+ request: { query: CREATE_SIGHTING_MUTATION, variables: { input } },
+ result: { data: { createSighting: created } },
+ },
+ ],
+ cache,
+ )
+
+ fillForm()
+
+ await screen.findByRole('heading', { name: 'Sighting submitted' })
+ expect(cache.extract().ROOT_QUERY).not.toHaveProperty('sightings')
+ })
+
it('shows field errors returned by the server next to the field', async () => {
renderPage([
{
diff --git a/frontend/src/tests/format-sighting.test.ts b/frontend/src/tests/format-sighting.test.ts
index 10bf2a7..8305525 100644
--- a/frontend/src/tests/format-sighting.test.ts
+++ b/frontend/src/tests/format-sighting.test.ts
@@ -36,6 +36,7 @@ describe('sighting formatters', () => {
it('falls back to readable text when the API returns null', () => {
expect(formatDate(null)).toBe('Unknown date')
+ expect(formatDate('not a date')).toBe('Unknown date')
expect(formatDuration(null)).toBe('Unknown')
expect(formatShape(null)).toBe('Unknown')
expect(formatLocation({ ...sighting, city: null })).toBe(
diff --git a/frontend/src/utils/format-sighting.ts b/frontend/src/utils/format-sighting.ts
index c5a09d9..73fcade 100644
--- a/frontend/src/utils/format-sighting.ts
+++ b/frontend/src/utils/format-sighting.ts
@@ -3,9 +3,11 @@ import type { UfoSighting } from '../types/ufo-sighting'
export function formatDate(date: string | null) {
if (!date) return 'Unknown date'
- return new Intl.DateTimeFormat('en', {
- dateStyle: 'medium',
- }).format(new Date(date))
+ // Intl throws a RangeError for an invalid date.
+ const parsed = new Date(date)
+ if (Number.isNaN(parsed.getTime())) return 'Unknown date'
+
+ return new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(parsed)
}
export function formatDuration(seconds: number | null) {
From b3025f4f76df67e48cf5e57e1409fb26c3f572ac Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Mon, 12 Oct 2026 13:53:53 +0200
Subject: [PATCH 06/11] Fix: Show a server error without a retry button
The detail page said "Check your connection" for every error. If
the backend answers with a GraphQL error, such as a database
failure, it now says the server could not load the sighting and
hides the retry button, since retrying won't help.
Network
failures keep the old message and the button.
Part of #35
---
frontend/src/pages/SightingDetailPage.tsx | 15 +++++++++++----
frontend/src/tests/SightingDetailPage.test.tsx | 15 +++++++++++++++
2 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
index b268539..93e45c9 100644
--- a/frontend/src/pages/SightingDetailPage.tsx
+++ b/frontend/src/pages/SightingDetailPage.tsx
@@ -1,3 +1,4 @@
+import { CombinedGraphQLErrors } from '@apollo/client'
import { useEffect } from 'react'
import { Link, useLocation, useParams } from 'react-router'
import SightingMap from '../components/SightingMap'
@@ -65,10 +66,16 @@ function SightingDetailPage() {
{title}
{error ? (
-
Check your connection and try again.
-
+ {CombinedGraphQLErrors.is(error) ? (
+
The server could not load this sighting. Try again later.
+ ) : (
+ <>
+
Check your connection and try again.
+
+ >
+ )}
) : (
!loading && There is no sighting with id “{id}”.
diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx
index 7e9d76a..3fcf2a8 100644
--- a/frontend/src/tests/SightingDetailPage.test.tsx
+++ b/frontend/src/tests/SightingDetailPage.test.tsx
@@ -2,6 +2,7 @@ import {
MockedProvider,
type MockedProviderProps,
} from '@apollo/client/testing/react'
+import { GraphQLError } from 'graphql'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -94,6 +95,20 @@ describe('SightingDetailPage', () => {
expect(screen.queryByText(/This event took place/)).not.toBeInTheDocument()
})
+ it('does not suggest a retry when the server fails', async () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ renderDetailPage('/sightings/1', [
+ { ...found, result: { errors: [new GraphQLError('Database down')] } },
+ ])
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'The server could not load this sighting',
+ )
+ expect(
+ screen.queryByRole('button', { name: 'Try again' }),
+ ).not.toBeInTheDocument()
+ })
+
it('handles unknown ids and links back to the unfiltered list', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/missing', [
From 6b7a7a34acb4e7d4e96c6cd81a5dfe64e0f90419 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?=
Date: Wed, 14 Oct 2026 09:45:58 +0200
Subject: [PATCH 07/11] Fix: Hide submit error summary once fields are fixed
The summary counted keys in the errors object, but fixing a field
set its error to undefined without removing the key.
Now it checks for actual messages instead.
Fixes #63
---
frontend/src/pages/SubmitSightingPage.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 13de282..9491a77 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -95,6 +95,9 @@ function SubmitSightingPage() {
const [submittedId, setSubmittedId] = useState()
const [saveFailed, setSaveFailed] = useState(false)
const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION)
+ // Fixing a field sets its error to undefined but keeps the key, so check
+ // the values rather than counting keys.
+ const hasErrors = Object.values(errors).some(Boolean)
function updateValue(field: keyof FormValues, value: string) {
setValues((previous) => ({ ...previous, [field]: value }))
@@ -426,7 +429,7 @@ function SubmitSightingPage() {
)}
- {Object.keys(errors).length > 0 && (
+ {hasErrors && (
Please correct the highlighted fields.
From da683b6d40845b44a6e3b6e5ff3aa4a93752387f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?=
Date: Wed, 14 Oct 2026 09:50:19 +0200
Subject: [PATCH 08/11] Test: Cover wanted submit form behavior
Check that an invalid form sends no request and links each error to
its feild, that the error summary goes away once the fileds are fixed,
that a double click sends on requests, and that a sbmitted
sighting shows up in the search list. Uses a fake database
that stores the sighting and counts requests.
Refs #43, #64
---
.../src/tests/SubmitSightingPage.test.tsx | 151 +++++++++++++++++-
1 file changed, 146 insertions(+), 5 deletions(-)
diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx
index 4c84245..b7080be 100644
--- a/frontend/src/tests/SubmitSightingPage.test.tsx
+++ b/frontend/src/tests/SubmitSightingPage.test.tsx
@@ -1,20 +1,29 @@
+import { ApolloProvider } from '@apollo/client/react'
import {
MockedProvider,
type MockedProviderProps,
} from '@apollo/client/testing/react'
-import { InMemoryCache } from '@apollo/client'
+import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client'
import { GraphQLError } from 'graphql'
-import { fireEvent, render, screen } from '@testing-library/react'
-import { MemoryRouter } from 'react-router'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router'
+import { delay, of } from 'rxjs'
import { describe, expect, it, vi } from 'vitest'
+import Banner from '../components/Banner'
+import { createCache } from '../graphql/client'
import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations'
import { SIGHTINGS_QUERY } from '../graphql/queries'
+import SearchPage from '../pages/SearchPage'
import SubmitSightingPage from '../pages/SubmitSightingPage'
+import { mockSightings } from './mock-sightings'
-// Leaflet needs a real browser layout, and the map is not under test here.
+// Leaflet needs a real browser layout, and the maps are not under test here.
vi.mock('../components/LocationPicker', () => ({
default: () => ,
}))
+vi.mock('../components/SightingMap', () => ({
+ default: () => ,
+}))
const input = {
datetime: '2024-03-10T21:15',
@@ -57,6 +66,11 @@ function renderPage(
}
function fillForm() {
+ fillFields()
+ fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' }))
+}
+
+function fillFields() {
const fields: [string, string][] = [
['Date and time *', input.datetime],
['City *', input.city],
@@ -70,7 +84,62 @@ function fillForm() {
for (const [label, value] of fields) {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
}
- fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' }))
+}
+
+/**
+ * A fake backend that behaves like the database: createSighting stores
+ * the sighting, and later `sightings` queries return it first. It records
+ * the name of every request, so tests can count them. `latencyMs` delays
+ * every answer like a real network.
+ */
+function renderWithFakeDatabase(
+ initialEntry = '/submit',
+ { latencyMs = 0 } = {},
+) {
+ const operations: string[] = []
+ const database = [...mockSightings]
+
+ const link = new ApolloLink((operation) => {
+ operations.push(operation.operationName ?? 'unknown')
+
+ if (operation.operationName === 'CreateSighting') {
+ database.unshift(created)
+ return of({ data: { createSighting: created } }).pipe(delay(latencyMs))
+ }
+
+ return of({
+ data: {
+ sightings: {
+ __typename: 'SightingConnection',
+ edges: database.map((node) => ({
+ __typename: 'SightingEdge',
+ cursor: node.id,
+ node: { __typename: 'Sighting', ...node },
+ })),
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: false,
+ endCursor: null,
+ },
+ totalCount: database.length,
+ },
+ },
+ }).pipe(delay(latencyMs))
+ })
+ const client = new ApolloClient({ link, cache: createCache() })
+
+ render(
+
+
+
+
+ } />
+ } />
+
+
+ ,
+ )
+ return operations
}
describe('SubmitSightingPage', () => {
@@ -168,4 +237,76 @@ describe('SubmitSightingPage', () => {
screen.getByRole('button', { name: 'Submit sighting' }),
).toBeEnabled()
})
+
+ describe('wanted behavior', () => {
+ it('sends nothing when required fields are missing, and links each error to its field', async () => {
+ const operations = renderWithFakeDatabase()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' }))
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'Please correct the highlighted fields.',
+ )
+ expect(screen.getByLabelText('City *')).toHaveAccessibleDescription(
+ 'Enter the city.',
+ )
+ expect(screen.getByLabelText('Date and time *')).toHaveAttribute(
+ 'aria-invalid',
+ 'true',
+ )
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(operations).toEqual([])
+ })
+
+ it('removes the error summary once every highlighted field is fixed', () => {
+ renderWithFakeDatabase()
+ fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' }))
+ expect(
+ screen.getByText('Please correct the highlighted fields.'),
+ ).toBeInTheDocument()
+
+ fillFields()
+
+ expect(screen.getByLabelText('City *')).toHaveAttribute(
+ 'aria-invalid',
+ 'false',
+ )
+ expect(
+ screen.queryByText('Please correct the highlighted fields.'),
+ ).not.toBeInTheDocument()
+ })
+
+ it('sends one request when the submit button is clicked twice', async () => {
+ const operations = renderWithFakeDatabase('/submit', { latencyMs: 50 })
+
+ fillFields()
+ const button = screen.getByRole('button', { name: 'Submit sighting' })
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ expect(
+ await screen.findByRole('heading', { name: 'Sighting submitted' }),
+ ).toBeInTheDocument()
+ expect(operations).toEqual(['CreateSighting'])
+ })
+
+ it('shows the submitted sighting in the search list afterwards', async () => {
+ renderWithFakeDatabase('/')
+ // The list has been loaded and is now in Apollo's cache.
+ await screen.findByText('12 results')
+
+ fireEvent.click(screen.getByRole('link', { name: 'Report a sighting' }))
+ fillForm()
+ await screen.findByRole('heading', { name: 'Sighting submitted' })
+
+ fireEvent.click(screen.getByRole('link', { name: 'Back to sightings' }))
+
+ await waitFor(() =>
+ expect(screen.getByText('13 results')).toBeInTheDocument(),
+ )
+ expect(
+ screen.getByRole('link', { name: 'trondheim, NO' }),
+ ).toBeInTheDocument()
+ })
+ })
})
From faef6e9a990dee5c344c61a9486d8c6849405e1b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?=
Date: Wed, 14 Oct 2026 09:56:00 +0200
Subject: [PATCH 09/11] Chore: Make npm test run all tests once
npm test started Vitest in watch mode and only reached
the backend tests after quitting it. Run Vitest once by
default, like CI, and moved watch mode to npm run test:watch.
Documented in AGENTS.md.
Refs #43
---
AGENTS.md | 6 +++++-
frontend/package.json | 3 ++-
package.json | 1 +
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index c3e01a7..3cb86e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,9 +52,13 @@ npm run lint
# Format
npm run format:check
-# Run unit/component tests (Vitest, watch mode)
+# Run all tests once: frontend (Vitest) and backend (node --test), as CI does.
+# No running backend or database is needed; component tests mock Apollo.
npm run test
+# Re-run frontend tests on every file change while developing
+npm run test:watch
+
# Run end-to-end tests
# TODO: e2e tool not chosen yet
```
diff --git a/frontend/package.json b/frontend/package.json
index 2200f48..ca3111f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "vitest"
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@apollo/client": "^4.3.2",
diff --git a/package.json b/package.json
index d884746..b63ed29 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"build": "npm run build --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
+ "test:watch": "npm run test:watch -w frontend",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
From b1972dba5761dec12276000cedc260475b138dd0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?=
Date: Thu, 15 Oct 2026 10:20:38 +0200
Subject: [PATCH 10/11] Fix: Run backend tests through tsx on Node 22+
node --test only runs .ts files by itself from Node 22.18, so npm test
failed with ERR_UNKNOWN_FILE_EXTENSION on older Node 22 version. Now the
tests are ran through tsx instead, and Node 22 is set as the minimum
version in package.json.
Refs #43
---
AGENTS.md | 1 +
backend/package.json | 2 +-
package-lock.json | 3 +++
package.json | 3 +++
4 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 3cb86e5..26ea71e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,7 @@
| Area | Choice |
| -------------------- | ------------------------------------------------------------------------- |
+| Runtime | Node.js 22 or newer (`engines` in package.json) |
| Frontend | React, TypeScript, Vite, React Router |
| API | GraphQL (custom backend, Node + TypeScript, Apollo Server) |
| GraphQL client | Apollo Client |
diff --git a/backend/package.json b/backend/package.json
index 4501772..dcb0cc0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -8,7 +8,7 @@
"build": "tsc -p .",
"start": "node --env-file-if-exists=.env dist/index.js",
"lint": "eslint .",
- "test": "node --test \"src/**/*.test.ts\""
+ "test": "tsx --test \"src/**/*.test.ts\""
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/package-lock.json b/package-lock.json
index c38c2d4..3e5635a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,9 @@
],
"devDependencies": {
"prettier": "^3.9.9"
+ },
+ "engines": {
+ "node": ">=22"
}
},
"backend": {
diff --git a/package.json b/package.json
index b63ed29..16487ab 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,9 @@
{
"name": "t25-project-2",
"private": true,
+ "engines": {
+ "node": ">=22"
+ },
"workspaces": [
"frontend",
"backend"
From 9e5026bd233f561a4a3b7138989d69478b799f0d Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Thu, 15 Oct 2026 10:38:30 +0200
Subject: [PATCH 11/11] Chore: Retrigger checks