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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
```
Expand All @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/db/sightings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RowDataPacket[]>(
`${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`,
[id],
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 0 additions & 24 deletions frontend/src/data/sighting-store.ts

This file was deleted.

27 changes: 27 additions & 0 deletions frontend/src/graphql/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
`
23 changes: 16 additions & 7 deletions frontend/src/hooks/use-sighting.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
6 changes: 0 additions & 6 deletions frontend/src/hooks/use-sightings.ts

This file was deleted.

35 changes: 29 additions & 6 deletions frontend/src/pages/SightingDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -6,6 +7,7 @@ import {
formatDate,
formatDuration,
formatLocation,
formatShape,
} from '../utils/format-sighting'
import './SightingDetailPage.css'

Expand All @@ -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.
Expand All @@ -58,7 +64,22 @@ function SightingDetailPage() {
<div className="sighting-detail">
{backLink}
<h1>{title}</h1>
<p>There is no sighting with id “{id}”.</p>
{error ? (
<div role="alert">
{CombinedGraphQLErrors.is(error) ? (
<p>The server could not load this sighting. Try again later.</p>
) : (
<>
<p>Check your connection and try again.</p>
<button type="button" onClick={retry}>
Try again
</button>
</>
)}
</div>
) : (
!loading && <p>There is no sighting with id “{id}”.</p>
)}
</div>
)
}
Expand All @@ -83,15 +104,17 @@ function SightingDetailPage() {
<dl className="sighting-detail__facts">
<div>
<dt>Shape</dt>
<dd>{sighting.shape}</dd>
<dd>{formatShape(sighting.shape)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(sighting.durationSeconds)}</dd>
</div>
</dl>

<p className="sighting-detail__comments">{sighting.comments}</p>
{sighting.comments && (
<p className="sighting-detail__comments">{sighting.comments}</p>
)}
</article>
)
}
Expand Down
29 changes: 7 additions & 22 deletions frontend/src/pages/SubmitSightingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<FormErrors>({})
const [submittedId, setSubmittedId] = useState<string>()
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 }))
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -444,7 +429,7 @@ function SubmitSightingPage() {
</p>
)}
</div>
{Object.keys(errors).length > 0 && (
{hasErrors && (
<p className="submit-sighting__summary" role="alert">
Please correct the highlighted fields.
</p>
Expand Down
Loading