diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f92dcf8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + 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: + 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 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/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/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/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. +

- -