From 13e2b24f7c856a61e3698947e2d1795e24b4794d Mon Sep 17 00:00:00 2001 From: Oliver Dragland Date: Fri, 18 Sep 2026 11:45:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(quality):=20rett=20formatering=20og=20v?= =?UTF-8?q?=C3=A6rkort-sjekker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- e2e/weather-states.spec.ts | 71 +++++-- e2e/weather.spec.ts | 6 +- web/src/App.css | 50 +++-- web/src/App.tsx | 179 ++++++++---------- web/src/WeatherExample.test.tsx | 6 +- web/src/WeatherList.test.tsx | 6 +- .../components/WeatherCard/WeatherCard.css | 110 +++++------ .../components/WeatherCard/WeatherCard.tsx | 49 ++--- .../components/WeatherList/WeatherList.css | 7 +- .../components/WeatherList/WeatherList.tsx | 12 +- web/src/data/activities.ts | 2 +- web/src/data/cities.ts | 2 +- web/src/features/weather/cities.ts | 2 +- web/src/services/weatherService.ts | 2 +- web/src/styles.css | 10 +- web/src/types/activity.ts | 8 +- web/src/types/city.ts | 2 +- web/src/types/weather.ts | 2 +- 19 files changed, 261 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 46c78cf..4549a7d 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,17 @@ Applikasjonen skal: - fungere på både mobil og desktop Værdata hentes direkte når applikasjonen brukes og mellomlagres ikke + ## Weather service `weatherService` har ansvar for å hente værdata fra Open-Meteo og konvertere API-responsen til prosjektets egen `WeatherData`-type. Dette gjør at React-komponentene ikke trenger å kjenne til: -* URL-en til Open-Meteo -* feltnavnene i API-responsen -* hvordan API-kallet bygges -* hvordan HTTP-feil håndteres +- URL-en til Open-Meteo +- feltnavnene i API-responsen +- hvordan API-kallet bygges +- hvordan HTTP-feil håndteres Komponentene arbeider bare med prosjektets egne TypeScript-typer. @@ -136,7 +137,6 @@ flowchart LR Denne oppdelingen gjør API-håndteringen samlet på ett sted og lar React-komponentene bruke en enkel og stabil `WeatherData`-modell. - ## Værdata Prosjektet bruker Open-Meteo Forecast API. @@ -161,7 +161,6 @@ Vi velger følgende data: | `is_day` | Dag/natt | | `time` | Tidspunkt for målingen | - Eksempel: ```bash diff --git a/e2e/weather-states.spec.ts b/e2e/weather-states.spec.ts index d63e995..0e0fc70 100644 --- a/e2e/weather-states.spec.ts +++ b/e2e/weather-states.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; +import { cities, exampleCity } from '../web/src/features/weather/cities'; import { weatherResponse } from '../web/src/test/fixtures'; test('announces loading while the API request is pending', async ({ page }) => { @@ -13,13 +14,17 @@ test('announces loading while the API request is pending', async ({ page }) => { }); await page.goto('./'); try { - await expect(page.getByRole('status')).toHaveText('Henter værdata…'); + await expect(page.getByRole('status')).toHaveText( + cities.map(() => 'Henter værdata…'), + ); await expect(page.getByRole('alert')).toHaveCount(0); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); } finally { finishRequest(); } - await expect(page.getByText('14 °C')).toBeVisible(); + await expect(page.getByText('14 °C', { exact: true })).toHaveCount( + cities.length, + ); await expect(page.getByRole('status')).toHaveCount(0); }); @@ -32,6 +37,13 @@ test('announces API errors and supports keyboard retry with loading feedback', a finishRetry = resolve; }); await page.route('https://api.open-meteo.com/**', async (route) => { + if ( + new URL(route.request().url()).searchParams.get('latitude') !== + String(exampleCity.latitude) + ) { + await route.fulfill({ json: weatherResponse }); + return; + } calls++; if (calls === 1) { await route.fulfill({ status: 503 }); @@ -41,23 +53,26 @@ test('announces API errors and supports keyboard retry with loading feedback', a await route.fulfill({ json: weatherResponse }); }); await page.goto('./'); + const card = page.getByRole('article', { name: 'Værkort for Trondheim' }); + const retry = card.getByRole('button', { name: 'Prøv igjen' }); try { - await expect(page.getByRole('alert')).toContainText( + await expect(card.getByRole('alert')).toContainText( 'Kunne ikke hente værdata.', ); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); expect(calls).toBe(1); + await card + .getByRole('button', { name: 'Legg Trondheim til i favoritter' }) + .focus(); await page.keyboard.press('Tab'); - await expect( - page.getByRole('button', { name: 'Prøv igjen' }), - ).toBeFocused(); + await expect(retry).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.getByRole('status')).toHaveText('Henter værdata…'); - await expect(page.getByRole('alert')).toHaveCount(0); + await expect(card.getByRole('status')).toHaveText('Henter værdata…'); + await expect(card.getByRole('alert')).toHaveCount(0); } finally { finishRetry(); } - await expect(page.getByText('14 °C')).toBeVisible(); + await expect(card.getByText('14 °C')).toBeVisible(); await expect(page.getByRole('status')).toHaveCount(0); expect(calls).toBe(2); }); @@ -71,6 +86,13 @@ test('explains empty results and disables repeat requests while retrying', async finishRetry = resolve; }); await page.route('https://api.open-meteo.com/**', async (route) => { + if ( + new URL(route.request().url()).searchParams.get('latitude') !== + String(exampleCity.latitude) + ) { + await route.fulfill({ json: weatherResponse }); + return; + } calls++; if (calls === 1) { await route.fulfill({ status: 204 }); @@ -80,21 +102,30 @@ test('explains empty results and disables repeat requests while retrying', async await route.fulfill({ json: weatherResponse }); }); await page.goto('./'); + const card = page.getByRole('article', { name: 'Værkort for Trondheim' }); try { - await expect(page.getByRole('status')).toContainText( + await expect(card.getByRole('status')).toContainText( 'Ingen værdata er tilgjengelige for Trondheim akkurat nå.', ); await expect(page.getByRole('alert')).toHaveCount(0); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); - await page.getByRole('button', { name: 'Prøv igjen' }).click(); - await expect(page.getByRole('status')).toHaveText('Henter værdata…'); + await card + .getByRole('button', { name: 'Se detaljer for Trondheim' }) + .click(); + await expect(page.getByRole('dialog').getByRole('status')).toContainText( + 'Ingen værdata er tilgjengelige for Trondheim akkurat nå.', + ); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await card.getByRole('button', { name: 'Prøv igjen' }).click(); + await expect(card.getByRole('status')).toHaveText('Henter værdata…'); await expect( - page.getByRole('button', { name: 'Prøv igjen' }), + card.getByRole('button', { name: 'Prøv igjen' }), ).toBeDisabled(); } finally { finishRetry(); } - await expect(page.getByText('14 °C')).toBeVisible(); + await expect(card.getByText('14 °C')).toBeVisible(); await expect(page.getByRole('status')).toHaveCount(0); expect(calls).toBe(2); }); @@ -108,12 +139,18 @@ test('keeps the app usable when weather fields are missing', async ({ await route.fulfill({ json: { ...weatherResponse, current: {} } }); }); await page.goto('./'); - await expect(page.getByRole('alert')).toContainText( - 'Kunne ikke hente værdata.', + await expect(page.getByRole('alert')).toHaveText( + cities.map(() => 'Kunne ikke hente værdata.'), ); await expect( page.getByRole('heading', { name: 'Turvær Norge' }), ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + for (const city of cities) { + await expect( + page + .getByRole('article', { name: `Værkort for ${city.name}` }) + .getByRole('button', { name: 'Prøv igjen' }), + ).toBeEnabled(); + } expect(pageErrors).toEqual([]); }); diff --git a/e2e/weather.spec.ts b/e2e/weather.spec.ts index 765e485..8fe18ab 100644 --- a/e2e/weather.spec.ts +++ b/e2e/weather.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { cities } from '../web/src/features/weather/cities'; import { weatherResponse } from '../web/src/test/fixtures'; -test('weather example loads accessibly without horizontal overflow', async ({ +test('weather cards and details load accessibly without horizontal overflow', async ({ page, }) => { let calls = 0; @@ -12,7 +12,7 @@ test('weather example loads accessibly without horizontal overflow', async ({ }); await page.goto('./'); await expect( - page.getByRole('heading', { name: 'Vær i norske byer' }), + page.getByRole('heading', { name: 'Været i norske byer' }), ).toBeVisible(); await page.getByRole('button', { name: 'Se detaljer for Trondheim' }).click(); await expect( @@ -24,7 +24,7 @@ test('weather example loads accessibly without horizontal overflow', async ({ .getByText('14°'), ).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Vær i norske byer' }), + page.getByRole('heading', { name: 'Været i norske byer' }), ).toBeVisible(); expect(calls).toBe(cities.length); expect( diff --git a/web/src/App.css b/web/src/App.css index 148c1e1..0f20225 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,4 +1,3 @@ - /**********/ /* Footer */ /**********/ @@ -6,16 +5,16 @@ .app-footer { background-color: var(--color-footer-background); width: 100%; - + margin-block-start: var(--space-lg); padding-block: var(--space-md); } .app-footer > div { - width: var(--main-wrapper); + width: var(--main-wrapper); - margin-inline: auto; + margin-inline: auto; } /**********/ @@ -76,7 +75,6 @@ flex: 1 1 10rem; } - .app-header__weather-controls select { width: 100%; } @@ -130,23 +128,23 @@ } .details-dialog__close-button { - width: 100%; + width: 100%; - padding: var(--space-xs); + padding: var(--space-xs); - border-radius: var(--radius-md); + border-radius: var(--radius-md); - background-color: var(--color-action-surface); - border: 1px solid var(--color-action-surface-border); + background-color: var(--color-action-surface); + border: 1px solid var(--color-action-surface-border); } .details-dialog__close-button:hover, .details-dialog__close-button:focus-visible { - background-color: var(--color-action-surface-hover); + background-color: var(--color-action-surface-hover); } .details-dialog__close-button:active { - background-color: var(--color-action-surface-active); + background-color: var(--color-action-surface-active); } .details-dialog h2 { @@ -154,40 +152,40 @@ } .details-dialog__temperature-condition { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .details-dialog__temperature-condition > p:first-child { - font-size: var(--font-lg); - font-weight: var(--weight-semibold); + font-size: var(--font-lg); + font-weight: var(--weight-semibold); } .details-dialog__temperature-condition > p:last-child { - color: var(--color-text-muted); - font-weight: var(--weight-medium); + color: var(--color-text-muted); + font-weight: var(--weight-medium); } .details-dialog__detail-row { - display: flex; - gap: var(--space-xs); + display: flex; + gap: var(--space-xs); } .details-dialog__detail-row dd { - font-weight: var(--weight-medium); + font-weight: var(--weight-medium); } .details-dialog__detail-row dt { - color: var(--color-text-secondary); + color: var(--color-text-secondary); } .details-dialog__update-time { color: var(--color-text-muted); - + font-size: var(--font-sm); } .details-dialog__section { - display: grid; - gap: var(--space-sm) -} \ No newline at end of file + display: grid; + gap: var(--space-sm); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index fd69f3a..1783441 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,22 +10,20 @@ import { } from './features/weather/cities'; import { weatherQuery, type CityWeather } from './features/weather/weather'; import { useStoredState } from './shared/storage'; -import { WeatherList, type WeatherListItem } from './components/WeatherList/WeatherList'; +import { QueryFeedback } from './components/feedback'; +import { + WeatherList, + type WeatherListItem, +} from './components/WeatherList/WeatherList'; import './App.css'; import { activities } from './data/activities'; import type { ActivityId } from './types/activity'; - const sortModes = ['default', 'best', 'temperature', 'rain', 'wind'] as const; type SortMode = (typeof sortModes)[number]; const cityIds = new Set(cities.map(({ id }) => id)); -const activitySchema = z.enum([ - 'walking', - 'cycling', - 'running', - 'skiing', -]); +const activitySchema = z.enum(['walking', 'cycling', 'running', 'skiing']); const sortModeSchema = z.enum(sortModes); const cityIdSchema = z .string() @@ -44,18 +42,20 @@ function Header({ activity: ActivityId; sortMode: SortMode; onCityChange: (cityId: CityId) => void; -onActivityChange: (activity: ActivityId) => void; + onActivityChange: (activity: ActivityId) => void; onSortChange: (sortMode: SortMode) => void; }) { return ( -
-
+
+

