From 9d328bc2b23b5303652a9b704f033ccd88e05d8d Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 1/9] =?UTF-8?q?fix(css):=20=C3=B8k=20kontrasten=20p=C3=A5?= =?UTF-8?q?=20aksentfargen=20og=20bruk=20relative=20skriftst=C3=B8rrelser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bytt aksentfargen i lys modus fra #aa3bff til #8b2fd9. Den gamle ga 3,8:1 på aksentflaten, under kravet på 4,5:1 i WCAG AA. Den nye gir 5,1:1 på aksentflaten og 6,0:1 på hvit. - Sett grunnstørrelsen i prosent og overskriftene i rem, slik at teksten følger nettleserens innstilling for tekststørrelse. - Gi h1 egen linjehøyde, så linjene ikke overlapper når overskriften brytes. Margene er justert, så innholdet flytter seg høyst 3,8 px. Refs #17 Co-Authored-By: Claude Opus 5 --- src/index.css | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/index.css b/src/index.css index e50c1b9..9d52b7d 100644 --- a/src/index.css +++ b/src/index.css @@ -4,9 +4,11 @@ --bg: #fff; --border: #e5e4e7; --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); + /* Aksentfargen gir minst 4,5:1 kontrast mot både hvit og --accent-bg, + som WCAG AA krever for vanlig tekst. */ + --accent: #8b2fd9; + --accent-bg: rgba(139, 47, 217, 0.1); + --accent-border: rgba(139, 47, 217, 0.5); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; @@ -20,7 +22,9 @@ --radius: 6px; --radius-lg: 12px; - font: 18px/145% var(--sans); + /* Prosent av nettleserens standard tekststørrelse (vanligvis 16 px), slik at + brukerens egen innstilling følges. 112,5 % gir 18 px. */ + font: 112.5%/145% var(--sans); letter-spacing: 0.18px; color-scheme: light dark; color: var(--text); @@ -31,7 +35,7 @@ -moz-osx-font-smoothing: grayscale; @media (max-width: 1024px) { - font-size: 16px; + font-size: 100%; } } @@ -80,31 +84,36 @@ h2 { color: var(--text-h); } +/* Overskriftene bruker rem, så de følger tekststørrelsen i nettleseren. + Pikselverdiene i kommentarene gjelder standardinnstillingen. */ h1 { - font-size: 56px; + font-size: 3.125rem; /* 56 px */ + /* Egen linjehøyde, slik at linjene ikke overlapper hvis overskriften + brytes, for eksempel med stor tekst i nettleseren. */ + line-height: 1.1; letter-spacing: -1.68px; - margin: 32px 0; + margin: 16px 0; @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; + font-size: 2.25rem; /* 36 px */ + margin: 12px 0; } @media (max-width: 480px) { - font-size: 30px; - margin: 16px 0; + font-size: 1.875rem; /* 30 px */ + margin: 10px 0; } /* Lav skjerm, for eksempel mobil i liggende format. */ @media (max-height: 500px) { - font-size: 28px; - margin: 12px 0; + font-size: 1.75rem; /* 28 px på mobil i liggende format */ + margin: 8px 0; } } h2 { - font-size: 24px; + font-size: 1.3333rem; /* 24 px */ line-height: 118%; letter-spacing: -0.24px; margin: 0 0 8px; @media (max-width: 1024px) { - font-size: 20px; + font-size: 1.25rem; /* 20 px */ } } p { From 2c508619fd58f75413e0db7adc31ea5be83e0d8a Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 2/9] fix(src): vis ikke forrige lands flagg mens nytt flagg lastes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nettleseren gjenbrukte bildeelementet og viste flagget til forrige land til det nye var lastet, for eksempel «Brazil» med Bangladeshs flagg. key={flagUrl} gir et nytt bildeelement for hvert flagg. Testen feiler uten rettelsen. Refs #17 Co-Authored-By: Claude Opus 5 --- src/components/CountryCard.test.tsx | 20 ++++++++++++++++++++ src/components/CountryCard.tsx | 3 +++ 2 files changed, 23 insertions(+) diff --git a/src/components/CountryCard.test.tsx b/src/components/CountryCard.test.tsx index ade5e7d..6d92e17 100644 --- a/src/components/CountryCard.test.tsx +++ b/src/components/CountryCard.test.tsx @@ -54,6 +54,26 @@ describe('CountryCard', () => { expect(screen.getByText('Flagg ikke tilgjengelig')).toBeInTheDocument(); }); + it('lager et nytt bildeelement for nytt land, så forrige flagg ikke vises mens det nye lastes', () => { + const { rerender } = render(); + const firstFlag = screen.getByRole('img', { name: 'Flagget til Norway' }); + + rerender( + , + ); + + expect(firstFlag).not.toBeInTheDocument(); + expect( + screen.getByRole('img', { name: 'Flagget til Sweden' }), + ).toHaveAttribute('src', 'https://example.test/flags/swe.svg'); + }); + it('rendrer children ved siden av landnavnet', () => { const country = createMockCountry(); render( diff --git a/src/components/CountryCard.tsx b/src/components/CountryCard.tsx index a667656..c6b2789 100644 --- a/src/components/CountryCard.tsx +++ b/src/components/CountryCard.tsx @@ -30,7 +30,10 @@ function CountryCard({ country, children }: CountryCardProps) { return (
{showFlag ? ( + // key gir et nytt bildeelement for hvert flagg. Ellers viser nettleseren + // forrige lands flagg til det nye er lastet. {`Flagget Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 3/9] feat(src): legg til snarvei forbi landlisten og kildehenvisning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Hopp til landkortet» er første tabulatorstopp, så tastaturbrukere slipper å tabbe gjennom 25 land. Målet får fokus og viser en ramme ved tastaturbruk. - Bunnteksten oppgir countries.dev, GeoNames (CC BY 4.0) og Flagpedia.net. CC BY krever at kilden oppgis der dataene vises. Refs #17 Co-Authored-By: Claude Opus 5 --- src/App.css | 62 +++++++++++++++++++++++++++ src/App.tsx | 118 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 132 insertions(+), 48 deletions(-) diff --git a/src/App.css b/src/App.css index cec10fb..0a49f1c 100644 --- a/src/App.css +++ b/src/App.css @@ -1,7 +1,69 @@ .app { + /* Snarveien plasseres i forhold til hovedinnholdet. */ + position: relative; padding: 0 16px 32px; } +/* Snarveien ligger over skjermkanten til den får fokus med tastaturet. */ +.app__skip-link { + position: absolute; + top: 8px; + left: 16px; + z-index: 1; + padding: 8px 14px; + color: var(--accent); + background: var(--bg); + border: 2px solid var(--accent); + border-radius: var(--radius); + transform: translateY(-200%); +} + +.app__skip-link:focus { + transform: none; +} + +.app__skip-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Målet for snarveien får fokus via tabindex="-1". Etter snarveien viser en + ramme hvor fokus havnet, og neste Tab går til første aktive knapp i + navigasjonen. Et museklikk i kortet gir ingen ramme. */ +.app__country { + /* Luft over målet når snarveien scroller dit, så hele rammen synes. */ + scroll-margin-top: 16px; +} + +.app__country:focus { + outline: none; +} + +.app__country:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 8px; + border-radius: var(--radius-lg); +} + +/* #root er en flex-kolonne, så margin-top: auto legger bunnteksten nederst + også når innholdet er kort. */ +.app-footer { + margin-top: auto; + padding: 16px; + font-size: 0.875rem; + color: var(--text); + border-top: 1px solid var(--border); +} + +.app-footer a { + color: var(--accent); +} + +.app-footer a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* Meny til venstre og valgt land til høyre. Menyen smalner på mellomstore skjermer, og på smale skjermer ligger den over landet. */ .app__layout { diff --git a/src/App.tsx b/src/App.tsx index 007897f..6969d84 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,61 +83,83 @@ function App() { } else { // Menyen, navigasjonen og kortet bruker samme utvalg og samme land. content = ( -
- - - - + <> + {/* Første tabulatorstopp. Lar tastaturbrukere hoppe forbi kontrollene + og hele landlisten i sidemenyen. */} + + Hopp til landkortet + +
+ + + + -
- {selectedCountry ? ( - <> - - - {/* Favorittstatus slås opp fra valgt landkode ved hver render, - så den er alltid riktig når brukeren blar mellom land. */} - toggleFavorite(selectedCountry.code)} + {/* tabIndex={-1} gjør at snarveien kan flytte fokus hit. */} +
+ {selectedCountry ? ( + <> + - - - ) : ( - // Tomt utvalg er bare mulig når favorittfilteret er på, for eksempel - // uten favoritter eller etter at siste favoritt er fjernet. -
-

Du har ingen favorittland å vise.

- -
- )} + + {/* Favorittstatus slås opp fra valgt landkode ved hver render, + så den er alltid riktig når brukeren blar mellom land. */} + toggleFavorite(selectedCountry.code)} + /> + + + ) : ( + // Tomt utvalg er bare mulig når favorittfilteret er på, for eksempel + // uten favoritter eller etter at siste favoritt er fjernet. +
+

Du har ingen favorittland å vise.

+ +
+ )} +
-
+ ); } return ( -
-

Utforsk verden

- {content} -
+ <> +
+

Utforsk verden

+ {content} +
+ {/* Kildehenvisning. Dataene fra GeoNames er lisensiert under CC BY 4.0, + som krever at kilden oppgis der dataene vises. */} + + ); } From 63faff27e1e910e0054e6188deaa9881dade61d1 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 4/9] test(src): legg til tester for sortering, favorittfilter og ny henting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SortControl og FavoritesFilter: props og brukerinteraksjon. - useSortOrder, useShowOnlyFavorites og favoritesFilterStorage: startverdi, lagring og blokkert lagring. - App: sorteringen huskes etter ny montering, valgt land beholdes ved ny sortering, data vises videre når en ny henting feiler, snarveien og kildehenvisningen. - CountryNavigation med bare ett land. Antall tester går fra 52 til 70. Refs #17 Co-Authored-By: Claude Opus 5 --- src/App.test.tsx | 95 ++++++++++++++++++++++- src/components/CountryNavigation.test.tsx | 14 ++++ src/components/FavoritesFilter.test.tsx | 29 +++++++ src/components/SortControl.test.tsx | 27 +++++++ src/hooks/useShowOnlyFavorites.test.ts | 26 +++++++ src/hooks/useSortOrder.test.ts | 26 +++++++ src/utils/favoritesFilterStorage.test.ts | 49 ++++++++++++ 7 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/components/FavoritesFilter.test.tsx create mode 100644 src/components/SortControl.test.tsx create mode 100644 src/hooks/useShowOnlyFavorites.test.ts create mode 100644 src/hooks/useSortOrder.test.ts create mode 100644 src/utils/favoritesFilterStorage.test.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 2ae0041..a2272ab 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { createTestQueryClient } from './test/queryClient'; @@ -17,13 +17,15 @@ function stubFetchResolved(json: unknown, ok = true, status = 200) { ); } +// Returnerer også QueryClient-en, slik at en test kan starte en ny henting. function renderApp() { const queryClient = createTestQueryClient(); - return render( + const result = render( , ); + return { ...result, queryClient }; } describe('App', () => { @@ -131,4 +133,93 @@ describe('App', () => { screen.getByText('Du har ingen favorittland å vise.'), ).toBeInTheDocument(); }); + + it('beholder valgt land ved ny sortering og husker sorteringen etter ny innlasting', async () => { + stubFetchResolved(RAW_COUNTRIES); + + const firstLoad = renderApp(); + // Standardsorteringen er alfabetisk, så Norway vises først. + await screen.findByText('Land 1 av 2'); + + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Sorter etter' }), + 'population', + ); + + // Sweden har flest innbyggere og kommer først, men landet som vises, beholdes. + expect(screen.getByText('Land 2 av 2')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + + // En ny montering med samme sessionStorage tilsvarer reload i samme fane. + firstLoad.unmount(); + renderApp(); + + await screen.findByText('Land 1 av 2'); + expect(screen.getByRole('combobox', { name: 'Sorter etter' })).toHaveValue( + 'population', + ); + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + }); + + it('viser landene videre når en ny henting feiler i bakgrunnen', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => RAW_COUNTRIES, + }); + vi.stubGlobal('fetch', fetchMock); + + const { queryClient } = renderApp(); + await screen.findByText('Land 1 av 2'); + + // Neste henting feiler, for eksempel ved gjenoppkobling etter lang tid. + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: async () => null, + }); + await act(() => queryClient.refetchQueries({ queryKey: ['countries'] })); + + await waitFor(() => + expect(queryClient.getQueryState(['countries'])?.status).toBe('error'), + ); + expect(screen.getByText('Land 1 av 2')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('har en snarvei forbi landlisten som første tabulatorstopp', async () => { + stubFetchResolved(RAW_COUNTRIES); + + renderApp(); + await screen.findByText('Land 1 av 2'); + + const skipLink = screen.getByRole('link', { name: 'Hopp til landkortet' }); + await userEvent.tab(); + expect(skipLink).toHaveFocus(); + + // Målet kan få fokus og inneholder navigasjonen og landkortet. + expect(skipLink).toHaveAttribute('href', '#landkort'); + const target = document.getElementById('landkort'); + expect(target).toHaveAttribute('tabindex', '-1'); + expect(target).toContainElement( + screen.getByRole('navigation', { name: 'Bla mellom land' }), + ); + expect(target).toContainElement( + screen.getByRole('article', { name: 'Norway' }), + ); + }); + + it('oppgir kilden til landdataene og flaggene', () => { + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + + renderApp(); + + expect(screen.getByRole('contentinfo')).toHaveTextContent( + 'Landdata fra countries.dev, basert på GeoNames (CC BY 4.0). Flagg fra Flagpedia.net.', + ); + expect(screen.getByRole('link', { name: 'countries.dev' })).toHaveAttribute( + 'href', + 'https://countries.dev', + ); + }); }); diff --git a/src/components/CountryNavigation.test.tsx b/src/components/CountryNavigation.test.tsx index de7729b..8388fde 100644 --- a/src/components/CountryNavigation.test.tsx +++ b/src/components/CountryNavigation.test.tsx @@ -91,6 +91,20 @@ describe('CountryNavigation', () => { expect(screen.getByRole('button', { name: 'Neste' })).toBeDisabled(); }); + it('deaktiverer begge knappene når utvalget har ett land', () => { + render( + , + ); + + expect(screen.getByText('Land 1 av 1')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Forrige' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Neste' })).toBeDisabled(); + }); + it('rendrer ingenting når valgt kode ikke finnes i utvalget', () => { const { container } = render( { + it('viser valget fra props og sender ny verdi ved klikk', async () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + const checkbox = screen.getByRole('checkbox', { + name: 'Vis bare favoritter', + }); + expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + expect(onChange).toHaveBeenLastCalledWith(true); + + // Komponenten eier ingen state selv. Den viser ny verdi først når + // forelderen sender den inn som props. + expect(checkbox).not.toBeChecked(); + rerender(); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + expect(onChange).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/src/components/SortControl.test.tsx b/src/components/SortControl.test.tsx new file mode 100644 index 0000000..2d91e86 --- /dev/null +++ b/src/components/SortControl.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import SortControl from './SortControl'; + +describe('SortControl', () => { + it('viser valgt sortering fra props', () => { + render(); + + expect(screen.getByRole('combobox', { name: 'Sorter etter' })).toHaveValue( + 'population', + ); + }); + + it('kaller onChange med sorteringen brukeren velger', async () => { + const onChange = vi.fn(); + render(); + + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Sorter etter' }), + 'Befolkning (høyest først)', + ); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('population'); + }); +}); diff --git a/src/hooks/useShowOnlyFavorites.test.ts b/src/hooks/useShowOnlyFavorites.test.ts new file mode 100644 index 0000000..cdb6e58 --- /dev/null +++ b/src/hooks/useShowOnlyFavorites.test.ts @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FAVORITES_FILTER_STORAGE_KEY } from '../utils/favoritesFilterStorage'; +import { useShowOnlyFavorites } from './useShowOnlyFavorites'; + +describe('useShowOnlyFavorites', () => { + it('viser alle land når ingenting er lagret', () => { + const { result } = renderHook(() => useShowOnlyFavorites()); + + expect(result.current[0]).toBe(false); + }); + + it('starter med lagret valg og lagrer nytt valg i sessionStorage', () => { + sessionStorage.setItem(FAVORITES_FILTER_STORAGE_KEY, 'true'); + + const { result } = renderHook(() => useShowOnlyFavorites()); + expect(result.current[0]).toBe(true); + + act(() => { + result.current[1](false); + }); + + expect(result.current[0]).toBe(false); + expect(sessionStorage.getItem(FAVORITES_FILTER_STORAGE_KEY)).toBe('false'); + }); +}); diff --git a/src/hooks/useSortOrder.test.ts b/src/hooks/useSortOrder.test.ts new file mode 100644 index 0000000..7a9252c --- /dev/null +++ b/src/hooks/useSortOrder.test.ts @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { SORT_ORDER_STORAGE_KEY } from '../utils/sortOrderStorage'; +import { useSortOrder } from './useSortOrder'; + +describe('useSortOrder', () => { + it('starter med standardvalget når ingenting er lagret', () => { + const { result } = renderHook(() => useSortOrder()); + + expect(result.current[0]).toBe('name'); + }); + + it('starter med lagret valg og lagrer nytt valg i sessionStorage', () => { + sessionStorage.setItem(SORT_ORDER_STORAGE_KEY, 'population'); + + const { result } = renderHook(() => useSortOrder()); + expect(result.current[0]).toBe('population'); + + act(() => { + result.current[1]('name'); + }); + + expect(result.current[0]).toBe('name'); + expect(sessionStorage.getItem(SORT_ORDER_STORAGE_KEY)).toBe('name'); + }); +}); diff --git a/src/utils/favoritesFilterStorage.test.ts b/src/utils/favoritesFilterStorage.test.ts new file mode 100644 index 0000000..1a073dd --- /dev/null +++ b/src/utils/favoritesFilterStorage.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + FAVORITES_FILTER_STORAGE_KEY, + loadShowOnlyFavorites, + saveShowOnlyFavorites, +} from './favoritesFilterStorage'; + +describe('loadShowOnlyFavorites', () => { + it('returnerer false når ingenting er lagret', () => { + expect(loadShowOnlyFavorites()).toBe(false); + }); + + it('returnerer true bare når lagret verdi er "true"', () => { + sessionStorage.setItem(FAVORITES_FILTER_STORAGE_KEY, 'true'); + expect(loadShowOnlyFavorites()).toBe(true); + + sessionStorage.setItem(FAVORITES_FILTER_STORAGE_KEY, 'false'); + expect(loadShowOnlyFavorites()).toBe(false); + + sessionStorage.setItem(FAVORITES_FILTER_STORAGE_KEY, 'ja'); + expect(loadShowOnlyFavorites()).toBe(false); + }); + + it('returnerer false når lesing kaster feil', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(loadShowOnlyFavorites()).toBe(false); + }); +}); + +describe('saveShowOnlyFavorites', () => { + it('lagrer valget som tekst', () => { + saveShowOnlyFavorites(true); + expect(sessionStorage.getItem(FAVORITES_FILTER_STORAGE_KEY)).toBe('true'); + + saveShowOnlyFavorites(false); + expect(sessionStorage.getItem(FAVORITES_FILTER_STORAGE_KEY)).toBe('false'); + }); + + it('kaster ikke feil når lagring er blokkert', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(() => saveShowOnlyFavorites(true)).not.toThrow(); + }); +}); From 6061b761d9c7fb336c71cea58534d38f4c1aef9c Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 5/9] chore(assets): fjern ubrukte Vite-filer og bruk eget favikon hero.png, react.svg, vite.svg og icons.svg fra Vite-malen var ikke i bruk. Favikonet var Vite-logoen og er byttet ut med en jordklode. Refs #17 Co-Authored-By: Claude Opus 5 --- public/favicon.svg | 9 ++++++++- public/icons.svg | 24 ------------------------ src/assets/hero.png | Bin 13057 -> 0 bytes src/assets/react.svg | 1 - src/assets/vite.svg | 1 - 5 files changed, 8 insertions(+), 27 deletions(-) delete mode 100644 public/icons.svg delete mode 100644 src/assets/hero.png delete mode 100644 src/assets/react.svg delete mode 100644 src/assets/vite.svg diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..acf52c6 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,8 @@ - \ No newline at end of file + + + + + + + + diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite From e935a596851db3e2250f47e5b3d8c1f4f7475558 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 6/9] build(config): legg eslint-config-prettier sist i ESLint-oppsettet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eslint-config-prettier må ligge sist for å slå av regler i konfigurasjonene før den som kan komme i konflikt med Prettier. Refs #17 Co-Authored-By: Claude Opus 5 --- eslint.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index f5e5a3a..fcc5e28 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,6 @@ import tseslint from 'typescript-eslint'; import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ - prettier, globalIgnores(['dist']), { files: ['**/*.{ts,tsx}'], @@ -21,4 +20,6 @@ export default defineConfig([ globals: globals.browser, }, }, + // Sist, slik at den slår av regler over som kan komme i konflikt med Prettier. + prettier, ]); From 036773cabb74453f8e88c9243be86f092f377481 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:26 +0200 Subject: [PATCH 7/9] docs(docs): utvid prosjektstandarden med branches, issue-kobling og PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beskriv navn på branches og at de lages fra issuen. - Skriv Refs #N i commit-meldingene, og før medforfattere og KI-verktøy med Co-authored-by på egne linjer. - Legg til regler for pull requests med Closes #N og godkjenning. - Legg npm run test til i Definition of Done. Refs #17 Co-Authored-By: Claude Opus 5 --- docs/prosjektstandard.md | 49 ++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/prosjektstandard.md b/docs/prosjektstandard.md index 0619007..4b996c0 100644 --- a/docs/prosjektstandard.md +++ b/docs/prosjektstandard.md @@ -1,6 +1,6 @@ # Prosjektstandarder -Dette repoet følger noen enkle standarder for issues, commits og ferdigstilling (DoD). +Dette repoet følger noen enkle standarder for issues, branches, commits, pull requests og ferdigstilling (DoD). --- @@ -36,9 +36,23 @@ Hva som skal gjøres, hvorfor det er nødvendig, og hvilke filer eller deler av --- +## Branches + +Hver issue får sin egen branch fra `main`. Branchen lages fra issuen på GitHub («Create a branch»), slik at den kobles til issuen. + +``` +-- +``` + +Eksempel: `17-docs-dokumentere-løsning-testing-og-ki-bruk-i-readme` + +Branchen slettes etter at pull requesten er merget. + +--- + ## Commit-standard -Vi følger en enkel konvensjon basert på _Conventional Commits_. +Vi følger en enkel konvensjon basert på _Conventional Commits_. Beskrivelsen skrives på norsk i imperativ, for eksempel «legg til», «rett» eller «fjern». **Format** @@ -74,23 +88,43 @@ Bruk et kort område som peker på delen som er endret. ``` feat(app): legg til prosjektoversikt på forsiden -fix(app): rett counter-knappens tekst +fix(app): rett teksten på favorittknappen style(css): juster layout for mobilvisning docs(docs): oppdater prosjektstandard med commit-typer build(deps): oppdater React og Vite-avhengigheter build(config): legg til strengere TypeScript-innstillinger -refactor(src): flytt hero-innhold til egen komponent +refactor(src): flytt sorteringslogikken til utils chore(assets): fjern ubrukte Vite-ikoner ``` -**Parprogrammering** -Hvis dere jobber to sammen, legg til i commit-meldingen: +**Kobling til issue** +Skriv issuenummeret i meldingen, slik at GitHub viser commiten i issuen: + +``` +docs(docs): samle testdokumentasjonen i docs/testing.md + +Refs #17 +``` + +**Medforfattere og KI** +Medforfattere skrives til slutt i meldingen, etter en tom linje, med én linje per person. De må stå på egne linjer for at GitHub skal gjenkjenne dem: ``` Co-authored-by: Ola Nordmann Co-authored-by: Kari Nordmann ``` +Har et KI-verktøy skrevet en vesentlig del av endringen, legges verktøyet til på samme måte, og bruken beskrives i README under «Bruk av KI». + +--- + +## Pull requests + +- Tittelen følger commit-standarden. +- Beskrivelsen forteller hva som er endret, hvorfor og hvordan det er testet, og avsluttes med `Closes #`. Da kobles pull requesten til issuen, og issuen lukkes ved merge. +- Et annet gruppemedlem må godkjenne pull requesten før merge (se `.github/CODEOWNERS`). +- Pull requesten merges til `main` først når Definition of Done under er oppfylt. + --- ## Definition of Done (DoD) @@ -101,6 +135,7 @@ En issue regnes som ferdig når: - `npm run build` kjører uten feil - `npm run lint` kjører uten feil - `npm run format:check` kjører uten feil, eller `npm run format` er kjørt +- `npm run test` kjører uten feil - Endringen er testet manuelt i appen ved behov - Dokumentasjon er oppdatert ved behov -- PR er koblet til riktig issue +- PR er koblet til riktig issue og godkjent av et annet gruppemedlem From 59ff4bf2cc344916f1387dfbb007e3869a231a08 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Fri, 18 Sep 2026 15:35:27 +0200 Subject: [PATCH 8/9] docs(docs): samle og kvalitetssikre dokumentasjonen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skriv om README etter akseptansekriteriene i #17: funksjoner mot kravene, installasjon og kommandoer, datakilde og kildehenvisninger, TanStack Query, state og props med kodehenvisninger, Web Storage, responsivt design, tilgjengelighet, testing, kjente begrensninger, arbeidsflyt og KI-bruk med Cursor og erfaringer. - Samle alle gjennomførte tester i docs/testing.md og fjern utdatert tekst, blant annet en manuell test som fortsatt sto som «gjenstår». - Legg til publiseringsveiledning i docs/publisering.md. - Oppdater docs/accessibility.md med snarvei, kontrast, tekststørrelse og kjente begrensninger. Refs #17 Co-Authored-By: Claude Opus 5 --- README.md | 1024 ++++++++++++----------------------------- docs/accessibility.md | 72 ++- docs/publisering.md | 62 +++ docs/testing.md | 198 ++++++++ 4 files changed, 593 insertions(+), 763 deletions(-) create mode 100644 docs/publisering.md create mode 100644 docs/testing.md diff --git a/README.md b/README.md index f161842..36d5b84 100644 --- a/README.md +++ b/README.md @@ -1,803 +1,347 @@ # Utforsk verden -[Åpne appen på gruppas VM](http://it2810-36.idi.ntnu.no/project1/) +**Publisert app:** [http://it2810-36.idi.ntnu.no/project1/](http://it2810-36.idi.ntnu.no/project1/) (krever NTNUs nett eller VPN) + +Gruppeprosjekt 1 i IT2810 Webutvikling, høsten 2026, gruppe T36. + +Utforsk verden henter data om de 25 mest folkerike landene i verden fra et åpent REST-API og viser ett land om gangen. Brukeren kan bla mellom landene, hoppe direkte til et land, sortere listen, markere favoritter og vise bare favorittene. + +Mer dokumentasjon: + +- [Testing](docs/testing.md): automatiske tester og alle gjennomførte tester i nettlesere og på enheter +- [Tilgjengelighet](docs/accessibility.md): sjekkliste for universell utforming +- [Publisering](docs/publisering.md): slik legges appen ut på gruppas VM +- [Prosjektstandard](docs/prosjektstandard.md): issues, branches, commits, pull requests og Definition of Done + +## Innhold + +1. [Funksjoner](#funksjoner) +2. [Kom i gang](#kom-i-gang) +3. [Teknologi og struktur](#teknologi-og-struktur) +4. [Datakilde og datautvalg](#datakilde-og-datautvalg) +5. [Henting med TanStack Query](#henting-med-tanstack-query) +6. [State og props i React](#state-og-props-i-react) +7. [Lagring i nettleseren](#lagring-i-nettleseren) +8. [Responsivt design](#responsivt-design) +9. [Tilgjengelighet](#tilgjengelighet) +10. [Testing](#testing) +11. [Kjente begrensninger](#kjente-begrensninger) +12. [Arbeidsflyt](#arbeidsflyt) +13. [Bruk av KI](#bruk-av-ki) + +## Funksjoner + +| Krav i oppgaven | Slik er det løst | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Én ressurs om gangen, bla frem og tilbake og hoppe direkte | Landkort med «Forrige» og «Neste», og en klikkbar landliste i sidemenyen | +| Valg som påvirker utvalget og huskes ved reload | Sortering etter navn eller befolkning og filteret «Vis bare favoritter», lagret i sessionStorage | +| Favoritter som huskes etter at nettleseren lukkes | Knappen «Favoritt» med stjerne på landkortet, lagret i localStorage | +| Responsivt design | Sidemeny og landkort ved siden av hverandre på store skjermer, én kolonne på mobil og eget oppsett for mobil i liggende format | +| REST-API med TanStack Query | Ett kall til countries.dev per sidelast, se [Henting med TanStack Query](#henting-med-tanstack-query) | +| Tilgjengelig HTML | Semantiske elementer, tastaturstøtte, synlig fokus og god kontrast, se [Tilgjengelighet](#tilgjengelighet) | + +- **Landkort:** Navn, flagg, hovedstad, region, befolkning, areal og språk. Tall vises med norsk format (`1 402 112 000` og `km²`), og flere språk skrives som «Hindi og English». Manglende verdier vises som «Ikke oppgitt», og manglende flagg som «Flagg ikke tilgjengelig». +- **Blaing:** «Forrige», «Neste» og posisjonen «Land 3 av 25» over kortet. Knappene er deaktivert ved første og siste land. +- **Landliste:** Sidemenyen viser alle land i utvalget. Valgt land er markert, og favoritter har en stjerne. På mobil ligger listen bak knappen «Landliste (25)». +- **Sortering:** «Navn (A–Å)» etter norsk alfabet, eller «Befolkning (høyest først)». Landet som vises, beholdes når sorteringen endres. +- **Favoritter og filter:** Stjerneknappen legger landet til eller fjerner det som favoritt. «Vis bare favoritter» begrenser landlisten, blaingen og posisjonen til favorittene. Er filteret på uten favoritter, vises en melding og knappen «Vis alle land». +- **Lasting og feil:** «Laster land …» mens dataene hentes. Ved feil vises en norsk feilmelding og knappen «Prøv igjen», og ved tomt svar vises «Fant ingen land.». +- **Kildehenvisning:** Datakilden og flaggkilden er oppgitt nederst på siden. + +## Kom i gang + +### Krav + +- Node.js 24.6.0 eller nyere. `.nvmrc` peker på Node 24, så med nvm holder det å kjøre `nvm install` og `nvm use`. +- npm 11 eller nyere. + +Kravene står også i `engines` i `package.json`. -Gruppeprosjekt i IT2810 Webutvikling, høsten 2026. -Gruppe: T36. +### Installasjon -Applikasjonen skal la brukeren utforske ett land om gangen, -bla mellom land, sortere visningen og lagre favoritter. +```bash +git clone git@git.ntnu.no:IT2810-H26/T36-Project-1.git +cd T36-Project-1 +npm ci +``` -## Status +Uten SSH-nøkkel kan repoet klones med `https://git.ntnu.no/IT2810-H26/T36-Project-1.git`. `npm ci` installerer nøyaktig de versjonene som står i `package-lock.json`. Appen trenger ingen API-nøkkel eller `.env`-fil. -Prosjektoppsettet er på plass med React, TypeScript, Vite, -ESLint og Prettier. Appen henter landdata fra et REST-API med -TanStack Query og viser ett land om gangen i et landkort. -Brukeren kan bla med forrige/neste, velge land direkte i en sidemeny og -sortere landene etter navn eller befolkning. Sorteringsvalget huskes etter -reload i samme fane. Brukeren kan markere favorittland, og favorittene -huskes mellom nettleserøkter. Et filter kan vise bare favorittland, og -filtervalget huskes etter reload i samme fane. Layouten tilpasser seg -desktop, nettbrett og mobil i både stående og liggende format. +### Kommandoer -## Krav +| Kommando | Hva den gjør | +| ---------------------- | ------------------------------------------------------------- | +| `npm run dev` | Starter utviklingsserveren på http://localhost:5173/project1/ | +| `npm run build` | Typesjekker med `tsc -b` og bygger appen til `dist/` | +| `npm run preview` | Viser produksjonsbygget på http://localhost:4173/project1/ | +| `npm run lint` | Kontrollerer koden med ESLint | +| `npm run format:check` | Kontrollerer formateringen med Prettier | +| `npm run format` | Formaterer alle filer med Prettier | +| `npm run test` | Kjører alle testene én gang med Vitest | +| `npm run test:watch` | Kjører testene på nytt ved hver endring | -- Node.js 24.6.0 eller nyere. Gruppen bruker Node 24. -- npm 11 eller nyere. +Adressene slutter på `/project1/` fordi appen er bygget for å ligge i den mappen på VM-en. Stopp en server med Ctrl+C. -Hvis du bruker nvm: +Kjør alle kontrollene med én kommando, for eksempel før en pull request: ```bash -nvm install -nvm use +npm run lint && npm run format:check && npm run test && npm run build ``` -## Installasjon +Slik legges appen ut på VM-en: se [publiseringsveiledningen](docs/publisering.md). -Klon repoet og gå inn i prosjektmappen: +## Teknologi og struktur -```bash -git clone git@git.ntnu.no:IT2810-H26/T36-Project-1.git -cd T36-Project-1 -``` +- React 19 og TypeScript, satt opp med `npm create vite@latest` og malen for React og TypeScript +- TanStack Query 5 til henting fra REST-API-et +- Vanlig CSS, én fil per komponent. Ingen UI-biblioteker eller ferdige komponenter. +- ESLint og Prettier +- Vitest og Testing Library -Installer avhengigheter fra låsefilen: - -```bash -npm ci +```text +src/ +├── api/countries.ts fetchCountries: henter, kontrollerer og gjør om API-svaret +├── hooks/ useCountries (TanStack Query) og hooks for lagrede valg +├── components/ CountryCard, CountryMenu, CountryNavigation, SortControl, +│ FavoritesFilter og FavoriteButton, hver med CSS og test +├── utils/ sortering og lesing og lagring i Web Storage +├── types/country.ts typen Country +├── test/ felles testoppsett og hjelpefunksjoner +├── App.tsx eier valgt land og utvalget og setter sammen komponentene +└── main.tsx oppretter QueryClient og starter React +docs/ testing, tilgjengelighet, publisering og prosjektstandard ``` -## Lokal utvikling +Dataene flyter én vei, fra API-et gjennom `App` og ned til komponentene: -```bash -npm run dev +```text +countries.dev ─▶ fetchCountries ─▶ useCountries (cache i TanStack Query) + │ data + ▼ + App: sorter ▶ filtrer ▶ finn valgt land + │ props │ props │ props + ▼ ▼ ▼ + CountryMenu CountryNavigation CountryCard + (SortControl, FavoritesFilter) (FavoriteButton) ``` -Åpne adressen som vises i terminalen, typisk -http://localhost:5173/project1/. -Stopp utviklingsserveren med Ctrl + C. +## Datakilde og datautvalg -## Kodekontroll og formatering +### Kilder -Kontroller koden med ESLint: +- **Landdata:** [countries.dev](https://countries.dev) ([dokumentasjon](https://countries.dev/docs)). API-et er gratis, krever ingen nøkkel og tillater kall direkte fra nettleseren (CORS). Dataene bygger på [GeoNames](https://www.geonames.org) og er lisensiert under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.no), som krever at kilden oppgis. +- **Flagg:** Bildeadressene i API-svaret peker til [flagcdn.com](https://flagcdn.com), en gratis tjeneste fra [Flagpedia.net](https://flagpedia.net), som ber om en lenke tilbake. +- Begge kildene er oppgitt nederst i appen ([`App.tsx`](src/App.tsx#L152)). -```bash -npm run lint -``` - -Kontroller formatering: +REST Countries var førstevalget, men nyere versjoner av det API-et krever registrering. -```bash -npm run format:check -``` +### Datautvalg -Formater filene automatisk: +Appen gjør én forespørsel ([`src/api/countries.ts`](src/api/countries.ts#L9)): -```bash -npm run format +```text +https://countries.dev/countries?fields=alpha3Code,name,flags,capital,region,population,area,languages&sort=population&order=desc&limit=25 ``` -## Produksjonsbygg +- **Antall land:** De 25 mest folkerike landene. Antallet settes med `COUNTRY_LIMIT`. Ingen land fra Oseania kommer med i utvalget. +- **Felter:** Bare feltene appen bruker: landkode, navn, flagg, hovedstad, region, befolkning, areal og språk. +- **Identitet:** Landkoden `alpha3Code` (ISO 3166-1 alpha-3, for eksempel `NOR`) brukes som identitet og React-key. +- **Kontroll av svaret:** Svaret gjøres om til typen `Country` i `src/types/country.ts`. Er svaret ikke en array, blir det en feil. Land uten kode eller navn hoppes over. Andre felter kan mangle i API-et og er derfor valgfrie i typen, bortsett fra `languages`, som blir en tom liste. +- **Ingen lagring i nettleseren:** Dataene hentes fra API-et hver gang appen lastes. TanStack Query holder dem i minnet mens appen er åpen, men landdata lagres aldri i localStorage, sessionStorage eller IndexedDB. Bare brukerens valg lagres. -```bash -npm run build -``` +## Henting med TanStack Query -Kommandoen kontrollerer TypeScript og lager produksjonsbygget -i `dist`. +Hentingen skjer i [`useCountries`](src/hooks/useCountries.ts), som kaller `useQuery`. -Forhåndsvis bygget lokalt: +- **Én QueryClient:** Den opprettes én gang utenfor React i [`main.tsx`](src/main.tsx#L8), slik at cachen ikke nullstilles når komponenter rendres på nytt. +- **Fast query key:** [`['countries']`](src/hooks/useCountries.ts#L7). Alle komponenter som bruker hooken, deler samme data og samme forespørsel. +- **Avbrudd:** TanStack Query sender med et `AbortSignal`, som [`fetchCountries`](src/api/countries.ts#L17) sender videre til `fetch`. +- **`staleTime` er én time** ([linje 13](src/hooks/useCountries.ts#L13)). Landdata endres sjelden, og API-et oppgir selv `Cache-Control: max-age=3600`. Så lenge dataene er ferske, gir nye rendringer og nye komponenter ingen nye kall. +- **`refetchOnWindowFocus: false`** ([linje 15](src/hooks/useCountries.ts#L15)), så bytte av fane eller vindu gir ingen nye kall. +- **`refetchOnReconnect` og `refetchOnMount`** har standardverdien `true`, men gjelder bare data som er eldre enn `staleTime`. Kommer nettet tilbake når dataene er mer enn én time gamle, hentes de på nytt. Ellers skjer ingenting. `App` monteres bare én gang. +- **`retry: 1`** ([linje 20](src/hooks/useCountries.ts#L20)) gir ett automatisk nytt forsøk, også etter «Prøv igjen». Deretter vises feilmeldingen. Flere forsøk ville bare gjort feilmeldingen tregere. +- **`gcTime`** har standardverdien fem minutter. Den har ingen betydning her, fordi `App` alltid bruker queryen. +- **Bare i minnet:** Cachen forsvinner ved reload, og da hentes dataene på nytt. +- **Lokale endringer:** Sortering, favoritter, filter, blaing og valg av land skjer på dataene som allerede er hentet, og gir ingen nye kall. -```bash -npm run preview -``` +**Resultat:** Produksjonsbygget gjør ett kall per sidelast. Det er målt i Chrome, Firefox og WebKit, se [testing](docs/testing.md#skriptet-nettlesertest-av-produksjonsbygget-17). -Bygget ligger under `/project1/`, så forhåndsvisningen åpnes på -http://localhost:4173/project1/. +**Utviklingsmodus:** React Strict Mode er beholdt. I `npm run dev` monterer Strict Mode komponentene to ganger. Den første forespørselen avbrytes da, og en ny startes. Nettverksfanen viser derfor én avbrutt og én fullført forespørsel. Dette skjer bare i utvikling. -## Publisering på gruppas VM +### Feilhåndtering -Appen kjører på -[http://it2810-36.idi.ntnu.no/project1/](http://it2810-36.idi.ntnu.no/project1/). -VM-en er tilgjengelig på NTNUs nett eller VPN. +`fetchCountries` gjør feil om til norske meldinger. `App` viser «Kunne ikke hente landdata.» fulgt av meldingen, og knappen «Prøv igjen», som kaller `refetch`. -For å legge ut en ny versjon: +| Situasjon | Melding | +| ------------------------------------------ | ------------------------------------------------------- | +| Ingen kontakt (nettverks- eller CORS-feil) | «Fikk ikke kontakt med API-et. Sjekk nettforbindelsen.» | +| HTTP-feil, for eksempel 429 eller 500 | «API-et svarte med en feil (HTTP 500).» | +| Svaret er ikke gyldig JSON | «Svaret fra API-et var ikke gyldig JSON.» | +| Svaret er ikke en array | «Svaret fra API-et hadde et uventet format.» | -1. Kjør `npm ci` og `npm run build` (lokalt eller på VM-en). -2. Kopier innholdet i `dist/` til `/var/www/html/project1/` på - `it2810-36.idi.ntnu.no`. +- Den opprinnelige feilen legges ved som `cause`, så den kan undersøkes ved feilsøking. +- Feilen fra en avbrutt forespørsel kastes videre uendret. TanStack Query håndterer den selv, og den vises ikke som feil. +- Tomt svar (`[]`) gir «Fant ingen land.». +- **Feil etter vellykket henting:** Feiler en senere henting i bakgrunnen, beholder TanStack Query de gamle dataene. `App` viser feilsiden bare når det ikke finnes data ([`App.tsx`](src/App.tsx#L69)), så brukeren kan fortsette med landene som allerede er hentet. -`base` i `vite.config.ts` er `/project1/`, slik at CSS og JS lastes fra -samme mappe som `index.html`. +## State og props i React -## Teknologi +`App` eier tilstanden og sender data og funksjoner ned til komponentene som props. Komponentene viser det de får, og melder endringer tilbake gjennom funksjoner som `onSelect`, `onChange` og `onToggle`. `SortControl` og `FavoritesFilter` er kontrollerte skjemaelementer: verdien kommer fra props, ikke fra egen state. Bare tilstand som gjelder visningen i én komponent, som om landlisten er åpen på mobil, ligger lokalt i komponenten. Landdataene er servertilstand og eies av TanStack Query, ikke av `useState`. -- React og TypeScript -- Vite -- TanStack Query til henting fra REST-API -- ESLint -- Prettier -- Vanlig CSS +### State -## Landdata fra API +| State | Hvor | Hva den holder | +| ------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | +| `selectedCode` | [`App.tsx`](src/App.tsx#L19) | Landkoden til valgt land, eller `null` før brukeren har valgt noe | +| `sortOrder` | [`useSortOrder.ts`](src/hooks/useSortOrder.ts#L8) | Valgt sortering, med startverdi fra sessionStorage | +| `showOnlyFavorites` | [`useShowOnlyFavorites.ts`](src/hooks/useShowOnlyFavorites.ts#L10) | Om favorittfilteret er på, med startverdi fra sessionStorage | +| `favorites` | [`useFavorites.ts`](src/hooks/useFavorites.ts#L7) | Favorittenes landkoder, med startverdi fra localStorage | +| `isListOpen` | [`CountryMenu.tsx`](src/components/CountryMenu.tsx#L27) | Om landlisten er åpen på smale skjermer (lokal state i komponenten) | +| `failedFlagUrl` | [`CountryCard.tsx`](src/components/CountryCard.tsx#L25) | Flagg-URL-en som ikke kunne lastes, så reserveteksten vises | +| Landdata | [`useCountries.ts`](src/hooks/useCountries.ts) | Data, lastestatus og feil fra TanStack Query | -### Datakilde +De tre hookene for lagrede valg bruker `useState` og lagrer i Web Storage, se [Lagring i nettleseren](#lagring-i-nettleseren). -Vi bruker [countries.dev](https://countries.dev) -([dokumentasjon](https://countries.dev/docs)). API-et er gratis, -krever ingen API-nøkkel og tillater kall direkte fra nettleseren (CORS). -REST Countries var førstevalget, men krever nå autentisering. +**Avledede verdier i stedet for mer state:** Den sorterte listen, det filtrerte utvalget og valgt land regnes ut på nytt ved hver render ([`App.tsx`, linje 29–39](src/App.tsx#L29-L39)). `App` lagrer bare landkoden til valgt land. Finnes ikke koden i utvalget, for eksempel etter filtrering, vises første land. Dermed kan valget aldri bli ugyldig, og det finnes bare én kilde til sannhet. -Appen henter dataene fra API-et når den lastes. Vi leverer ingen -forhåndsnedlastet landdatabase, og landdata lagres ikke i -localStorage, sessionStorage eller IndexedDB. Bare brukervalg lagres: -sorteringen og favorittfilteret i sessionStorage, se -[Sortering](#sortering) og [Favorittfilter](#favorittfilter), og -favorittenes landkoder i localStorage, se [Favoritter](#favoritter). +**Eksempel:** Brukeren trykker «Neste». `CountryNavigation` kaller `onSelect` med landkoden til neste land. `App` oppdaterer `selectedCode`, rendres på nytt og sender det nye landet til menyen, navigasjonen og kortet. -### Datautvalg +### Props -Appen gjør én forespørsel: +| Komponent | Props | Rolle | +| -------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | +| [`CountryMenu`](src/components/CountryMenu.tsx#L5) | `countries`, `selectedCode`, `onSelect`, `isFavorite`, `children` | Sidemeny med landliste. Får sorteringen og filteret som `children`. | +| [`SortControl`](src/components/SortControl.tsx#L8) | `value`, `onChange` | Kontrollert nedtrekksliste for sortering | +| [`FavoritesFilter`](src/components/FavoritesFilter.tsx#L3) | `checked`, `onChange` | Kontrollert avkrysningsboks for favorittfilteret | +| [`CountryNavigation`](src/components/CountryNavigation.tsx#L4) | `countries`, `selectedCode`, `onSelect` | Forrige/neste og posisjon. Regner ut nabolandene fra listen. | +| [`CountryCard`](src/components/CountryCard.tsx#L5) | `country`, `children` | Viser ett land. Får favorittknappen som `children`. | +| [`FavoriteButton`](src/components/FavoriteButton.tsx#L3) | `countryName`, `isFavorite`, `onToggle` | Av/på-knapp for favoritt | -```text -https://countries.dev/countries?fields=alpha3Code,name,flags,capital,region,population,area,languages&sort=population&order=desc&limit=25 -``` +Alle props har egne TypeScript-typer. `children` brukes til å sette sammen komponentene i [`App.tsx`](src/App.tsx#L93-L123): `CountryMenu` viser sorteringen og filteret uten å vite hvordan de virker, og `CountryCard` viser favorittknappen uten å vite noe om favoritter. -- **Antall land:** De 25 mest folkerike landene. Utvalget er avgrenset i - første versjon og settes med `COUNTRY_LIMIT` i `src/api/countries.ts`. - Oseania er foreløpig ikke med i utvalget. -- **Felter:** Bare feltene appen trenger: landkode, navn, flagg, - hovedstad, region, befolkning, areal og språk. -- **Identitet:** Landkoden `alpha3Code` (ISO 3166-1 alpha-3, f.eks. `NOR`) - brukes som identitet og React-key. - -Listeendepunktet returnerer en JSON-array. Svaret gjøres om til typen -`Country` i `src/types/country.ts`. Et svar som ikke er en array, gir en -feil. Land uten kode eller navn hoppes over. Andre felter kan mangle i -API-et, for eksempel hovedstad eller areal, og er derfor valgfrie i typen. - -### Struktur - -| Fil | Ansvar | -| -------------------------------------- | -------------------------------------------------------------------- | -| `src/types/country.ts` | TypeScript-typen for et land | -| `src/api/countries.ts` | `fetchCountries`: henter, kontrollerer og gjør om svaret | -| `src/hooks/useCountries.ts` | `useCountries`: TanStack Query-hook med cache-innstillinger | -| `src/components/CountryCard.tsx` | Landkort som viser ett land, se [Landkort](#landkort) | -| `src/components/CountryCard.css` | Egen styling for landkortet | -| `src/components/CountryMenu.tsx` | Sidemeny med kontroller og landliste, se [Navigasjon](#navigasjon) | -| `src/components/CountryMenu.css` | Egen styling for sidemenyen | -| `src/components/CountryNavigation.tsx` | Forrige/neste og posisjon, se [Navigasjon](#navigasjon) | -| `src/components/CountryNavigation.css` | Egen styling for navigasjonen | -| `src/components/SortControl.tsx` | Valg av sortering, se [Sortering](#sortering) | -| `src/components/SortControl.css` | Egen styling for sorteringsvalget | -| `src/utils/sortCountries.ts` | `sortCountries`, `SortOrder` og validering av sorteringsvalg | -| `src/utils/sortOrderStorage.ts` | Leser og lagrer sorteringsvalget i sessionStorage | -| `src/hooks/useSortOrder.ts` | `useSortOrder`: sorteringsvalg som state, lagret per fane | -| `src/components/FavoriteButton.tsx` | Av/på-knapp for favoritt, se [Favoritter](#favoritter) | -| `src/components/FavoriteButton.css` | Egen styling for favorittknappen | -| `src/utils/favoritesStorage.ts` | Leser, validerer og lagrer favoritter i localStorage | -| `src/hooks/useFavorites.ts` | `useFavorites`: favoritter som state, lagret mellom økter | -| `src/components/FavoritesFilter.tsx` | Av/på-valg for bare favoritter, se [Favorittfilter](#favorittfilter) | -| `src/components/FavoritesFilter.css` | Egen styling for favorittfilteret | -| `src/utils/favoritesFilterStorage.ts` | Leser og lagrer favorittfilteret i sessionStorage | -| `src/hooks/useShowOnlyFavorites.ts` | `useShowOnlyFavorites`: filtervalg som state, lagret per fane | -| `src/App.tsx` | Lasting, feil og tomt resultat, utvalg, valgt land og favoritter | -| `src/main.tsx` | Oppretter én `QueryClient` og `QueryClientProvider` | - -### Hentestrategi med TanStack Query - -- `QueryClient` opprettes én gang utenfor React i `main.tsx`, slik at - cachen ikke nullstilles når komponenter rendres på nytt. -- `useCountries` bruker den faste query key-en `['countries']`. Alle - komponenter som bruker hooken, deler dermed samme data og forespørsel. -- TanStack Query sender med et `AbortSignal`, som `fetchCountries` - sender videre til `fetch`. -- `staleTime` er én time. Landdata endres sjelden, og API-et oppgir - selv `Cache-Control: max-age=3600`. Så lenge dataene er ferske, gir - nye rendringer og nye komponenter ingen nye kall. -- `refetchOnWindowFocus: false` gjør at fanebytte ikke gir nye kall. -- `refetchOnReconnect` og `refetchOnMount` har standardverdien `true`, - men gjelder bare data som er eldre enn `staleTime`. Kommer nettet - tilbake etter mer enn én time, hentes dataene på nytt. Ellers skjer - ingenting. `App` monteres bare én gang, så `refetchOnMount` gir ingen - ekstra kall. -- `retry: 1` gir ett automatisk nytt forsøk, også når brukeren trykker - «Prøv igjen». Deretter vises feilmeldingen. Flere forsøk ville bare - gjort feilmeldingen tregere, og brukeren kan prøve igjen selv. -- `gcTime` har standardverdien fem minutter. Den har ingen praktisk - betydning her, fordi `App` alltid bruker queryen og dataene derfor - aldri fjernes fra cachen mens appen er åpen. -- Cachen ligger bare i minnet og forsvinner ved reload. Da hentes - dataene på nytt, men nettleseren kan bruke sin egen HTTP-cache. -- Sortering, favoritter, favorittfilter, blaing og valg av land skjer - lokalt på dataene som allerede er hentet. De endrer ikke query key-en - og gir derfor ingen nye kall. +## Lagring i nettleseren -### Feilhåndtering +| Nøkkel | Lagring | Verdi | Huskes | +| ------------------------------------ | -------------- | --------------------------------------------- | ----------------------------------- | +| `utforsk-verden:sort-order` | sessionStorage | `"name"` eller `"population"` | Ved reload i samme fane | +| `utforsk-verden:show-only-favorites` | sessionStorage | `"true"` eller `"false"` | Ved reload i samme fane | +| `utforsk-verden:favorites` | localStorage | JSON-array med landkoder, som `["BGD","BRA"]` | Også etter at nettleseren er lukket | -`fetchCountries` gjør feil om til norske meldinger. `App` viser -«Kunne ikke hente landdata.» etterfulgt av meldingen, og en knapp -«Prøv igjen» som kaller `refetch`. +- **Hvorfor sessionStorage for sortering og filter:** Oppgaven krever at valgene huskes ved reload. sessionStorage gjelder bare fanen, så valgene overlever reload, men en ny fane eller en ny økt starter med standardvalgene. Hver fane kan dermed ha sin egen visning. +- **Hvorfor localStorage for favoritter:** Favorittene skal huskes etter at nettleseren er lukket og startet igjen. Det gjør localStorage, og det gjelder alle faner. +- **Lesing én gang:** Hookene leser lagret verdi som startverdi i `useState` (lazy initializer) og lagrer i funksjonen som endrer verdien, uten `useEffect`. +- **Validering:** Lagrede verdier kontrolleres før de brukes. En ukjent sortering gir standardvalget, bare `"true"` slår på filteret, og ugyldig JSON, verdier som ikke er tekst og duplikater i favorittene hoppes over. +- **Blokkert lagring:** Lesing og skriving ligger i `try/catch` ([`src/utils`](src/utils)). Er lagring blokkert eller full, virker appen, men valgene huskes ikke. +- **Ikke lagret:** landdata (se [Datautvalg](#datautvalg)) og hvilket land som er valgt. -| Situasjon | Melding | -| ------------------------------------------ | ------------------------------------------------------- | -| Ingen kontakt (nettverks- eller CORS-feil) | «Fikk ikke kontakt med API-et. Sjekk nettforbindelsen.» | -| HTTP-feil, for eksempel 429 eller 500 | «API-et svarte med en feil (HTTP 500).» | -| Svaret er ikke gyldig JSON | «Svaret fra API-et var ikke gyldig JSON.» | -| Svaret er ikke en array | «Svaret fra API-et hadde et uventet format.» | +## Responsivt design -- Den opprinnelige feilen legges ved som `cause`, slik at den kan - undersøkes ved feilsøking. -- En forespørsel som avbrytes via `AbortSignal`, sendes videre uendret. - TanStack Query håndterer den selv, og den vises ikke som feil. -- **Tomt svar:** `[]` gir «Fant ingen land.». -- **Manglende felter:** Land uten kode eller navn hoppes over. Andre - felter som mangler eller har feil type, blir `undefined`, og kortet - viser «Ikke oppgitt». -- **Feil etter vellykket henting:** Feiler en senere henting i - bakgrunnen, for eksempel ved gjenoppkobling, beholder TanStack Query - de gamle dataene. `App` viser derfor feilsiden bare når det ikke finnes - data, slik at brukeren kan fortsette med landene som allerede er hentet. - -### Utviklingsmodus og produksjonsbygg - -React Strict Mode er beholdt. I utviklingsmodus (`npm run dev`) monterer -Strict Mode komponentene to ganger. Første forespørsel avbrytes da, og en -ny startes. Nettverksfanen viser derfor én avbrutt og én fullført -forespørsel ved oppstart. Dette er forventet og skjer bare i utvikling. -Produksjonsbygget (`npm run build` og `npm run preview`) gjør én -forespørsel. Flere kall i produksjonsbygget ville vært et problem. - -### API-nøkler - -countries.dev krever ingen API-nøkkel. Repoet og klientkoden inneholder -ingen nøkler, tokens eller `.env`-filer. - -### Landkort - -`CountryCard` får ett `Country` via props og henter ingen data selv. -`App` bruker `useCountries`, håndterer lasting, feil og tomt resultat, -og sender valgt land til kortet. - -- **Felter:** Navn, flagg, hovedstad, region, befolkning, areal og språk. - Navn, region og språk vises slik API-et oppgir dem, altså på engelsk. -- **Semantikk:** Kortet er en `
` med landnavnet som `

` - (koblet med `aria-labelledby`). Faktaene ligger i en `
` med - `
`/`
`-par. Flagget har alternativteksten «Flagget til …». -- **Formatering:** Tall formateres med `Intl.NumberFormat('nb-NO')`, - for eksempel `1 402 112 000`. Areal får enheten `km²`. Språk slås - sammen med `Intl.ListFormat`, for eksempel «Hindi og English». -- **Manglende data:** Manglende hovedstad, region, befolkning, areal - eller språk vises som «Ikke oppgitt». Mangler flagget, eller kan - bildet ikke lastes, vises «Flagg ikke tilgjengelig» i stedet. -- **Styling:** Vanlig CSS i `CountryCard.css` med fargevariablene fra - `index.css`, slik at lys og mørk modus fungerer. På smale skjermer - legges etikett og verdi under hverandre. På lave skjermer i liggende - format ligger flagget ved siden av fakta, se - [Responsivt design](#responsivt-design). - -### Navigasjon - -Brukeren kan bla med forrige/neste over kortet og velge land direkte i en -sidemeny. Begge komponentene får landlisten, valgt landkode og en -`onSelect`-funksjon via props. Valgt land og utvalget eies av `App`. - -- **`CountryMenu`:** En `