Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -161,7 +161,6 @@ Vi velger følgende data:
| `is_day` | Dag/natt |
| `time` | Tidspunkt for målingen |


Eksempel:

```bash
Expand Down
71 changes: 54 additions & 17 deletions e2e/weather-states.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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);
});

Expand All @@ -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 });
Expand All @@ -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);
});
Expand All @@ -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 });
Expand All @@ -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);
});
Expand All @@ -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([]);
});
6 changes: 3 additions & 3 deletions e2e/weather.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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(
Expand Down
50 changes: 24 additions & 26 deletions web/src/App.css
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@

/**********/
/* Footer */
/**********/

.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;
}

/**********/
Expand Down Expand Up @@ -76,7 +75,6 @@
flex: 1 1 10rem;
}


.app-header__weather-controls select {
width: 100%;
}
Expand Down Expand Up @@ -130,64 +128,64 @@
}

.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 {
font-size: var(--font-xl);
}

.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)
}
display: grid;
gap: var(--space-sm);
}
Loading