From 3b24b17387425bb35087d9c1ffd6e3533203be54 Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Mon, 5 Oct 2026 12:44:01 +0200 Subject: [PATCH 1/7] Chore: add CI workflow for lint, test, build Runs lint, format check, tests and build on every pull request to main and on pushes to main, so broken code is caught before review. Uses npm ci to match the lockfile, caches dependencies and cancels outdated runs to save runner time. Refs #45 --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db0956d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI +on: + push: + pull_request: + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm run lint + - run: npm test From d71be674fb20655f274bf9efe25eb8f28a477ddb Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Mon, 5 Oct 2026 12:51:11 +0200 Subject: [PATCH 2/7] Chore: allow CI to pass without test files Vitest exits with code 1 when it finds no tests, which made the CI test step fail before any tests exist. Adds --passWithNoTests as a temporary measure. Refs #45 --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 26173b5..e619a1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest --passWithNoTests" }, "dependencies": { "leaflet": "^1.9.4", From fe9b9813e95865629cffde14f7838cdebdbca9db Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Mon, 5 Oct 2026 12:56:41 +0200 Subject: [PATCH 3/7] Chore: extend CI with format check and build Adds format check and build steps so formatting and type errors are caught before review. Limits triggers to pull requests and pushes to main to avoid duplicate runs, caches npm dependencies and cancels outdated runs to save runner time. Refs #45 --- .github/workflows/ci.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0956d..f92dcf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,31 @@ name: CI + on: - push: pull_request: + branches: [main] + push: + branches: [main] + +# Cancel outdated runs on the same branch to save runner time and energy. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - test: + checks: runs-on: self-hosted steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + + # npm ci installs exactly what package-lock.json says, so CI matches + # what we run locally. - run: npm ci - run: npm run lint + - run: npm run format:check - run: npm test + - run: npm run build From 541a981bf8d644d62985c8afb41e25f4831742b5 Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Mon, 5 Oct 2026 13:12:03 +0200 Subject: [PATCH 4/7] Style: format repo with Prettier Applies the Prettier config to files that did not follow it, mainly the ESLint and Vite configs, so the CI format check passes. Also aligns markdown tables and spacing in AGENTS.md and CONTRIBUTING.md. No behavior changes. --- .prettierrc | 2 +- AGENTS.md | 22 +++++++++++++--------- CONTRIBUTING.md | 18 +++++++++--------- frontend/eslint.config.js | 20 ++++++++++---------- frontend/src/index.css | 4 ++-- frontend/src/setupTests.ts | 2 +- frontend/vite.config.ts | 12 ++++++------ 7 files changed, 42 insertions(+), 38 deletions(-) diff --git a/.prettierrc b/.prettierrc index 00fbdb1..b2095be 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,4 @@ { "semi": false, "singleQuote": true -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md index 518662d..0b1b1b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ # AGENTS.md + ## Project overview - **Name:** UFOund it @@ -9,15 +10,15 @@ ## Tech stack -| Area | Choice | -| --- | --- | -| Frontend | React, TypeScript, Vite, React Router | -| API | GraphQL (custom backend, Node + TypeScript, Apollo Server) | -| GraphQL client | Apollo Client | -| Database | MySQL (leaning, not final), run directly on the VM | -| Testing | Vitest + React Testing Library (components), e2e tool and API testing TBD | -| Linting / formatting | ESLint, Prettier | -| UI libraries | Leaflet with react-leaflet (OpenStreetMap tiles, no API key) | +| Area | Choice | +| -------------------- | ------------------------------------------------------------------------- | +| Frontend | React, TypeScript, Vite, React Router | +| API | GraphQL (custom backend, Node + TypeScript, Apollo Server) | +| GraphQL client | Apollo Client | +| Database | MySQL (leaning, not final), run directly on the VM | +| Testing | Vitest + React Testing Library (components), e2e tool and API testing TBD | +| Linting / formatting | ESLint, Prettier | +| UI libraries | Leaflet with react-leaflet (OpenStreetMap tiles, no API key) | ## Commands @@ -101,15 +102,18 @@ frontend/ ## Boundaries Always: + - Run lint and tests before proposing a change is done. - Ask before making large or cross-cutting changes. Ask first: + - Adding or upgrading dependencies. - Changing the database schema or GraphQL schema. - Changing project structure or tooling. Never: + - Commit secrets, credentials or `.env` files. - Use Docker for the database or backend (course requirement). - Edit generated files or lock files by hand. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac66ab7..6b254ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,16 +29,17 @@ Always run `npm install` again after pulling changes that touch `package.json`. - Write issues concrete enough to be resolved with a single PR. Split large tasks into several issues rather than making one giant one. ## Commits + **Format** - + ``` : - + [Optional body explaining what and why, wrapped at 72 chars] ``` - + **Rules** - + - Separate subject from body with a blank line - Limit the subject line to 50 characters - Capitalize the subject line @@ -48,9 +49,8 @@ Always run `npm install` again after pulling changes that touch `package.json`. - Use the body to explain what and why, not how - Reference the issue the commit belongs to, e.g. `#12`, and use `Closes #12` in the final commit or the PR description to auto-close it on merge - **Types** - + - `Feat`: new feature - `Fix`: bug fix - `Docs`: documentation @@ -59,11 +59,11 @@ Always run `npm install` again after pulling changes that touch `package.json`. - `Test`: adding or fixing tests - `Chore`: build or maintenance tasks - `Wip`: work in progress -**Example** - + **Example** + ``` Feat: add favorite button to resource card - + Adds a star icon on each resource card that toggles a favorite state stored in localStorage, so favorites persist across browser restarts as required by the assignment. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4ae265a..1cfd02a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,15 +1,15 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; -import { defineConfig, globalIgnores } from "eslint/config"; -import eslintConfigPrettier from "eslint-config-prettier/flat"; +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' +import eslintConfigPrettier from 'eslint-config-prettier/flat' export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(['dist']), { - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -21,4 +21,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]); +]) diff --git a/frontend/src/index.css b/frontend/src/index.css index df1be6e..28eae6f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -11,8 +11,8 @@ --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: system-ui, "Segoe UI", Roboto, sans-serif; - --heading: system-ui, "Segoe UI", Roboto, sans-serif; + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; font: 18px/145% var(--sans); diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index e8ee517..a9d0dd3 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1 +1 @@ -import '@testing-library/jest-dom/vitest' \ No newline at end of file +import '@testing-library/jest-dom/vitest' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c90c55e..37a303c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,13 +1,13 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vitest/config"; +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], - base: "/project2", + base: '/project2', test: { globals: true, - environment: "jsdom", - setupFiles: "./src/setupTests.ts", + environment: 'jsdom', + setupFiles: './src/setupTests.ts', }, -}); +}) From 5a97d46a62ee2bb5db81595a377e1f3576ae4814 Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Mon, 5 Oct 2026 13:17:03 +0200 Subject: [PATCH 5/7] Style: format merged submit form and tests Runs Prettier on files that came in with the submit sighting form and the unit tests, so the CI format check passes. No behavior changes. --- frontend/src/components/Banner.tsx | 4 +- frontend/src/components/LocationPicker.css | 2 +- frontend/src/components/LocationPicker.tsx | 11 +- frontend/src/data/sighting-store.ts | 2 +- frontend/src/hooks/use-sightings.ts | 2 +- frontend/src/pages/SubmitSightingPage.css | 2 +- frontend/src/pages/SubmitSightingPage.tsx | 278 +++++++++++++++--- frontend/src/tests/Banner.test.tsx | 12 +- frontend/src/tests/SearchBar.test.tsx | 76 ++--- frontend/src/tests/SearchPage.test.tsx | 164 ++++++----- .../src/tests/SightingDetailPage.test.tsx | 96 +++--- frontend/src/tests/SightingList.test.tsx | 55 ++-- frontend/src/tests/SightingMap.test.tsx | 146 +++++---- frontend/src/tests/filter-sightings.test.ts | 33 ++- frontend/src/tests/format-sighting.test.ts | 2 +- frontend/src/tests/hooks.test.tsx | 2 +- 16 files changed, 577 insertions(+), 310 deletions(-) diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx index 8c4f53f..71c0d03 100644 --- a/frontend/src/components/Banner.tsx +++ b/frontend/src/components/Banner.tsx @@ -8,7 +8,9 @@ function Banner() { UFOund it - Report a sighting + + Report a sighting + ) diff --git a/frontend/src/components/LocationPicker.css b/frontend/src/components/LocationPicker.css index a9752b0..2a2cfea 100644 --- a/frontend/src/components/LocationPicker.css +++ b/frontend/src/components/LocationPicker.css @@ -14,4 +14,4 @@ .location-picker { height: 280px; } -} \ No newline at end of file +} diff --git a/frontend/src/components/LocationPicker.tsx b/frontend/src/components/LocationPicker.tsx index 01496e2..c136ade 100644 --- a/frontend/src/components/LocationPicker.tsx +++ b/frontend/src/components/LocationPicker.tsx @@ -21,7 +21,9 @@ interface LocationPickerProps { onPositionChange: (position: [number, number]) => void } -function MapClickHandler({ onPositionChange }: Pick) { +function MapClickHandler({ + onPositionChange, +}: Pick) { useMapEvents({ click: (event) => onPositionChange([event.latlng.lat, event.latlng.lng]), }) @@ -30,7 +32,10 @@ function MapClickHandler({ onPositionChange }: Pick +
) { sightings = [newSighting, ...sightings] listeners.forEach((listener) => listener()) return newSighting -} \ No newline at end of file +} diff --git a/frontend/src/hooks/use-sightings.ts b/frontend/src/hooks/use-sightings.ts index cd90ac3..96a532c 100644 --- a/frontend/src/hooks/use-sightings.ts +++ b/frontend/src/hooks/use-sightings.ts @@ -3,4 +3,4 @@ import { getSightings, subscribeToSightings } from '../data/sighting-store' export function useSightings() { return useSyncExternalStore(subscribeToSightings, getSightings, getSightings) -} \ No newline at end of file +} diff --git a/frontend/src/pages/SubmitSightingPage.css b/frontend/src/pages/SubmitSightingPage.css index 4e7bdaf..54b3bff 100644 --- a/frontend/src/pages/SubmitSightingPage.css +++ b/frontend/src/pages/SubmitSightingPage.css @@ -117,4 +117,4 @@ .submit-sighting__row { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 2507d4d..09bb19b 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -44,10 +44,20 @@ function validate(values: FormValues): FormErrors { errors.durationSeconds = 'Enter a duration of zero seconds or more.' } if (!values.comments.trim()) errors.comments = 'Describe what you saw.' - if (!values.latitude || !Number.isFinite(latitude) || latitude < -90 || latitude > 90) { + if ( + !values.latitude || + !Number.isFinite(latitude) || + latitude < -90 || + latitude > 90 + ) { errors.latitude = 'Enter a latitude between -90 and 90.' } - if (!values.longitude || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + if ( + !values.longitude || + !Number.isFinite(longitude) || + longitude < -180 || + longitude > 180 + ) { errors.longitude = 'Enter a longitude between -180 and 180.' } return errors @@ -91,14 +101,25 @@ function SubmitSightingPage() { if (submittedId) { return (
- Back to sightings + + Back to sightings +

Sighting submitted

Your sighting has been added to the collection.

- + View your sighting -
@@ -108,62 +129,221 @@ function SubmitSightingPage() { return (
- Back to sightings + + Back to sightings +

Report a sighting

-

Tell us what you saw. Fields marked with an asterisk are required.

-
+

+ Tell us what you saw. Fields marked with an asterisk are required. +

+
- - updateValue('datetime', event.target.value)} aria-invalid={Boolean(errors.datetime)} aria-describedby={errors.datetime ? 'sighting-datetime-error' : undefined} required /> - {errors.datetime &&

{errors.datetime}

} + + updateValue('datetime', event.target.value)} + aria-invalid={Boolean(errors.datetime)} + aria-describedby={ + errors.datetime ? 'sighting-datetime-error' : undefined + } + required + /> + {errors.datetime && ( +

+ {errors.datetime} +

+ )}
- - updateValue('city', event.target.value)} aria-invalid={Boolean(errors.city)} aria-describedby={errors.city ? 'sighting-city-error' : undefined} required /> - {errors.city &&

{errors.city}

} + + updateValue('city', event.target.value)} + aria-invalid={Boolean(errors.city)} + aria-describedby={errors.city ? 'sighting-city-error' : undefined} + required + /> + {errors.city && ( +

+ {errors.city} +

+ )}
- updateValue('state', event.target.value)} /> + updateValue('state', event.target.value)} + />
- - updateValue('country', event.target.value)} aria-invalid={Boolean(errors.country)} aria-describedby={errors.country ? 'sighting-country-error' : undefined} required /> - {errors.country &&

{errors.country}

} + + updateValue('country', event.target.value)} + aria-invalid={Boolean(errors.country)} + aria-describedby={ + errors.country ? 'sighting-country-error' : undefined + } + required + /> + {errors.country && ( +

+ {errors.country} +

+ )}
- - updateValue('shape', event.target.value)} + aria-invalid={Boolean(errors.shape)} + aria-describedby={ + errors.shape ? 'sighting-shape-error' : undefined + } + required + > - {['circle', 'cylinder', 'disk', 'fireball', 'formation', 'light', 'rectangle', 'sphere', 'triangle', 'other'].map((shape) => )} + {[ + 'circle', + 'cylinder', + 'disk', + 'fireball', + 'formation', + 'light', + 'rectangle', + 'sphere', + 'triangle', + 'other', + ].map((shape) => ( + + ))} - {errors.shape &&

{errors.shape}

} + {errors.shape && ( +

+ {errors.shape} +

+ )}
- - updateValue('durationSeconds', event.target.value)} aria-invalid={Boolean(errors.durationSeconds)} aria-describedby={errors.durationSeconds ? 'sighting-duration-error' : undefined} required /> - {errors.durationSeconds &&

{errors.durationSeconds}

} + + + updateValue('durationSeconds', event.target.value) + } + aria-invalid={Boolean(errors.durationSeconds)} + aria-describedby={ + errors.durationSeconds ? 'sighting-duration-error' : undefined + } + required + /> + {errors.durationSeconds && ( +

+ {errors.durationSeconds} +

+ )}
- - updateValue('latitude', event.target.value)} aria-invalid={Boolean(errors.latitude)} aria-describedby={errors.latitude ? 'sighting-latitude-error' : undefined} required /> - {errors.latitude &&

{errors.latitude}

} + + updateValue('latitude', event.target.value)} + aria-invalid={Boolean(errors.latitude)} + aria-describedby={ + errors.latitude ? 'sighting-latitude-error' : undefined + } + required + /> + {errors.latitude && ( +

+ {errors.latitude} +

+ )}
- - updateValue('longitude', event.target.value)} aria-invalid={Boolean(errors.longitude)} aria-describedby={errors.longitude ? 'sighting-longitude-error' : undefined} required /> - {errors.longitude &&

{errors.longitude}

} + + updateValue('longitude', event.target.value)} + aria-invalid={Boolean(errors.longitude)} + aria-describedby={ + errors.longitude ? 'sighting-longitude-error' : undefined + } + required + /> + {errors.longitude && ( +

+ {errors.longitude} +

+ )}

Where did it happen?

-

Click the map to place the marker, or enter coordinates above.

+

+ Click the map to place the marker, or enter coordinates above. +

- -