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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/components/SightingList.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
list-style: none;
margin: 0;
padding: 0;

&[aria-busy='true'] {
opacity: 0.6;
}
}

.sighting-list__item {
Expand Down Expand Up @@ -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;
Expand Down
86 changes: 76 additions & 10 deletions frontend/src/components/SightingList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="sighting-results" aria-labelledby="results-heading">
<div className="sighting-results__header">
<h1 id="results-heading">Sightings</h1>
<p aria-live="polite">
{sightings.length} {sightings.length === 1 ? 'result' : 'results'}
</p>
<p aria-live="polite">{summary}</p>
</div>

{sightings.length === 0 ? (
<p className="sighting-results__empty">
{error ? (
<div className="sighting-results__message" role="alert">
<p>Could not load sightings. Check your connection and try again.</p>
<button
type="button"
className="sighting-results__button"
onClick={onRetry}
>
Try again
</button>
</div>
) : initialLoad ? (
<p className="sighting-results__message">Loading sightings…</p>
) : sightings.length === 0 ? (
<p className="sighting-results__message">
No sightings match your search. Try another city, shape or
description.
</p>
) : (
<ul className="sighting-list">
// aria-busy tells screen readers the list is about to change, and
// dims it visually while a new search loads.
<ul className="sighting-list" aria-busy={loading}>
{sightings.map((sighting) => (
<li className="sighting-list__item" key={sighting.id}>
<SightingMap sighting={sighting} />
Expand All @@ -46,26 +91,47 @@ function SightingList({ sightings }: SightingListProps) {
{formatLocation(sighting)}
</Link>
</h2>
<time dateTime={sighting.datetime}>
<time dateTime={sighting.datetime ?? undefined}>
{formatDate(sighting.datetime)}
</time>
</div>
<dl className="sighting-list__details">
<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>{sighting.comments}</p>
{sighting.comments && <p>{sighting.comments}</p>}
</article>
</li>
))}
</ul>
)}

{!error && hasNextPage && (
<div className="sighting-results__more">
{loadMoreFailed && <p role="alert">Could not load more sightings.</p>}
{/* aria-disabled rather than disabled, so keyboard focus stays on
the button while the next page loads. The hook ignores clicks
in the meantime. */}
<button
type="button"
className="sighting-results__button"
aria-disabled={loadingMore}
onClick={onLoadMore}
>
{loadingMore
? 'Loading…'
: loadMoreFailed
? 'Try again'
: 'Load more'}
</button>
</div>
)}
</section>
)
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/SightingMap.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 13 additions & 2 deletions frontend/src/components/SightingMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={`${classNames} sighting-map--empty`}>
<p>No map location</p>
</div>
)
}

const position: [number, number] = [sighting.latitude, sighting.longitude]

return (
<div
className={['sighting-map', className].filter(Boolean).join(' ')}
aria-label={`Map showing ${sighting.city}`}
className={classNames}
aria-label={`Map showing ${formatLocation(sighting)}`}
>
<MapContainer
center={position}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/data/mock-sightings.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { UfoSighting } from '../types/ufo-sighting'

/**
* Hand-written sample sightings so the search UI has something to show
* before the backend exists. Remove once sightings are fetched via GraphQL.
* 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.
*/
export const mockSightings: UfoSighting[] = [
{
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import type { SightingsQueryData } from './queries'

type SightingConnection = SightingsQueryData['sightings']

// 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`

/** Exported so tests use the same pagination rules as the app. */
export function createCache() {
return new InMemoryCache({
typePolicies: {
Query: {
fields: {
sightings: {
// One cached list per search, filter and sort. `first` and
// `after` only pick a page within that list, so they are left
// out of the key and every page lands in the same entry.
keyArgs: ['search', 'filter', 'sort'],
// "Load more" passes `after` and appends its page to what is
// already loaded. Without `after` it is a fresh first page.
merge(
existing: SightingConnection | undefined,
incoming: SightingConnection,
{ args },
) {
if (!existing || !args?.after) return incoming
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
}
},
},
},
},
},
})
}

export const apolloClient = new ApolloClient({
link: new HttpLink({ uri: graphqlUrl }),
cache: new InMemoryCache(),
cache: createCache(),
})
58 changes: 58 additions & 0 deletions frontend/src/graphql/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { gql, type TypedDocumentNode } from '@apollo/client'
import type { SightingSortEnum, UfoSighting } from '../types/ufo-sighting'

interface HealthQueryData {
health: string
Expand All @@ -13,3 +14,60 @@ export const HEALTH_QUERY: TypedDocumentNode<HealthQueryData> = 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
}
}
`
3 changes: 1 addition & 2 deletions frontend/src/hooks/use-debounced-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
Expand Down
Loading