From bb9ad62302187a646769179610c0515f4299b7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20=C3=98rjavik?= Date: Fri, 11 Sep 2026 17:37:41 +0200 Subject: [PATCH 1/9] feature(weather): add App layout and activity selector Refs #6, #8 --- web/src/App.tsx | 140 ++++++++++++++++++++++++++++++++ web/src/WeatherExample.test.tsx | 21 +++++ web/src/main.tsx | 4 +- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 web/src/App.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..33dcae6 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { cities, exampleCity, type City } from './features/weather/cities'; +import { weatherQuery, type CityWeather } from './features/weather/weather'; + +const activities = ['Gåtur', 'Løpetur', 'Skitur'] as const; +type Activity = (typeof activities)[number]; + +type WeatherContentProps = { + city: City; + activity: Activity; + weather: CityWeather | null; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}; + +function WeatherContent({ city, activity, weather, isLoading, error, onRetry }: WeatherContentProps) { + return ( +
+

Været i {city.name}

+

Valgt aktivitet: {activity}

+ {isLoading &&

Henter værdata…

} + {error && ( +
+

Kunne ikke hente værdata. Prøv igjen om litt.

+ +
+ )} + {weather && ( + <> +
+
+
Temperatur
+
{weather.current.temperature_2m} °C
+
+
+
Nedbør siste {weather.current.interval / 60} min
+
{weather.current.precipitation} mm
+
+
+
Vind
+
{weather.current.wind_speed_10m} m/s
+
+
+

+ Oppdatert{' '} + {' '} + (norsk tid). +

+ + )} +
+ ); +} + +function Header({ + city, + activity, + onCityChange, + onActivityChange, +}: { + city: City; + activity: Activity; + onCityChange: (city: City) => void; + onActivityChange: (activity: Activity) => void; +}) { + return ( +
+

IT2810 · Gruppe 31

+

Turvær Norge

+

Finn været for din neste tur.

+ + +
+ ); +} + +export function App() { + const [selectedCity, setSelectedCity] = useState(exampleCity); + const [selectedActivity, setSelectedActivity] = useState('Gåtur'); + const [weatherData, setWeatherData] = useState(null); + const weatherQueryResult = useQuery(weatherQuery(selectedCity)); + + useEffect(() => { + setWeatherData(weatherQueryResult.data ?? null); + }, [weatherQueryResult.data]); + + return ( +
+
+ void weatherQueryResult.refetch()} + /> + +
+ ); +} \ No newline at end of file diff --git a/web/src/WeatherExample.test.tsx b/web/src/WeatherExample.test.tsx index bc6021b..8d204a2 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; +import { App } from './App'; import { WeatherExample } from './WeatherExample'; import { WEATHER_URL, fetchWeather } from './features/weather/weather'; import { exampleCity } from './features/weather/cities'; @@ -15,6 +16,13 @@ function renderExample() { , ); } +function renderApp() { + return render( + + + , + ); +} it('renders validated weather and a stable snapshot', async () => { const { asFragment } = renderExample(); await screen.findByText('14 °C'); @@ -25,6 +33,19 @@ it('renders validated weather and a stable snapshot', async () => { expect(screen.getByText('3 m/s')).toBeVisible(); expect(asFragment()).toMatchSnapshot(); }); +it('stores selected city and activity in App state', async () => { + renderApp(); + await screen.findByText('14 °C'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'By' }), 'oslo'); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Aktivitet' }), + 'Løpetur', + ); + + expect(screen.getByRole('heading', { name: 'Været i Oslo' })).toBeVisible(); + expect(screen.getByText('Valgt aktivitet: Løpetur')).toBeVisible(); +}); it('shows errors and retries on user interaction', async () => { server.use( http.get(WEATHER_URL, () => new HttpResponse(null, { status: 503 })), diff --git a/web/src/main.tsx b/web/src/main.tsx index 573102f..7ea3284 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { WeatherExample } from './WeatherExample'; +import { App } from './App'; import './styles.css'; const root = document.getElementById('root'); if (!root) throw new Error('Missing root element'); @@ -9,7 +9,7 @@ const queryClient = new QueryClient(); createRoot(root).render( - + , ); From dc159e990fd8af1e44f3698e934938756271990c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20=C3=98rjavik?= Date: Sat, 12 Sep 2026 15:16:05 +0200 Subject: [PATCH 2/9] feature(weather): add WeatherList and WeatherCard Refs #6, #7 --- e2e/weather.spec.ts | 6 ++- web/src/App.tsx | 56 ++++++++++++++++----- web/src/WeatherCard.tsx | 88 +++++++++++++++++++++++++++++++++ web/src/WeatherExample.test.tsx | 12 ++++- web/src/WeatherList.tsx | 41 +++++++++++++++ web/src/styles.css | 27 ++++++++++ 6 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 web/src/WeatherCard.tsx create mode 100644 web/src/WeatherList.tsx diff --git a/e2e/weather.spec.ts b/e2e/weather.spec.ts index 8586ca1..507e8a1 100644 --- a/e2e/weather.spec.ts +++ b/e2e/weather.spec.ts @@ -1,5 +1,6 @@ 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 ({ page, @@ -14,7 +15,10 @@ test('weather example loads accessibly without horizontal overflow', async ({ page.getByRole('heading', { name: 'Været i Trondheim' }), ).toBeVisible(); await expect(page.getByText('14 °C')).toBeVisible(); - expect(calls).toBe(1); + await expect( + page.getByRole('heading', { name: 'Vær i norske byer' }), + ).toBeVisible(); + expect(calls).toBe(cities.length); expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, diff --git a/web/src/App.tsx b/web/src/App.tsx index 33dcae6..585b8dc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useQueries } from '@tanstack/react-query'; import { cities, exampleCity, type City } from './features/weather/cities'; import { weatherQuery, type CityWeather } from './features/weather/weather'; +import { WeatherList, type WeatherListItem } from './WeatherList'; const activities = ['Gåtur', 'Løpetur', 'Skitur'] as const; type Activity = (typeof activities)[number]; @@ -15,7 +16,14 @@ type WeatherContentProps = { onRetry: () => void; }; -function WeatherContent({ city, activity, weather, isLoading, error, onRetry }: WeatherContentProps) { +function WeatherContent({ + city, + activity, + weather, + isLoading, + error, + onRetry, +}: WeatherContentProps) { return (

Været i {city.name}

@@ -24,7 +32,9 @@ function WeatherContent({ city, activity, weather, isLoading, error, onRetry }: {error && (

Kunne ikke hente værdata. Prøv igjen om litt.

- +
)} {weather && ( @@ -108,12 +118,27 @@ function Header({ export function App() { const [selectedCity, setSelectedCity] = useState(exampleCity); const [selectedActivity, setSelectedActivity] = useState('Gåtur'); - const [weatherData, setWeatherData] = useState(null); - const weatherQueryResult = useQuery(weatherQuery(selectedCity)); + const weatherQueries = useQueries({ + queries: cities.map((city) => weatherQuery(city)), + }); + const selectedCityIndex = cities.findIndex( + ({ id }) => id === selectedCity.id, + ); + const weatherQueryResult = weatherQueries[selectedCityIndex]; + const weatherListItems: WeatherListItem[] = cities.map((city, index) => { + const query = weatherQueries[index]; + if (!query) { + return { city, weather: null, isLoading: true, hasError: false }; + } + return { + city, + weather: query.data ?? null, + isLoading: query.isPending, + hasError: query.isError, + }; + }); - useEffect(() => { - setWeatherData(weatherQueryResult.data ?? null); - }, [weatherQueryResult.data]); + const weatherData = weatherQueryResult?.data ?? null; return (
@@ -127,9 +152,14 @@ export function App() { city={selectedCity} activity={selectedActivity} weather={weatherData} - isLoading={weatherQueryResult.isPending} - error={weatherQueryResult.error} - onRetry={() => void weatherQueryResult.refetch()} + isLoading={weatherQueryResult?.isPending ?? true} + error={weatherQueryResult?.error ?? null} + onRetry={() => void weatherQueryResult?.refetch()} + /> +
Værdata fra Open-Meteo,{' '} @@ -137,4 +167,4 @@ export function App() {
); -} \ No newline at end of file +} diff --git a/web/src/WeatherCard.tsx b/web/src/WeatherCard.tsx new file mode 100644 index 0000000..cae4edb --- /dev/null +++ b/web/src/WeatherCard.tsx @@ -0,0 +1,88 @@ +import type { City } from './features/weather/cities'; +import type { CityWeather } from './features/weather/weather'; + +export type WeatherCardProps = { + city: City; + weather: CityWeather | null; + isLoading: boolean; + hasError: boolean; + isSelected: boolean; + onSelect: (city: City) => void; +}; + +const weatherTypes: Record = { + 0: 'Klarvær', + 1: 'Hovedsakelig klart', + 2: 'Delvis skyet', + 3: 'Overskyet', + 45: 'Tåke', + 48: 'Underkjølt tåke', + 51: 'Lett yr', + 53: 'Yr', + 55: 'Kraftig yr', + 61: 'Lett regn', + 63: 'Regn', + 65: 'Kraftig regn', + 71: 'Lett snø', + 73: 'Snø', + 75: 'Kraftig snø', + 80: 'Lette regnbyger', + 81: 'Regnbyger', + 82: 'Kraftige regnbyger', + 95: 'Tordenvær', + 96: 'Tordenvær med hagl', + 99: 'Tordenvær med kraftig hagl', +}; + +export function WeatherCard({ + city, + weather, + isLoading, + hasError, + isSelected, + onSelect, +}: WeatherCardProps) { + const weatherType = weather + ? (weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype') + : null; + + return ( +
+

{city.name}

+ + {isLoading &&

Henter værdata…

} + {hasError &&

Kunne ikke hente værdata.

} + {weather && ( +
+
+
Værtype
+
{weatherType}
+
+
+
Temperatur
+
{weather.current.temperature_2m} °C
+
+
+
Nedbør
+
{weather.current.precipitation} mm
+
+
+
Vind
+
{weather.current.wind_speed_10m} m/s
+
+
+ )} +
+ ); +} diff --git a/web/src/WeatherExample.test.tsx b/web/src/WeatherExample.test.tsx index 8d204a2..0286749 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -35,9 +35,12 @@ it('renders validated weather and a stable snapshot', async () => { }); it('stores selected city and activity in App state', async () => { renderApp(); - await screen.findByText('14 °C'); + await screen.findByRole('heading', { name: 'Været i Trondheim' }); - await userEvent.selectOptions(screen.getByRole('combobox', { name: 'By' }), 'oslo'); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'By' }), + 'oslo', + ); await userEvent.selectOptions( screen.getByRole('combobox', { name: 'Aktivitet' }), 'Løpetur', @@ -45,6 +48,11 @@ it('stores selected city and activity in App state', async () => { expect(screen.getByRole('heading', { name: 'Været i Oslo' })).toBeVisible(); expect(screen.getByText('Valgt aktivitet: Løpetur')).toBeVisible(); + + const bergenCard = screen.getByRole('button', { name: 'Vær i Bergen' }); + bergenCard.focus(); + await userEvent.keyboard('{Enter}'); + expect(screen.getByRole('heading', { name: 'Været i Bergen' })).toBeVisible(); }); it('shows errors and retries on user interaction', async () => { server.use( diff --git a/web/src/WeatherList.tsx b/web/src/WeatherList.tsx new file mode 100644 index 0000000..3904eee --- /dev/null +++ b/web/src/WeatherList.tsx @@ -0,0 +1,41 @@ +import type { City } from './features/weather/cities'; +import type { CityWeather } from './features/weather/weather'; +import { WeatherCard } from './WeatherCard'; + +export type WeatherListItem = { + city: City; + weather: CityWeather | null; + isLoading: boolean; + hasError: boolean; +}; + +export type WeatherListProps = { + items: WeatherListItem[]; + selectedCity: City; + onCitySelect: (city: City) => void; +}; + +export function WeatherList({ + items, + selectedCity, + onCitySelect, +}: WeatherListProps) { + return ( +
+

Vær i norske byer

+
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 8943805..af2bf8f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -27,6 +27,33 @@ section { padding: clamp(1rem, 4vw, 2rem); border-radius: 0.5rem; } +.weather-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} +.weather-card { + border: 1px solid #bacac1; + border-radius: 0.5rem; + padding: 1rem; + background: #fff; + cursor: pointer; +} +.weather-card:hover, +.weather-card:focus-visible, +.weather-card-selected { + border-color: #173d35; + box-shadow: 0 0 0 3px #d5e2dc; +} +.weather-card h3 { + margin-top: 0; +} +.weather-card dl { + gap: 0.75rem 1rem; +} +.weather-card dd { + font-size: 1.1rem; +} dl { display: flex; flex-wrap: wrap; From 76c16b1374a65b38ae00fec00f380399fdbced52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20=C3=98rjavik?= Date: Mon, 14 Sep 2026 11:13:59 +0200 Subject: [PATCH 3/9] feature(weather): add ranked cards and city details Refs #7, #8, #9, #11, #12 --- docs/weather-app-architecture.md | 74 ++++++ docs/weather-cards-and-ranking.md | 66 +++++ e2e/weather.spec.ts | 4 + web/src/App.tsx | 423 ++++++++++++++++++++++-------- web/src/WeatherCard.tsx | 31 +++ web/src/WeatherExample.test.tsx | 134 +++++++++- web/src/WeatherList.tsx | 41 ++- web/src/styles.css | 161 +++++++++++- 8 files changed, 808 insertions(+), 126 deletions(-) create mode 100644 docs/weather-app-architecture.md create mode 100644 docs/weather-cards-and-ranking.md diff --git a/docs/weather-app-architecture.md b/docs/weather-app-architecture.md new file mode 100644 index 0000000..4ee02b8 --- /dev/null +++ b/docs/weather-app-architecture.md @@ -0,0 +1,74 @@ +# Weather app architecture + +## Overview + +The production entry point is `web/src/App.tsx`. `App` owns the user-facing state and composes the reusable city list: + +```text +App +├── Header +│ ├── city selector +│ ├── activity selector +│ └── sort selector +└── WeatherList + └── WeatherCard + └── MainWeather (centered overlay while that card is expanded) +``` + +`WeatherExample.tsx` remains a small API example used by the existing provider tests. The production page uses all configured cities and TanStack Query. + +## State and persistence + +- The selected city is stored in `sessionStorage` under `t31-selected-city`. +- The selected activity is stored in `sessionStorage` under `t31-activity`. +- The selected sort mode is stored in `sessionStorage` under `t31-sort-mode`. +- Favorite city IDs are stored in `localStorage` under `t31-favorite-cities`. +- `useStoredState` parses JSON and validates stored values with Zod. Invalid or unavailable values fall back to defaults. +- Weather payloads are not stored in Web Storage. TanStack Query owns the live API responses. + +Session storage is used for choices that describe the current visit. Local storage is used for favorites because favorites should survive closing and reopening the browser. + +## Main view and navigation + +The default view presents the weather cards first. Users can select a city through the city select or a card. A card's details action opens a wider centered overlay without changing the grid row height or aligning the panel to the card column. The panel supplies the main temperature, weather type, precipitation, wind, update time and provider interval, with a close action. + +The card list remains visible around the expanded detail. It is sorted from a copied array, so the source city list is never mutated. + +## Sorting and ranking + +The list supports: + +- Original city order. +- Best weather for the selected activity. +- Highest temperature. +- Lowest precipitation. +- Lowest wind speed. + +The activity ranking is intentionally small and explainable at this stage. It rewards conditions suitable for the selected activity and subtracts penalties for precipitation and wind. Ranking is presentation logic; the validated API data remains unchanged. + +## Accessibility and responsive design + +- Native `select` and `button` controls provide keyboard interaction. +- Labels describe all selection controls. +- `main`, `header`, `section`, `article`, `dl`, `dt` and `dd` provide semantic structure. +- Loading and API failures use `role="status"` and `role="alert"`. +- Focus-visible styles are defined in `web/src/styles.css`. +- The layout changes from a horizontal desktop header to stacked mobile controls below 760px. +- Cards use stable grid sizing and remain usable in narrow viewports. + +## Verification + +Checks run for this implementation: + +```bash +npm test +npm run lint +npm run format:check +npm run build --workspace @t31/web +``` + +The component tests use MSW fixtures, so unit tests do not call Open-Meteo. They cover the existing validated weather example, city/activity state, city navigation, sorting persistence and favorite persistence. Playwright remains the browser-level check for responsive layout and accessibility; install its browsers with `npm run test:e2e:install` before running it locally. + +## Scope notes + +The ranking formula is a first usable implementation, not a medical or safety forecast. Future issues can refine activity-specific thresholds, add a visible ranking explanation, and add filtering for favorites or weather categories. diff --git a/docs/weather-cards-and-ranking.md b/docs/weather-cards-and-ranking.md new file mode 100644 index 0000000..b7b75c1 --- /dev/null +++ b/docs/weather-cards-and-ranking.md @@ -0,0 +1,66 @@ +# Weather cards and activity ranking + +## New page flow + +The weather cards are now the default main view. The page opens directly on the city collection so users can compare the available cities at a glance. + +Each card provides: + +- city name +- weather type +- temperature +- precipitation +- wind +- city selection +- favorite toggle +- a `Se detaljer` action + +Selecting a city updates the selected city state. Opening details shows a wider centered overlay. The overlay is independent of the card column, does not change the height of the card grid, and contains the selected city's relevant weather summary and update time. It can be closed with the close button or by clicking outside the panel. + +## Activity-specific ranking + +The `Best vær for aktivitet` sort option uses a separate weather profile for each activity: + +- `Gåtur` prefers mild temperatures, low precipitation and moderate wind. +- `Løpetur` prefers cooler temperatures, no precipitation and low wind. +- `Skitur` prefers temperatures below freezing and gives a bonus to snow weather codes. + +The score combines distance from the activity's ideal temperature with precipitation and wind penalties. The resulting ranking is presentation state only; API responses and the city source array are not changed. + +When the best-activity sort is active, cards display their current rank. Changing the activity recalculates all scores and can change which city is ranked first. + +## State and storage + +`App` owns the selected city, activity, sort mode, favorite IDs and currently expanded detail city: + +- selected city, activity and sort mode use `sessionStorage` +- favorite city IDs use `localStorage` +- expanded detail state is temporary React state and is not persisted +- weather payloads remain in TanStack Query rather than Web Storage + +Stored values are parsed and validated with Zod through `useStoredState`. Invalid storage values fall back to safe defaults. + +## Accessibility and responsive behavior + +The implementation uses native `select` and `button` controls, accessible labels, semantic headings and `dl` elements for weather values. The expanded main view has a clear heading and close control. Focus-visible styles remain available for keyboard users, and the card grid/control area stacks on narrow screens. + +## Verification + +The implementation is covered by MSW-backed Vitest tests for: + +- the existing validated weather example +- city and activity selection +- card-first detail expansion and navigation +- favorite and sort persistence +- activity-specific ranking changes + +The standard checks are: + +```bash +npm test +npm run lint +npm run format:check +npm run build --workspace @t31/web +``` + +Playwright browser checks should be run after installing the configured browsers with `npm run test:e2e:install`. diff --git a/e2e/weather.spec.ts b/e2e/weather.spec.ts index 507e8a1..94bad8d 100644 --- a/e2e/weather.spec.ts +++ b/e2e/weather.spec.ts @@ -11,6 +11,10 @@ test('weather example loads accessibly without horizontal overflow', async ({ await route.fulfill({ json: weatherResponse }); }); await page.goto('./'); + await expect( + page.getByRole('heading', { name: 'Vær i norske byer' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Se detaljer for Trondheim' }).click(); await expect( page.getByRole('heading', { name: 'Været i Trondheim' }), ).toBeVisible(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 585b8dc..f6f1873 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,37 +1,197 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQueries } from '@tanstack/react-query'; -import { cities, exampleCity, type City } from './features/weather/cities'; +import { z } from 'zod'; +import { + cities, + exampleCity, + type City, + type CityId, +} from './features/weather/cities'; import { weatherQuery, type CityWeather } from './features/weather/weather'; +import { useStoredState } from './shared/storage'; import { WeatherList, type WeatherListItem } from './WeatherList'; const activities = ['Gåtur', 'Løpetur', 'Skitur'] as const; type Activity = (typeof activities)[number]; +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(activities); +const sortModeSchema = z.enum(sortModes); +const cityIdSchema = z + .string() + .refine((id): id is CityId => cityIds.has(id as CityId)); +const favoriteIdsSchema = z.array(cityIdSchema); -type WeatherContentProps = { - city: City; +function Header({ + selectedCityId, + activity, + sortMode, + onCityChange, + onActivityChange, + onSortChange, +}: { + selectedCityId: CityId; activity: Activity; - weather: CityWeather | null; - isLoading: boolean; - error: Error | null; - onRetry: () => void; + sortMode: SortMode; + onCityChange: (cityId: CityId) => void; + onActivityChange: (activity: Activity) => void; + onSortChange: (sortMode: SortMode) => void; +}) { + return ( +
+

IT2810 · Gruppe 31

+

Turvær Norge

+

Finn været for din neste tur.

+
+ + + +
+
+ ); +} + +const weatherTypes: Record = { + 0: 'Klarvær', + 1: 'Hovedsakelig klart', + 2: 'Delvis skyet', + 3: 'Overskyet', + 45: 'Tåke', + 48: 'Underkjølt tåke', + 51: 'Lett yr', + 53: 'Yr', + 55: 'Kraftig yr', + 61: 'Lett regn', + 63: 'Regn', + 65: 'Kraftig regn', + 71: 'Lett snø', + 73: 'Snø', + 75: 'Kraftig snø', + 80: 'Lette regnbyger', + 81: 'Regnbyger', + 82: 'Kraftige regnbyger', + 95: 'Tordenvær', + 96: 'Tordenvær med hagl', + 99: 'Tordenvær med kraftig hagl', }; -function WeatherContent({ +function scoreWeather(weather: CityWeather | null, activity: Activity) { + if (!weather) return Number.NEGATIVE_INFINITY; + const { + temperature_2m: temperature, + precipitation, + wind_speed_10m: wind, + } = weather.current; + const profile = { + Gåtur: { + idealTemperature: 14, + temperatureWeight: 2, + rainWeight: 16, + windWeight: 3, + }, + Løpetur: { + idealTemperature: 11, + temperatureWeight: 3, + rainWeight: 24, + windWeight: 4, + }, + Skitur: { + idealTemperature: -3, + temperatureWeight: 4, + rainWeight: 10, + windWeight: 2, + }, + }[activity]; + const snowCode = + weather.current.weather_code >= 71 && weather.current.weather_code <= 77; + const snowBonus = activity === 'Skitur' && snowCode ? 35 : 0; + return ( + 100 - + Math.abs(temperature - profile.idealTemperature) * + profile.temperatureWeight - + precipitation * profile.rainWeight - + wind * profile.windWeight + + snowBonus + ); +} + +function MainWeather({ city, - activity, weather, + activity, isLoading, error, onRetry, -}: WeatherContentProps) { + onClose, +}: { + city: City; + weather: CityWeather | null; + activity: Activity; + isLoading: boolean; + error: Error | null; + onRetry: () => void; + onClose: () => void; +}) { return ( -
-

Været i {city.name}

-

Valgt aktivitet: {activity}

+
+
+
+

Valgt by

+

Været i {city.name}

+
+ +
+

+ Vurdert for {activity.toLowerCase()}. +

{isLoading &&

Henter værdata…

} {error && (
-

Kunne ikke hente værdata. Prøv igjen om litt.

+

Kunne ikke hente værdata akkurat nå.

@@ -39,127 +199,174 @@ function WeatherContent({ )} {weather && ( <> -
+
-
Temperatur
-
{weather.current.temperature_2m} °C
+

+ {weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype'} +

+

{weather.current.temperature_2m}°

+

+ Oppdatert{' '} + +

+
+
-
Nedbør siste {weather.current.interval / 60} min
+
Nedbør
{weather.current.precipitation} mm
Vind
{weather.current.wind_speed_10m} m/s
+
+
Intervall
+
{weather.current.interval / 60} min
+
-

- Oppdatert{' '} - {' '} - (norsk tid). -

)}
); } -function Header({ - city, - activity, - onCityChange, - onActivityChange, -}: { - city: City; - activity: Activity; - onCityChange: (city: City) => void; - onActivityChange: (activity: Activity) => void; -}) { - return ( -
-

IT2810 · Gruppe 31

-

Turvær Norge

-

Finn været for din neste tur.

- - -
- ); -} - export function App() { - const [selectedCity, setSelectedCity] = useState(exampleCity); - const [selectedActivity, setSelectedActivity] = useState('Gåtur'); + const [selectedCityId, setSelectedCityId] = useStoredState( + 'sessionStorage', + 't31-selected-city', + cityIdSchema, + exampleCity.id, + ); + const [activity, setActivity] = useStoredState( + 'sessionStorage', + 't31-activity', + activitySchema, + 'Gåtur', + ); + const [sortMode, setSortMode] = useStoredState( + 'sessionStorage', + 't31-sort-mode', + sortModeSchema, + 'default', + ); + const [favoriteIds, setFavoriteIds] = useStoredState( + 'localStorage', + 't31-favorite-cities', + favoriteIdsSchema, + [], + ); + const [detailCityId, setDetailCityId] = useState(null); const weatherQueries = useQueries({ queries: cities.map((city) => weatherQuery(city)), }); - const selectedCityIndex = cities.findIndex( - ({ id }) => id === selectedCity.id, + const selectedCity = + cities.find(({ id }) => id === selectedCityId) ?? exampleCity; + const items = useMemo( + () => + cities.map((city, index) => { + const query = weatherQueries[index]; + return { + city, + weather: query?.data ?? null, + isLoading: query?.isPending ?? true, + hasError: query?.isError ?? false, + isFavorite: favoriteIds.includes(city.id), + score: scoreWeather(query?.data ?? null, activity), + rank: null, + }; + }), + [activity, favoriteIds, weatherQueries], ); - const weatherQueryResult = weatherQueries[selectedCityIndex]; - const weatherListItems: WeatherListItem[] = cities.map((city, index) => { - const query = weatherQueries[index]; - if (!query) { - return { city, weather: null, isLoading: true, hasError: false }; - } - return { - city, - weather: query.data ?? null, - isLoading: query.isPending, - hasError: query.isError, - }; - }); - - const weatherData = weatherQueryResult?.data ?? null; + const sortedItems = useMemo( + () => + [...items].sort((a, b) => { + if (sortMode === 'best') return b.score - a.score; + if (sortMode === 'temperature') + return ( + (b.weather?.current.temperature_2m ?? -Infinity) - + (a.weather?.current.temperature_2m ?? -Infinity) + ); + if (sortMode === 'rain') + return ( + (a.weather?.current.precipitation ?? Infinity) - + (b.weather?.current.precipitation ?? Infinity) + ); + if (sortMode === 'wind') + return ( + (a.weather?.current.wind_speed_10m ?? Infinity) - + (b.weather?.current.wind_speed_10m ?? Infinity) + ); + return 0; + }), + [items, sortMode], + ); + const rankedItems = sortedItems.map((item, index) => ({ + ...item, + rank: sortMode === 'best' && Number.isFinite(item.score) ? index + 1 : null, + })); + const detailCity = cities.find(({ id }) => id === detailCityId) ?? null; + const detailItem = rankedItems.find(({ city }) => city.id === detailCityId); + const toggleFavorite = (city: City) => + setFavoriteIds( + favoriteIds.includes(city.id) + ? favoriteIds.filter((id) => id !== city.id) + : [...favoriteIds, city.id], + ); return (
- void weatherQueryResult?.refetch()} + selectedCityId={selectedCity.id} + activity={activity} + sortMode={sortMode} + onCityChange={setSelectedCityId} + onActivityChange={setActivity} + onSortChange={setSortMode} /> setSelectedCityId(city.id)} + onOpenDetails={(city) => { + setSelectedCityId(city.id); + setDetailCityId(city.id); + }} + onToggleFavorite={toggleFavorite} + expandedCityId={detailCityId} + details={ + detailCity ? ( +
+
+ ) : null + } />