Turvær Norge

IT2810 · Gruppe 31

-
-

Finn været for din neste tur.

+
+

+ Finn været for din neste tur. +

- Vurdert for{' '} - {getActivityName(activity).toLowerCase()}. + Vurdert for {getActivityName(activity).toLowerCase()}.

- {isLoading &&

Henter værdata…

} - {error && ( -
-

Kunne ikke hente værdata akkurat nå.

- -
- )} + {feedback} {weather && ( <>
-
+

{weather.current.temperature_2m}°

{weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype'} @@ -328,11 +310,11 @@ function MainWeather({

-
+
Nedbør:
{weather.current.precipitation} mm
-
+
Vind:
{weather.current.wind_speed_10m} m/s
@@ -346,9 +328,13 @@ function MainWeather({ )} - + ); } @@ -391,11 +377,16 @@ export function App() { return { city, weather: query?.data ?? null, - isLoading: query?.isPending ?? true, - hasError: query?.isError ?? false, - onRetry: () => { - void query?.refetch(); - }, + feedback: query && ( + + ), isFavorite: favoriteIds.includes(city.id), score: scoreWeather(query?.data ?? null, activity), rank: null, @@ -441,56 +432,46 @@ export function App() { return ( <> -
-
- setSelectedCityId(city.id)} - onOpenDetails={(city) => { - setSelectedCityId(city.id); - setDetailCityId(city.id); - }} - onToggleFavorite={toggleFavorite} - expandedCityId={detailCityId} - details={ - detailCity ? ( - setDetailCityId(null)}> - - void weatherQueries[ - cities.findIndex(({ id }) => id === detailCity.id) - ]?.refetch() - } - onClose={() => setDetailCityId(null)} - /> - - ) : null - } - /> -
- +
+
+ setSelectedCityId(city.id)} + onOpenDetails={(city) => { + setSelectedCityId(city.id); + setDetailCityId(city.id); + }} + onToggleFavorite={toggleFavorite} + expandedCityId={detailCityId} + details={ + detailCity ? ( + setDetailCityId(null)}> + setDetailCityId(null)} + /> + + ) : null + } + /> +
+ ); } diff --git a/web/src/WeatherExample.test.tsx b/web/src/WeatherExample.test.tsx index 07480fb..5e4c2c8 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -29,10 +29,6 @@ it('renders validated weather and a stable snapshot', async () => { expect(screen.getByText('3 m/s')).toBeVisible(); expect(asFragment()).toMatchSnapshot(); }); -it('shows errors and retries on user interaction', async () => { - // Keep the existing implementation of this test -}); - it('shows a loading message until the response arrives', async () => { let finishRequest = () => {}; const responseReady = new Promise((resolve) => { @@ -58,7 +54,7 @@ it('shows a loading message until the response arrives', async () => { await screen.findByText('14 °C'); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); - + it('shows an accessible error and loading feedback during a manual retry', async () => { let calls = 0; let finishRetry = () => {}; diff --git a/web/src/WeatherList.test.tsx b/web/src/WeatherList.test.tsx index c243951..32d1993 100644 --- a/web/src/WeatherList.test.tsx +++ b/web/src/WeatherList.test.tsx @@ -17,9 +17,7 @@ it('shows feedback when there are no weather cards to display', () => { ); expect( - screen.getByRole('heading', { name: 'Vær i norske byer' }), + screen.getByRole('heading', { name: 'Været i norske byer' }), ).toBeVisible(); - expect(screen.getByRole('status')).toHaveTextContent( - 'Ingen værkort å vise.', - ); + expect(screen.getByRole('status')).toHaveTextContent('Ingen værkort å vise.'); }); diff --git a/web/src/components/WeatherCard/WeatherCard.css b/web/src/components/WeatherCard/WeatherCard.css index ca3873f..1c0a9b1 100644 --- a/web/src/components/WeatherCard/WeatherCard.css +++ b/web/src/components/WeatherCard/WeatherCard.css @@ -1,124 +1,124 @@ h3 { - font-size: var(--font-xl) + font-size: var(--font-xl); } .weather-card { - display: grid; - gap: var(--space-sm); + display: grid; + gap: var(--space-sm); - background-color: var(--color-card-background); - - padding-inline: var(--space-lg); - padding-block-start: var(--space-sm); - padding-block-end: var(--space-lg); + background-color: var(--color-card-background); - border-radius: var(--radius-lg); - border: 1px solid var(--color-card-border); + padding-inline: var(--space-lg); + padding-block-start: var(--space-sm); + padding-block-end: var(--space-lg); + + border-radius: var(--radius-lg); + border: 1px solid var(--color-card-border); } .weather-card:hover, .weather-card:focus-visible { - border-color: var(--color-card-border-hover); - background-color: var(--color-card-hover); + border-color: var(--color-card-border-hover); + background-color: var(--color-card-hover); } - + .weather-card-selected { - border-color: var(--color-card-border-selected); - background-color: var(--color-card-selected); + border-color: var(--color-card-border-selected); + background-color: var(--color-card-selected); - border-width: 2px; + border-width: 2px; } .weather-card__header { - display: flex; - flex-wrap: wrap; + display: flex; + flex-wrap: wrap; - justify-content: space-between; - align-items: center; + justify-content: space-between; + align-items: center; } .weather-card__favorite-button { - background-color: transparent; - border: 0; + background-color: transparent; + border: 0; - font-size: var(--control-font-2xl); + font-size: var(--control-font-2xl); } .weather-card__favorite-button:hover, .weather-card__favorite-button:focus-visible { - border-radius: var(--radius-lg); + border-radius: var(--radius-lg); - background-color: var(--color-action-surface-hover); + background-color: var(--color-action-surface-hover); - border: 1px solid var(--color-border-hover); + border: 1px solid var(--color-border-hover); } .weather-card__favorite-button:active { - background-color: var(--color-action-surface-active); + background-color: var(--color-action-surface-active); } .weather-card__button-full-width { - width: 100%; + width: 100%; - padding: var(--space-xs); + padding: var(--space-xs); - border-radius: var(--radius-md); + border-radius: var(--radius-md); - background-color: var(--color-action-surface); - border: 1px solid var(--color-action-surface-border); + background-color: var(--color-action-surface); + border: 1px solid var(--color-action-surface-border); } .weather-card__button-full-width:hover, .weather-card__button-full-width:focus-visible { - background-color: var(--color-action-surface-hover); + background-color: var(--color-action-surface-hover); } .weather-card__button-full-width:active { - background-color: var(--color-action-surface-active); + background-color: var(--color-action-surface-active); } .weather-card__temperature-condition { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } -.weather-card__temperature-condition > div:first-child { - font-size: var(--font-lg); - font-weight: var(--weight-semibold); +.weather-card__temperature-condition > dd:first-of-type { + font-size: var(--font-lg); + font-weight: var(--weight-semibold); } -.weather-card__temperature-condition > div:last-child { - color: var(--color-text-muted); - font-weight: var(--weight-medium); +.weather-card__temperature-condition > dd:last-of-type { + color: var(--color-text-muted); + font-weight: var(--weight-medium); } .weather-card__details { - display: grid; - gap: var(--space-sm); + display: grid; + gap: var(--space-sm); } .weather-card__detail-row { - display: flex; - gap: var(--space-xs); + display: flex; + gap: var(--space-xs); } .weather-card__detail-row dd { - font-weight: var(--weight-medium); + font-weight: var(--weight-medium); } .weather-card__detail-row dt { - color: var(--color-text-secondary); + color: var(--color-text-secondary); } .weather-card__rank { - padding: var(--space-xs); - - background-color: var(--color-action-surface); + padding: var(--space-xs); - border-radius: var(--radius-md); + background-color: var(--color-action-surface); + + border-radius: var(--radius-md); } .weather-card__weather-error { - display: grid; - gap: var(--space-sm); -} \ No newline at end of file + display: grid; + gap: var(--space-sm); +} diff --git a/web/src/components/WeatherCard/WeatherCard.tsx b/web/src/components/WeatherCard/WeatherCard.tsx index 1114a80..0e4fee0 100644 --- a/web/src/components/WeatherCard/WeatherCard.tsx +++ b/web/src/components/WeatherCard/WeatherCard.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { City } from '../../features/weather/cities'; import type { CityWeather } from '../../features/weather/weather'; import './WeatherCard.css'; @@ -5,9 +6,7 @@ import './WeatherCard.css'; export type WeatherCardProps = { city: City; weather: CityWeather | null; - isLoading: boolean; - hasError: boolean; - onRetry: () => void; + feedback: ReactNode; isSelected: boolean; onSelect: (city: City) => void; onOpenDetails: (city: City) => void; @@ -43,9 +42,7 @@ const weatherTypes: Record = { export function WeatherCard({ city, weather, - isLoading, - hasError, - onRetry, + feedback, isSelected, onSelect, onOpenDetails, @@ -64,11 +61,11 @@ export function WeatherCard({ isSelected ? 'weather-card weather-card-selected' : 'weather-card' } > -
+

{city.name}

-
- )} + {feedback} {weather && ( -
-
-
-
Temperatur
-
{weather.current.temperature_2m} °C
-
- -
-
Værtype
-
{weatherType}
-
+
+
+
Temperatur
+
{weather.current.temperature_2m} °C
+
Værtype
+
{weatherType}
-
+
Nedbør:
{weather.current.precipitation} mm
-
+
Vind:
{weather.current.wind_speed_10m} m/s
@@ -122,7 +105,7 @@ export function WeatherCard({ )}