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
82 changes: 23 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,24 @@ Users can mark countries as visited or want to visit. The data is stored in the

## Implemented so far:

- Country list using data fetched from the GraphQL backend.
- Country detail page using data fetched from the GraphQL backend.
- Country list and country detail page using data fetched from the GraphQL backend.
- Responsive CountryQuest landing page with separate search, quiz, and results views, plus navigation to visited, wish-list, and unexplored countries.
- Search functionality for countries and cities, with case-insensitive searching performed in the PostgreSQL database.
- Search for countries and cities, with case-insensitive searching performed in the PostgreSQL database.
- Debounced search input (300 ms) to reduce unnecessary GraphQL requests.
- Loading, error, and empty-result states for country searches.
- Apollo Client integration for GraphQL queries and client-side caching.
- GraphQL resolvers for countries, individual countries, cities, and individual cities.
- Apollo Server with resolvers that retrieve data from PostgreSQL.
- Backend support for country search, continent filtering, and pagination.
- PostgreSQL database hosted on the group's NTNU VM, populated with country and city data.
- Backend deployed on the NTNU VM as a systemd service, allowing it to continue running after SSH logout and restart automatically if the process fails.
- Pagination of country search results using a reusable "Load more" component, fetching results in batches of 20.
- Apollo Client cache policies that merge paginated results and reuse previously fetched pages for the same search and filters.
- Backend filtering by continent, language, visited status, and unexplored status, with sorting by name, population, or area.
- Country and city pagination handled by the backend; results are loaded in batches of 20.
- Users can mark countries as visited or want to visit. Statuses are stored persistently and can be changed or removed.
- Quiz attempts are saved in the browser's IndexedDB and can be viewed on the quiz results page.
- Loading, error, and empty-result states for searches and data updates.
- Apollo Client integration and cache policies for GraphQL queries and paginated results.
- PostgreSQL database and GraphQL backend hosted on the group's NTNU VM; the backend runs as a systemd service.
- **Accessibility and sustainable web development**: Accessibility testing has covered keyboard navigation, VoiceOver, and Lighthouse. The app uses semantic HTML and ARIA where necessary. We also make conscious choices regarding data usage, caching, and performance to reduce unnecessary resource consumption. See our [Accessibility and Sustainability Plan](docs/planAccessibilityAndSustainability.md).

## Planned Functionality
## User data and anonymous browser ID

- **Result list**: Further improvements to result loading and pagination as needed when working with larger datasets.
The app creates a random ID for each browser and stores it in that browser's local storage. The ID is sent with requests so the backend can associate visited and want-to-visit statuses with the same browser without requiring account creation, passwords, or collecting account details. This keeps the feature simple and avoids introducing a login system for a project that does not need cross-device accounts.

- **Sorting and filtering**: Sorting by name/population and filtering by continent/language and Visited/Want to visit. These operations will be performed on the entire dataset in the backend, rather than only on the data loaded into the client.

- **User-generated data**: The ability to mark countries as Visited/Want to visit, stored persistently in the database.

- **Accessibility and sustainable web development**: Semantic HTML, ARIA where necessary, and keyboard navigation. Conscious choices regarding data usage, caching, and performance to reduce unnecessary resource consumption. See our [Accessibility and Sustainability Plan](docs/planAccessibilityAndSustainability.md).

- **Responsive design**: Continued improvements for both mobile and desktop.

- See our [design mockups](docs/design.md).
The data is tied to the browser that created it. It is not shared across browsers or devices, and clearing that browser's site data removes the ID needed to look up its saved statuses.

## Technology

