diff --git a/AGENTS.md b/AGENTS.md
index 8fee500..5a1a893 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 and is not yet connected to the frontend. No database yet.
+- **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.
- **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable.
## Tech stack
@@ -28,13 +28,18 @@ Run from the repository root unless noted.
# Install dependencies
npm install
-# Start frontend dev server
+# Start frontend dev server (proxies /project2/graphql to the backend)
npm run dev
# Start backend dev server (GraphQL on http://localhost:3001, restarts on save)
-# Use PORT=3002 npm run dev:backend if port 3001 is taken
npm run dev:backend
+# If port 3001 is taken, move both to another port. The frontend's
+# BACKEND_PORT must match the backend's PORT, or the footer shows
+# "Backend: unavailable".
+PORT=3002 npm run dev:backend
+BACKEND_PORT=3002 npm run dev
+
# Build (type-check + production build of frontend and backend)
npm run build
@@ -59,13 +64,13 @@ npm run test
```
frontend/
src/
- components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap)
+ components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap, BackendStatus)
pages/ Route-level components (SearchPage, SightingDetailPage)
hooks/ Custom hooks (debounce, sighting search, single sighting)
utils/ Pure helpers (filtering, formatting)
types/ Shared TypeScript types
data/ Mock sightings (to be replaced by GraphQL)
- # graphql/ Planned: Apollo queries
+ graphql/ Apollo Client setup (client.ts) and GraphQL queries (queries.ts)
backend/ Node + TypeScript GraphQL server (Apollo Server 5)
src/
index.ts Starts the server on PORT (default 3001)
@@ -127,6 +132,7 @@ Never:
- **VM address:** TODO (`http://it2810-XX.idi.ntnu.no/project2`)
- **Backend port:** 3001
+- **GraphQL endpoint:** The frontend calls `/project2/graphql` on its own origin. Apache must proxy it to `http://localhost:3001/graphql`, like the Vite dev proxy does locally. `VITE_GRAPHQL_URL` overrides the URL at build time if needed.
- **How to deploy:** TODO
## AI usage documentation
diff --git a/frontend/package.json b/frontend/package.json
index 26173b5..2200f48 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,11 +11,14 @@
"test": "vitest"
},
"dependencies": {
+ "@apollo/client": "^4.3.2",
+ "graphql": "^16.14.2",
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0",
- "react-router": "^8.4.0"
+ "react-router": "^8.4.0",
+ "rxjs": "^7.8.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 67ff84d..468e4c1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,5 @@
import { Route, Routes } from 'react-router'
+import BackendStatus from './components/BackendStatus'
import Banner from './components/Banner'
import SearchPage from './pages/SearchPage'
import SightingDetailPage from './pages/SightingDetailPage'
@@ -16,6 +17,9 @@ function App() {
} />
+
>
)
}
diff --git a/frontend/src/components/BackendStatus.css b/frontend/src/components/BackendStatus.css
new file mode 100644
index 0000000..69d3e87
--- /dev/null
+++ b/frontend/src/components/BackendStatus.css
@@ -0,0 +1,10 @@
+.backend-status {
+ padding: 16px 32px;
+ border-top: 1px solid var(--border);
+ color: var(--text);
+ font-size: 15px;
+
+ @media (max-width: 1024px) {
+ padding: 16px;
+ }
+}
diff --git a/frontend/src/components/BackendStatus.tsx b/frontend/src/components/BackendStatus.tsx
new file mode 100644
index 0000000..bf462ba
--- /dev/null
+++ b/frontend/src/components/BackendStatus.tsx
@@ -0,0 +1,25 @@
+import { useQuery } from '@apollo/client/react'
+import { HEALTH_QUERY } from '../graphql/queries'
+import './BackendStatus.css'
+
+/**
+ * Temporary check that the frontend can reach the GraphQL backend.
+ * Remove once real queries show whether the backend is up.
+ */
+function BackendStatus() {
+ const { data, loading, error } = useQuery(HEALTH_QUERY)
+
+ let status = 'checking…'
+ if (error) status = 'unavailable'
+ else if (!loading && data?.health === 'ok') status = 'connected'
+
+ // role="status" makes screen readers announce the change from
+ // "checking…" to the result, without moving focus.
+ return (
+
+ Backend: {status}
+
+ )
+}
+
+export default BackendStatus
diff --git a/frontend/src/graphql/client.ts b/frontend/src/graphql/client.ts
new file mode 100644
index 0000000..83108ae
--- /dev/null
+++ b/frontend/src/graphql/client.ts
@@ -0,0 +1,12 @@
+import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
+
+// BASE_URL is Vite's `base` ("/project2"), so the default is a same-origin
+// path that the Vite dev proxy and Apache on the VM both forward to the
+// backend. VITE_GRAPHQL_URL is only for pointing at a different backend.
+const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '')
+const graphqlUrl = import.meta.env.VITE_GRAPHQL_URL ?? `${baseUrl}/graphql`
+
+export const apolloClient = new ApolloClient({
+ link: new HttpLink({ uri: graphqlUrl }),
+ cache: new InMemoryCache(),
+})
diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts
new file mode 100644
index 0000000..535504b
--- /dev/null
+++ b/frontend/src/graphql/queries.ts
@@ -0,0 +1,15 @@
+import { gql, type TypedDocumentNode } from '@apollo/client'
+
+interface HealthQueryData {
+ health: string
+}
+
+/**
+ * Asks the backend's placeholder health field, to check that the frontend
+ * can reach it. The type parameters let useQuery know the shape of `data`.
+ */
+export const HEALTH_QUERY: TypedDocumentNode = gql`
+ query Health {
+ health
+ }
+`
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index b10eb49..b71cab8 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,15 +1,19 @@
+import { ApolloProvider } from '@apollo/client/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router'
import './index.css'
import App from './App.tsx'
+import { apolloClient } from './graphql/client.ts'
// Hash URLs (/project2/#/sightings/1) need no server rewrite rules, so
// refreshing or sharing a deep link works on the course VM as-is.
createRoot(document.getElementById('root')!).render(
-
-
-
+
+
+
+
+
,
)
diff --git a/frontend/src/tests/BackendStatus.test.tsx b/frontend/src/tests/BackendStatus.test.tsx
new file mode 100644
index 0000000..5b81468
--- /dev/null
+++ b/frontend/src/tests/BackendStatus.test.tsx
@@ -0,0 +1,52 @@
+import {
+ MockedProvider,
+ type MockedProviderProps,
+} from '@apollo/client/testing/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import BackendStatus from '../components/BackendStatus'
+import { HEALTH_QUERY } from '../graphql/queries'
+
+// MockedProvider stands in for ApolloProvider and answers queries from the
+// mocks below, so these tests never need a running backend.
+function renderWithMocks(mocks: MockedProviderProps['mocks']) {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('BackendStatus', () => {
+ it('shows that it is checking while the query is in flight', () => {
+ renderWithMocks([
+ { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } },
+ ])
+
+ expect(screen.getByText('Backend: checking…')).toBeInTheDocument()
+ })
+
+ it('is a status region so screen readers announce changes', () => {
+ renderWithMocks([
+ { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } },
+ ])
+
+ expect(screen.getByRole('status')).toHaveTextContent('Backend: checking…')
+ })
+
+ it('shows connected when the backend answers ok', async () => {
+ renderWithMocks([
+ { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } },
+ ])
+
+ expect(await screen.findByText('Backend: connected')).toBeInTheDocument()
+ })
+
+ it('shows unavailable when the request fails', async () => {
+ renderWithMocks([
+ { request: { query: HEALTH_QUERY }, error: new Error('Network error') },
+ ])
+
+ expect(await screen.findByText('Backend: unavailable')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..c701047
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,8 @@
+/**
+ * Types for our own VITE_ environment variables. Vite inlines these into
+ * the bundle at build time, so they must never contain secrets.
+ */
+interface ImportMetaEnv {
+ /** Overrides the GraphQL endpoint. Defaults to /project2/graphql. */
+ readonly VITE_GRAPHQL_URL?: string
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 37a303c..7a9e561 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,10 +1,25 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
+// Matches the backend's PORT, so `BACKEND_PORT=3002 npm run dev` works
+// when the backend runs with `PORT=3002`.
+const backendPort = process.env.BACKEND_PORT ?? '3001'
+
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/project2',
+ server: {
+ // The frontend always calls /project2/graphql on its own origin. In
+ // development Vite forwards it to the backend, on the VM Apache does,
+ // so the browser never makes cross-origin (CORS) requests.
+ proxy: {
+ '/project2/graphql': {
+ target: `http://localhost:${backendPort}`,
+ rewrite: (path) => path.replace(/^\/project2/, ''),
+ },
+ },
+ },
test: {
globals: true,
environment: 'jsdom',
diff --git a/package-lock.json b/package-lock.json
index 9c6db43..df228aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,11 +33,14 @@
"frontend": {
"version": "0.0.0",
"dependencies": {
+ "@apollo/client": "^4.3.2",
+ "graphql": "^16.14.2",
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0",
- "react-router": "^8.4.0"
+ "react-router": "^8.4.0",
+ "rxjs": "^7.8.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -77,6 +80,43 @@
"graphql": "14.x || 15.x || 16.x"
}
},
+ "node_modules/@apollo/client": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.2.tgz",
+ "integrity": "sha512-ra6f8NhP4b5Wznw6VpRGOGF6Q6a8rORToQeIx5BMC5Rc7mRVZ9wCN+01ynIZPjmlGPNif2Lqt0LRD+futfNgIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@wry/caches": "^1.0.0",
+ "@wry/equality": "^0.5.6",
+ "@wry/trie": "^0.5.0",
+ "graphql-tag": "^2.12.6",
+ "optimism": "^0.18.0",
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "graphql": "^16.0.0 || ^17.0.0",
+ "graphql-ws": "^5.5.5 || ^6.0.3",
+ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
+ "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
+ "rxjs": "^7.3.0",
+ "subscriptions-transport-ws": "^0.9.0 || ^0.11.0"
+ },
+ "peerDependenciesMeta": {
+ "graphql-ws": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "subscriptions-transport-ws": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@apollo/protobufjs": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.8.tgz",
@@ -2492,6 +2532,54 @@
"node": ">=22.15.0"
}
},
+ "node_modules/@wry/caches": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
+ "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/context": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz",
+ "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/equality": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz",
+ "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/trie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz",
+ "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -3652,6 +3740,21 @@
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
+ "node_modules/graphql-tag": {
+ "version": "2.12.7",
+ "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz",
+ "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -4565,6 +4668,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/optimism": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
+ "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@wry/caches": "^1.0.0",
+ "@wry/context": "^0.7.0",
+ "@wry/trie": "^0.5.0",
+ "tslib": "^2.3.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4950,6 +5065,15 @@
"@rolldown/binding-win32-x64-msvc": "1.2.11"
}
},
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",