diff --git a/AGENTS.md b/AGENTS.md
index 53c4160..26ea71e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,13 +5,14 @@
- **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
| 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 |
@@ -52,9 +53,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
```
@@ -69,7 +74,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/
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/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/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/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/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/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/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
index bca0d57..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'
@@ -6,6 +7,7 @@ import {
formatDate,
formatDuration,
formatLocation,
+ formatShape,
} from '../utils/format-sighting'
import './SightingDetailPage.css'
@@ -27,11 +29,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 +64,22 @@ function SightingDetailPage() {
{backLink}
{title}
-
There is no sighting with id “{id}”.
+ {error ? (
+
+ {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}”.
+ )}
)
}
@@ -83,7 +104,7 @@ function SightingDetailPage() {
Shape
-
{sighting.shape}
+
{formatShape(sighting.shape)}
Duration
@@ -91,7 +112,9 @@ function SightingDetailPage() {
-
{sighting.comments}
+ {sighting.comments && (
+
{sighting.comments}
+ )}
)
}
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 920dfe8..9491a77 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,30 +89,15 @@ 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({})
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 }))
@@ -135,6 +118,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
@@ -152,7 +138,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)
@@ -444,7 +429,7 @@ function SubmitSightingPage() {