Expand All @@ -54,6 +44,7 @@ Users can mark countries as visited or want to visit. The data is stored in the
- **Backend**: GraphQL (Node.js/TypeScript)
- **Database**: PostgreSQL, installed directly on the group's VM
- **Data**: countries and cities from open datasets (see [Data](#data))

- **Testing**: Vitest and React Testing Library
- **Linting/formatting**: ESLint and Prettier

Expand All @@ -69,13 +60,13 @@ The backend and database are hosted on the group's NTNU VM. The backend runs as

To support larger datasets, searching, filtering, and pagination are handled by the backend and database rather than requiring the frontend to load all records. The GraphQL API allows the frontend to request only the data needed for each view.

The dataset currently contains approximately 250 countries and 34,155 cities. Including city data provides a larger dataset for evaluating database queries and scalability.
The city dataset provides a larger set of records for evaluating database queries and scalability.

For deployment details, see [Backend Deployment](docs/backendDeployment.md).

## Pagination and Caching

Country results are fetched in batches of 20 using offset-based pagination. Apollo Client merges the fetched pages into a cached list for each search and continent filter combination.
Country and city results are fetched in batches of 20 using offset-based pagination. Apollo Client merges the fetched pages into a cached list for each search and filter combination.

Previously fetched results are reused when returning to a search with the same parameters. For example, if 60 countries have been loaded for a search and the user switches to another search before returning, the previously cached results may be displayed immediately.

Expand All @@ -90,20 +81,16 @@ This is expected Apollo Client cache behavior and helps avoid unnecessary networ

To run the frontend using the deployed backend, no direct database access or SSH access to the VM is required.

To run the backend locally, access to the PostgreSQL database and SSH access to the group's VM may be required. See [Database Setup](docs/databaseSetup.md) for instructions.

### 1. Start the Frontend

Open a terminal in the repository root and install the dependencies:
### Start the frontend

```bash
npm install
npm ci
```

Connect to the NTNU network or VPN and create a `.env.local` file in the repository root with the following content:
Connect to the NTNU network or VPN and create a `.env.local` file in the repository root:

```env
VITE_GRAPHQL_URL=http://it2810-28.idi.ntnu.no:3001/
VITE_GRAPHQL_URL=http://it2810-28.idi.ntnu.no:3001/graphql
```

Start the frontend:
Expand All @@ -112,32 +99,9 @@ Start the frontend:
npm run dev
```

Open [http://localhost:5173/](http://localhost:5173/) in your browser.

The frontend communicates with the GraphQL backend hosted on the group's NTNU VM. You do not need to start the backend locally.

### 2. Run the Backend Locally (Optional)

If you need to develop or test the backend locally, first follow the instructions in [Database Setup](docs/databaseSetup.md) to configure `backend/.env` and establish an SSH tunnel if necessary.

Open a terminal in the repository root and run:

```bash
cd backend
npm install
npm run db:check
npm run dev
```

The GraphQL server runs at [http://localhost:3001/](http://localhost:3001/).

To connect the frontend to your local backend instead of the deployed backend, update `.env.local` in the repository root:

```env
VITE_GRAPHQL_URL=http://localhost:3001/
```
Open [http://localhost:5173/](http://localhost:5173/) in your browser. The frontend uses the deployed backend, so you do not need database credentials or SSH access to the VM.

Keep the backend running in a separate terminal while developing.
To develop the backend locally, see [Database Setup](docs/databaseSetup.md) for the required database configuration and SSH tunnel instructions.

### Backend Deployment (NTNU VM)

Expand Down Expand Up @@ -227,7 +191,7 @@ Examples include working with React and TypeScript, understanding GraphQL querie

AI has also been used to help troubleshoot backend deployment on the NTNU VM, configure the systemd service, resolve Git-related issues, and improve technical documentation, including the README.

Using AI to explain concepts and provide feedback on our own code has helped us better understand the technologies used in the project. It has also made debugging and troubleshooting more efficient, particularly when working with unfamiliar tools and configurations.
Using AI to explain concepts and provide feedback on our own code has helped us better understand the technologies used in this project. It has also made debugging and troubleshooting more efficient, particularly when working with unfamiliar tools and configurations.

All AI-generated suggestions have been reviewed and evaluated before being applied, and the group remains responsible for the final implementation.

Expand Down
1 change: 1 addition & 0 deletions backend/src/checkDb.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Standalone connectivity check for the PostgreSQL settings in the backend environment. */
import pg from 'pg';

const pool = new pg.Pool({
Expand Down
1 change: 1 addition & 0 deletions backend/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Shared PostgreSQL connection pool used by the GraphQL server. */
import pg from 'pg';

export const pool = new pg.Pool({
Expand Down
4 changes: 4 additions & 0 deletions backend/src/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# GraphQL API contract for country and city data, user statuses, and quiz selection.
enum CountryStatus {
VISITED
WANT_TO_VISIT
Expand Down Expand Up @@ -28,6 +29,7 @@ type Country {
population: Int
area: Float
flagUrl: String!
# Null for anonymous users with no saved status for this country.
status: CountryStatus
}

Expand All @@ -40,6 +42,7 @@ type City {
population: Int!
}

# Page types expose a slice in items and a totalCount across active filters.
type CountryPage {
items: [Country!]!
totalCount: Int!
Expand All @@ -52,6 +55,7 @@ type CityPage {

type Query {
country(code: ID!): Country
# limit/offset select a page; country status filters require the anonymous browser ID.
countries(
search: String
continent: String
Expand Down
1 change: 1 addition & 0 deletions backend/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* - user_country is never touched, so user data survives a re-seed
*
* Run from backend/: npm run db:seed
* Country and city imports share one transaction so partial data refreshes are not exposed.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
Expand Down
7 changes: 7 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Starts the GraphQL API and attaches the validated anonymous browser ID to each request. */
import { readFile } from 'node:fs/promises';
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
Expand All @@ -15,6 +16,7 @@ const CITY_COLUMNS = `
id, name, latitude, longitude, population, country_code
`;

// SQL identifiers cannot be parameterized, so enum inputs map only to fixed column names.
const COUNTRY_SORT_COLUMNS = new Map([
['NAME', 'name'],
['POPULATION', 'population'],
Expand Down Expand Up @@ -57,6 +59,7 @@ const resolvers = {
const sortExpression = args.sortBy === 'NAME' ? 'name COLLATE "C"' : sortColumn;
const sortOrder = SORT_ORDERS.get(args.sortOrder) ?? 'ASC';

// Bind search text as a value; ILIKE provides case-insensitive substring matching.
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
AND ($2::text IS NULL OR continent = $2)
Expand Down Expand Up @@ -109,6 +112,7 @@ const resolvers = {
[...filterValues, args.limit ?? 20, args.offset ?? 0]
);

// Count all filtered matches separately so totalCount does not shrink with each page.
const countResult = await pool.query(`SELECT count(*) FROM country ${filters}`, filterValues);

return {
Expand Down Expand Up @@ -140,6 +144,7 @@ const resolvers = {
const sortColumn = CITY_SORT_COLUMNS.get(args.sortBy) ?? 'population';
const sortOrder = SORT_ORDERS.get(args.sortOrder) ?? 'DESC';

// ILIKE makes city-name matching case-insensitive; values remain bound parameters.
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
AND ($2::text IS NULL OR country_code = $2)
Expand All @@ -153,6 +158,7 @@ const resolvers = {

const filterValues = [args.search ?? null, args.countryCode ?? null, args.continent ?? null];

// Apply pagination after filtering/sorting, and keep the total independent of the page.
const itemsResult = await pool.query(
`SELECT ${CITY_COLUMNS}
FROM city
Expand All @@ -162,6 +168,7 @@ const resolvers = {
[...filterValues, args.limit ?? 20, args.offset ?? 0]
);

// Return the filtered total, not just the current LIMIT/OFFSET page.
const countResult = await pool.query(`SELECT count(*) FROM city ${filters}`, filterValues);

return {
Expand Down
1 change: 1 addition & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Global application shell, navigation, hero, filters, and result layout. */
#root {
min-height: 100vh;
}
Expand Down
7 changes: 7 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Coordinates app-level views and shared search, filter, and navigation state. */
import { useState } from 'react';
import { useQuery } from '@apollo/client/react';
import languages from './data/languages.json';
Expand All @@ -19,6 +20,7 @@ const PAGE_SIZE = 20;
type Mode = 'search' | 'quiz';
type Section = 'home' | 'visited' | 'wishlist' | 'unexplored' | 'quizResults';

/** Inline home glyph used by the persistent top-level navigation. */
function HomeIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
Expand All @@ -27,6 +29,7 @@ function HomeIcon() {
);
}

/** Renders the application shell and shared top-level navigation. */
function App() {
const [selectedCountry, setSelectedCountry] = useState<CountrySummary | null>(null);
const [mode, setMode] = useState<Mode>('search');
Expand All @@ -41,6 +44,7 @@ function App() {
const [resultFilter, setResultFilter] = useState<'all' | 'countries' | 'cities'>('all');

const debouncedSearch = useDebounce(searchText.trim(), 300);
// Enter skips the debounce and searches right away.
const search = submittedSearch ?? debouncedSearch;
const { loading, error, data, fetchMore } = useQuery<{
countries: { items: CountrySummary[]; totalCount: number };
Expand All @@ -58,6 +62,7 @@ function App() {
},
});

// CountryStatus has no value for "no status", so unexplored countries need their own query.
const {
loading: unexploredLoading,
error: unexploredError,
Expand Down Expand Up @@ -86,6 +91,7 @@ function App() {
} = useQuery<{
cities: { items: CitySearchResult[]; totalCount: number };
}>(GET_CITIES, {
// Cities don't have a status, so skip them in the status views.
skip: mode !== 'search' || !search || Boolean(statusFilter),
variables: {
search: search || null,
Expand Down Expand Up @@ -395,6 +401,7 @@ function App() {
<p role="alert">Could not load countries: {countriesError.message}</p>
)}
{citiesError && <p role="alert">Could not load cities: {citiesError.message}</p>}
{/* Keep already displayed results visible during refetches; show loading only before the first page arrives. */}
{countriesLoading && !(isUnexplored ? unexploredData : data) && (
<p role="status">Loading countries…</p>
)}
Expand Down
1 change: 1 addition & 0 deletions src/components/CountryCard.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Country card layout, status badges, and its quick-action row. */
.country-card {
display: flex;
flex-direction: column;
Expand Down
3 changes: 3 additions & 0 deletions src/components/CountryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ const STATUS_LABELS: Record<CountryStatus, string> = {
};

interface CountryCardProps {
/** Country fields displayed by the card, including its current saved status. */
country: CountrySummary;
/** Opens the selected country's details view. */
onSelect: () => void;
}

/** Displays country facts and lets the user toggle visited or want-to-visit status. */
export function CountryCard({ country, onSelect }: CountryCardProps) {
const formattedCapital = country.capital.length > 0 ? country.capital.join(', ') : 'None';
const [setStatus, { loading, error }] = useMutation(SET_COUNTRY_STATUS, {
Expand Down
1 change: 1 addition & 0 deletions src/components/CountryList.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Responsive grid for the semantic list of country cards. */
.country-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
Expand Down
3 changes: 3 additions & 0 deletions src/components/CountryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { CountryCard } from './CountryCard';
import './CountryList.css';

interface CountryListProps {
/** Countries to display in the list. */
countries: CountrySummary[];
/** Called with the country whose details the user selected. */
onSelect: (country: CountrySummary) => void;
}

/** Maps country results to cards while keeping list semantics and selection centralized. */
export function CountryList({ countries, onSelect }: CountryListProps) {
return (
<ul className="country-list" role="list">
Expand Down
1 change: 1 addition & 0 deletions src/components/CountryStatusPicker.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Layout and selected-state styling for country status controls. */
.status-picker {
margin: 1rem 0;
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/CountryStatusPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { SET_COUNTRY_STATUS } from '../graphql/countries';
import './CountryStatusPicker.css';

interface CountryStatusPickerProps {
/** Country whose status is being edited. */
code: string;
/** Current status, or null when the user has not marked the country. */
status: CountryStatus | null;
}

Expand All @@ -14,6 +16,7 @@ const OPTIONS: { label: string; value: CountryStatus | null }[] = [
{ label: 'Visited', value: 'VISITED' },
];

/** Saves the selected country status and reports mutation errors accessibly. */
export function CountryStatusPicker({ code, status }: CountryStatusPickerProps) {
const [setStatus, { loading, error }] = useMutation(SET_COUNTRY_STATUS, {
update(cache) {
Expand Down
1 change: 1 addition & 0 deletions src/components/LoadMore.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Centers the pagination count and next-page action below result lists. */
.load-more {
display: flex;
flex-direction: column;
Expand Down
5 changes: 5 additions & 0 deletions src/components/LoadMore.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import './LoadMore.css';

interface LoadMoreProps {
/** Number of records currently loaded. */
loadedCount: number;
/** Total records matching the active query and filters. */
totalCount: number;
/** Disables the action and changes its label while a page is loading. */
loading: boolean;
/** Requests the next page. */
onLoadMore: () => void;
}

/** Shows the loaded/total count and conditionally exposes the next-page action. */
export function LoadMore({ loadedCount, totalCount, loading, onLoadMore }: LoadMoreProps) {
const hasMore = loadedCount < totalCount;

Expand Down
1 change: 1 addition & 0 deletions src/components/QuizHistory.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* Styles grouped quiz attempts, expandable answers, and history actions. */
.quiz-history {
margin-top: 2rem;
}
Expand Down
Loading