diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9e78517 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - t31-ci + - t31-deploy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e8bf78 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # la main bli ferdig, også hvis vi må rulle tilbake + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +defaults: + run: + shell: bash + +jobs: + quality: + name: Quality checks + runs-on: [self-hosted, linux, x64, t31-ci] + timeout-minutes: 30 + env: + CI: 'true' + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + + - name: Use the pinned npm version + run: npm install --global npm@11.11.1 + + - name: Install dependencies + run: npm ci + + - name: Check code, contracts, migration metadata, tests and builds + run: npm run check + + - name: Test release switching and rollback + run: bash scripts/test-deploy-project1.sh + + - name: Install test browsers + run: npm run test:e2e:install + + - name: Run browser checks + run: npm run test:e2e -- --workers=2 + + - name: Preserve browser failure evidence + if: failure() + # denne node20-versjonen finnes på git.ntnu.no og støtter enterprise + uses: actions/upload-artifact@v3.2.1-node20 + with: + name: browser-results-${{ github.run_id }}-${{ github.run_attempt }} + path: test-results/ + retention-days: 7 + + - name: Save the tested production build + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-artifact@v3.2.1-node20 + with: + name: project1-${{ github.sha }}-${{ github.run_attempt }} + path: web/dist/ + if-no-files-found: error + retention-days: 7 + + deploy: + name: Deploy Project 1 + needs: quality + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + runs-on: [self-hosted, linux, x64, t31-deploy] + timeout-minutes: 10 + environment: + name: production + url: http://it2810-31.idi.ntnu.no/project1/ + concurrency: + group: project1-production + cancel-in-progress: false + steps: + - name: Check that this commit is still the tip of main + id: latest + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCommit({ + ...context.repo, + ref: 'main', + }); + core.setOutput('current', data.sha === context.sha); + if (data.sha !== context.sha) { + core.notice('A newer commit is on main; skip this outdated deployment.'); + } + + - name: Check out the deployment script from the tested commit + if: steps.latest.outputs.current == 'true' + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download the tested production build + if: steps.latest.outputs.current == 'true' + uses: actions/download-artifact@v3-node20 + with: + name: project1-${{ github.sha }}-${{ github.run_attempt }} + path: web/dist/ + + - name: Publish to Apache and verify the release + if: steps.latest.outputs.current == 'true' + env: + RELEASE_ID: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + run: bash scripts/deploy-project1.sh web/dist "$RELEASE_ID" diff --git a/README.md b/README.md index ebeaa0a..4549a7d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,98 @@ Applikasjonen skal: - vise tydelig lasting og feilmelding ved API-feil - fungere på både mobil og desktop -Værdata hentes direkte når applikasjonen brukes og mellomlagres ikke. +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 + +Komponentene arbeider bare med prosjektets egne TypeScript-typer. + +### Hente værdata + +```ts +const weather = await getWeather(city); +``` + +Her skjer følgende: + +1. `city` inneholder navn, breddegrad og lengdegrad. +2. `getWeather(city)` sender et `fetch`-kall til Open-Meteo. +3. `await` venter til API-kallet er ferdig. +4. Relevante felter fra API-responsen mappes til prosjektets egen WeatherData-type. +5. Resultatet lagres i variabelen `weather`. + +Variabelen kan deretter brukes slik: + +```ts +console.log(weather.temperature); +console.log(weather.windSpeed); +console.log(weather.precipitation); +``` + +### Eksempel med feilhåndtering + +```ts +try { + const weather = await getWeather(city); + console.log(weather); +} catch (error) { + console.error('Kunne ikke hente værdata:', error); +} +``` + +Hvis API-kallet mislykkes, kaster `getWeather` en feil som React-komponenten kan fange og vise som en forståelig feilmelding til brukeren. + +### Dataflyt + +```mermaid +flowchart LR + City["City"] --> Service["getWeather(city)"] + Service --> API["Open-Meteo"] + API --> Service + Service --> Data["WeatherData"] + Data --> Component["React-komponent"] +``` + +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. + +API-et kan brukes direkte fra nettleseren uten API-nøkkel. Byens +breddegrad og lengdegrad sendes til `/v1/forecast`. Applikasjonen ber om +temperatur, følt temperatur, luftfuktighet, nedbør, værkode, vindstyrke +og informasjon om dag eller natt. + +### Valgte værdata + +Vi velger følgende data: + +| Felt | Bruk | +| ---------------------- | ---------------------- | +| `temperature_2m` | Temperatur nå | +| `apparent_temperature` | Føles som | +| `weather_code` | Værtype og ikon | +| `precipitation` | Nedbør | +| `wind_speed_10m` | Vindstyrke | +| `relative_humidity_2m` | Luftfuktighet | +| `is_day` | Dag/natt | +| `time` | Tidspunkt for målingen | + +Eksempel: + +```bash +https://api.open-meteo.com/v1/forecast?latitude=63.4305&longitude=10.3951¤t=temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,weather_code,wind_speed_10m,is_day&wind_speed_unit=ms&timezone=auto +``` ## Teknologi @@ -122,4 +213,4 @@ Velg et issue før du starter, og lenk det i PR-en, for eksempel `Closes #8`. Å ## Dokumentasjon -[Roadmap](roadmap.md) · [Utviklerguide og planlagt dataflyt](docs/development.md) · [Krav og gjenstående arbeid](docs/requirements.md) · [Tester](docs/testing.md) +[Roadmap](roadmap.md) · [Utviklerguide og planlagt dataflyt](docs/development.md) · [Delte statuskomponenter](docs/ui-feedback.md) · [Krav og gjenstående arbeid](docs/requirements.md) · [Tester](docs/testing.md) diff --git a/deploy/apache-project1.conf b/deploy/apache-project1.conf new file mode 100644 index 0000000..53e2bf4 --- /dev/null +++ b/deploy/apache-project1.conf @@ -0,0 +1,15 @@ +# slå på med a2enconf t31-project1, sjekk configen og last apache på nytt +RedirectMatch 302 ^/project1$ /project1/ +Alias /project1/ /srv/t31-project1/current/ + + + Options +FollowSymLinks -Indexes -ExecCGI -Includes + AllowOverride None + Require all granted + DirectoryIndex index.html + # prosjekt 1 er bare statiske filer, ikke kjør noe backend her + SetHandler default-handler + + Header set Cache-Control "no-cache" + + diff --git a/docs/development.md b/docs/development.md index 0d0d844..6781b81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,6 +5,7 @@ The README covers everyday setup. This page contains optional infrastructure com ## Structure and type safety - `web/src/WeatherExample.tsx`: the single weather example. +- `web/src/components/feedback/`: shared loading, error, empty and offline messages plus a typed TanStack Query adapter; see [usage and extension](ui-feedback.md). - `web/src/features/weather/`: typed city definitions, Zod response validation and TanStack Query options. - `web/src/shared/storage.ts`: storage helper for future preferences and favorite city IDs. - `project2/api/`: independent Node/Hono/GraphQL API with native PostgreSQL and Drizzle migrations. @@ -12,6 +13,8 @@ The README covers everyday setup. This page contains optional infrastructure com Project 1 calls Open-Meteo directly. Wind is requested in m/s, timestamps use Europe/Oslo and precipitation is labelled with the provider's interval. Only the active response is held in memory; weather payloads are never persisted. Future storage features should store preferences and city IDs only. +`fetchWeather` returns `CityWeather | null`: HTTP 204, a JSON `null` response, or a validated response with a missing/null `current` observation means no weather is available. Partial observations, invalid units and malformed responses still fail Zod validation and produce a retryable error. Consumers must handle the empty result before reading weather fields. `WeatherExample` shows loading during initial requests and retries, explains requests paused while offline, and provides a retry button for errors and empty results. + Project 2 requires GraphQL and a database installed directly on the VM. Hosted Supabase and Docker backend/database deployment do not fit that requirement. Docker here is only an optional frontend development tool; Redis is not needed. ## Native PostgreSQL setup for Project 2 @@ -77,7 +80,7 @@ Project 2's eventual client belongs at `/project2/`. Run its built API on Node, ## Planned Project 1 structure -The following diagrams are retained from the team's project plan; these components are not implemented by the skeleton. +The following diagrams are retained from the team's project plan. Shared loading/error feedback is implemented; the full app, list and details composition remains planned. ## Dataflyt diff --git a/docs/testing.md b/docs/testing.md index b0c4ab0..eab9dad 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,23 @@ Playwright smoke tests intercept provider data and check the production example, - `docker compose config --quiet`: passed. The optional frontend image/container was not built or started during this verification. - Verification toolchain: isolated Node 24.21.0 and npm 11.11.1. The machine's default Node/npm were not changed; follow README setup before installing locally. +## Loading, error and empty states — 2026-09-14 + +Codex assisted with the loading/error/empty-state implementation, regression tests and documentation. The changes were checked with an isolated Node 24.21.0 / npm 11.11.1 toolchain after `npm ci`. + +- `npm run check`: passed formatting, zero-warning lint, generated GraphQL contract verification, strict TypeScript, migration metadata, all 18 Vitest tests (15 frontend, 3 API), the unchanged success snapshot and both builds. +- Frontend tests cover a held loading response, HTTP failure with manual retry and loading feedback, no-content/null/missing observations, incomplete measurements, invalid JSON, network failure, and offline pause/resume. Invalid data never reaches the rendered weather fields. +- `npm run test:e2e`: all 25 tests passed in Chromium, Firefox, WebKit and emulated iPhone portrait/landscape. New checks cover pending requests, error semantics, keyboard retry, empty-result recovery, disabled duplicate retries and missing-field responses without uncaught page errors. Axe reported no violations in loading, error, empty or successful states. + +Automated tests use mocked provider responses. Real-device and screen-reader testing, live provider behavior, deployment and teammate review remain separate checks. + +## Shared feedback components — 2026-09-14 + +Codex assisted with extracting standalone loading, error, empty and offline messages, a typed TanStack Query adapter, scoped CSS, component tests and the [usage guide](ui-feedback.md). The weather example now consumes the shared components. Component tests cover standalone use, keyboard actions, disabled actions, independent instances, custom empty-list rules, valid falsy data and disabled queries. + +- `npm run check`: passed formatting, zero-warning lint, generated contracts, strict TypeScript, migration metadata, all 27 Vitest tests (24 frontend, 3 API), the unchanged success snapshot and both builds, using Node 24.21.0 and npm 11.11.1. +- `npm run test:e2e`: all 25 tests passed with the shared components in Chromium, Firefox, WebKit and emulated iPhone portrait/landscape. This includes keyboard retry, state transitions, empty-result recovery, malformed data, overflow and axe accessibility checks. Axe found no violations in the tested loading, error, empty and success states. + ## Remaining manual checks - Final client: navigation, activity assessment, filters, sort, favorites and both storage lifetimes. diff --git a/docs/ui-feedback.md b/docs/ui-feedback.md new file mode 100644 index 0000000..3af5652 --- /dev/null +++ b/docs/ui-feedback.md @@ -0,0 +1,65 @@ +# Shared UI feedback + +Import feedback components from `web/src/components/feedback`. They are self-authored React components with plain CSS and no additional runtime dependencies. `WeatherExample` is the working integration. + +## Standalone messages + +| Component | Default message | Semantics | +| ---------------- | ------------------------------------------- | ------------- | +| `LoadingMessage` | Laster… | `role=status` | +| `ErrorMessage` | Noe gikk galt. Prøv igjen om litt. | `role=alert` | +| `EmptyMessage` | Ingen resultater. | `role=status` | +| `OfflineMessage` | Du er frakoblet. Venter på nettforbindelse. | `role=status` | + +Each component accepts a custom `message` string and an optional `action` with `label`, `onClick` and `disabled`. They work without TanStack Query or a provider, so features can use them for local filters, favorites and other UI state. + +For example, inside a feature with a `resetFilters` callback: + +```tsx +import { EmptyMessage } from './components/feedback'; + +; +``` + +Actions use native buttons with an explicit `type="button"`, visible keyboard focus and a minimum 44px height. Disabled actions cannot be activated. The button sits outside the live message region, so the alert contains only the explanation. Roles are fixed by the component to keep semantics consistent across features. + +## TanStack Query integration + +`QueryFeedback` selects a message from an existing typed query result. It does not fetch data, create a query client, render results or change query options. Features continue to own query keys, live REST requests, Zod validation and data presentation. + +For a feature with an existing `citiesQuery` whose successful data is an array: + +```tsx +import { QueryFeedback } from './components/feedback'; + + cities.length === 0} + messages={{ + loading: 'Henter byer…', + error: 'Kunne ikke hente byene. Prøv igjen om litt.', + empty: 'Ingen byer er tilgjengelige akkurat nå.', + }} + retryLabel="Hent byer på nytt" +/>; +``` + +- `query`: the existing query result; data and error types are inferred. +- `messages`: optional overrides for `loading`, `error`, `empty` and `offline`. Omitted values use the standalone defaults. Use understandable, feature-specific text rather than raw provider errors. +- `isEmpty`: optional domain rule. Null/undefined data is always empty and is never passed to the predicate; valid values such as `0` and `false` are not empty. Empty arrays require an explicit length check. +- `retryLabel`: accessible button text, defaulting to `Prøv igjen`. Errors and empty responses offer a manual `refetch`; retry buttons are disabled while a request is fetching or paused. + +Paused and fetching states take precedence over previous errors or empty results. A disabled query that is pending but idle shows no loading message. Successful nonempty data shows no feedback; the feature renders its own data alongside `QueryFeedback`. + +Use one adapter for a list loaded by one query. Use per-card feedback when cards fetch independently, and include the city name in messages and retry labels so users can distinguish actions. For client-side filtering, use `EmptyMessage` with an action that clears filters rather than refetching unchanged server data. + +## Styling and extension + +The components import `feedback.css` themselves. All selectors are scoped under `feedback-message`, so unrelated alerts and buttons keep their own styles. Wrapping text, logical spacing and native button sizing allow the same components to fit cards and full-page sections. + +Add feature wording and behavior through props. Change common spacing, colors or focus styles in the shared stylesheet. Keep weather types, API URLs and feature-specific state out of the standalone components. The adapter reads TanStack Query state directly; it does not duplicate that state in effects or component state. + +Component tests cover standalone use, keyboard actions, disabled actions, independent instances, custom empty-list rules, valid falsy data and disabled queries. The weather integration tests and Playwright suite cover the full request/retry lifecycle and accessibility checks. diff --git a/docs/weather-app-architecture.md b/docs/weather-app-architecture.md new file mode 100644 index 0000000..d6ac665 --- /dev/null +++ b/docs/weather-app-architecture.md @@ -0,0 +1,72 @@ +# 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) +``` + +## 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..bf2f931 --- /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 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-states.spec.ts b/e2e/weather-states.spec.ts new file mode 100644 index 0000000..0e0fc70 --- /dev/null +++ b/e2e/weather-states.spec.ts @@ -0,0 +1,156 @@ +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 }) => { + let finishRequest = () => {}; + const responseReady = new Promise((resolve) => { + finishRequest = resolve; + }); + await page.route('https://api.open-meteo.com/**', async (route) => { + await responseReady; + await route.fulfill({ json: weatherResponse }); + }); + await page.goto('./'); + try { + 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', { exact: true })).toHaveCount( + cities.length, + ); + await expect(page.getByRole('status')).toHaveCount(0); +}); + +test('announces API errors and supports keyboard retry with loading feedback', async ({ + page, +}) => { + let calls = 0; + let finishRetry = () => {}; + const responseReady = new Promise((resolve) => { + 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 }); + return; + } + await responseReady; + 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(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(retry).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(card.getByRole('status')).toHaveText('Henter værdata…'); + await expect(card.getByRole('alert')).toHaveCount(0); + } finally { + finishRetry(); + } + await expect(card.getByText('14 °C')).toBeVisible(); + await expect(page.getByRole('status')).toHaveCount(0); + expect(calls).toBe(2); +}); + +test('explains empty results and disables repeat requests while retrying', async ({ + page, +}) => { + let calls = 0; + let finishRetry = () => {}; + const responseReady = new Promise((resolve) => { + 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 }); + return; + } + await responseReady; + await route.fulfill({ json: weatherResponse }); + }); + await page.goto('./'); + const card = page.getByRole('article', { name: 'Værkort for Trondheim' }); + try { + 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 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( + card.getByRole('button', { name: 'Prøv igjen' }), + ).toBeDisabled(); + } finally { + finishRetry(); + } + await expect(card.getByText('14 °C')).toBeVisible(); + await expect(page.getByRole('status')).toHaveCount(0); + expect(calls).toBe(2); +}); + +test('keeps the app usable when weather fields are missing', async ({ + page, +}) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('https://api.open-meteo.com/**', async (route) => { + await route.fulfill({ json: { ...weatherResponse, current: {} } }); + }); + await page.goto('./'); + await expect(page.getByRole('alert')).toHaveText( + cities.map(() => 'Kunne ikke hente værdata.'), + ); + await expect( + page.getByRole('heading', { name: 'Turvær Norge' }), + ).toBeVisible(); + 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 8586ca1..8fe18ab 100644 --- a/e2e/weather.spec.ts +++ b/e2e/weather.spec.ts @@ -1,7 +1,8 @@ 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; @@ -10,11 +11,22 @@ test('weather example loads accessibly without horizontal overflow', async ({ await route.fulfill({ json: weatherResponse }); }); await page.goto('./'); + await expect( + page.getByRole('heading', { name: 'Været 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(); - await expect(page.getByText('14 °C')).toBeVisible(); - expect(calls).toBe(1); + await expect( + page + .locator('section[aria-labelledby="selected-weather-title"]') + .getByText('14°'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Været i norske byer' }), + ).toBeVisible(); + expect(calls).toBe(cities.length); expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, diff --git a/scripts/deploy-project1.sh b/scripts/deploy-project1.sh new file mode 100644 index 0000000..41111cc --- /dev/null +++ b/scripts/deploy-project1.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# kjør som t31-deploy, uten sudo. apache leser fra current +# ryddet opp med hjelp fra codex +set -Eeuo pipefail +umask 022 + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +check_input() { + [[ $release_id =~ ^[a-f0-9]{40}-[0-9]+-[0-9]+$ ]] || fail 'ugyldig release-id' + [[ -d $deploy_root/releases && -w $deploy_root ]] || fail 'deploy-mappa må settes opp først' + [[ -s $source_dir/index.html && -d $source_dir/assets ]] || fail 'bygget mangler index.html eller assets' + [[ -z $(find "$source_dir" -mindepth 1 ! -type f ! -type d -print -quit) ]] || fail 'bygget kan bare inneholde vanlige filer og mapper' + [[ ! -e $deploy_root/current || -L $deploy_root/current ]] || fail 'current må være en symlink' + [[ $health_url == http://* || $health_url == https://* ]] || fail 'sjekk-adressen må bruke http eller https' +} + +# bytt hele siden på en gang, så ingen får et halvferdig bygg +set_current() { + rm -f -- "$work_dir/current" + ln -s -- "$1" "$work_dir/current" + mv -Tf -- "$work_dir/current" "$deploy_root/current" +} + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + + # noe feilet etter byttet? legg tilbake forrige versjon + if [[ $status != 0 && $switched == true ]]; then + if [[ -n $previous ]]; then + set_current "$previous" + printf 'deploy feilet, gikk tilbake til %s\n' "$previous" >&2 + else + rm -- "$deploy_root/current" + printf 'første deploy feilet, fjernet current\n' >&2 + fi + fi + rm -rf -- "$work_dir" + exit "$status" +} + +prepare_release() { + mkdir "$work_dir/build" + cp -R -- "$source_dir/." "$work_dir/build/" + printf '%s\n' "$release_id" > "$work_dir/build/release.txt" + find "$work_dir/build" -type d -exec chmod 755 {} + + find "$work_dir/build" -type f -exec chmod 644 {} + + mv -T -- "$work_dir/build" "$release_dir" +} + +# et 200-svar er ikke nok, apache må sende akkurat den fila vi bygde +check_file() { + local file=$1 url_path=${2-$1} + curl --fail --silent --show-error --noproxy '*' --connect-timeout 5 --max-time 15 \ + "$health_url/$url_path" -o "$work_dir/response" || fail "fikk ikke hentet $file fra apache" + cmp -s -- "$release_dir/$file" "$work_dir/response" || fail "apache sender feil innhold for $file" +} + +check_release() { + check_file release.txt + check_file index.html '' # sjekk selve /project1/, ikke bare index.html + find "$release_dir/assets" -type f -printf '%P\0' > "$work_dir/assets" + while IFS= read -r -d '' asset; do + [[ $asset =~ ^[A-Za-z0-9_./-]+$ ]] || fail 'uventet filnavn i assets' + check_file "assets/$asset" + done < "$work_dir/assets" +} + +[[ $# == 2 ]] || fail 'bruk: deploy-project1.sh BYGGMAPPE RELEASE_ID' +source_dir=$(realpath -- "$1") +release_id=$2 +deploy_root=${T31_DEPLOY_ROOT:-/srv/t31-project1} +health_url=${T31_DEPLOY_URL:-http://127.0.0.1/project1/} +health_url=${health_url%/} +check_input + +# bare en deploy av gangen, også om noen kjører scriptet manuelt +exec 9>"$deploy_root/deploy.lock" +flock -w 60 9 || fail 'en annen deploy holder på' +release_dir=$deploy_root/releases/$release_id +[[ ! -e $release_dir ]] || fail 'denne releasen finnes fra før, start en ny kjøring' +previous=$(readlink "$deploy_root/current" || true) +work_dir=$(mktemp -d "$deploy_root/.deploy-XXXXXXXX") +switched=false +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +prepare_release +switched=true # rollback må være klar før vi bytter +set_current "$release_dir" +check_release +printf 'publisert %s på %s/\n' "$release_id" "$health_url" diff --git a/scripts/test-deploy-project1.sh b/scripts/test-deploy-project1.sh new file mode 100644 index 0000000..4e3edd2 --- /dev/null +++ b/scripts/test-deploy-project1.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# sjekker deploy og rollback med en liten lokal webserver +set -Eeuo pipefail +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +scratch=$(mktemp -d) +server_pid='' +cleanup() { + if [[ -n $server_pid ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf -- "$scratch" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +mkdir -p "$scratch/site/releases" "$scratch/build/assets" "$scratch/http" +ln -s "$scratch/site/current" "$scratch/http/project1" +printf '\n' > "$scratch/build/index.html" +printf 'console.log("release one");\n' > "$scratch/build/assets/app.js" + +python3 - "$scratch" <<'PY' & +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import sys + +root = Path(sys.argv[1]) + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(root / 'http'), **kwargs) + + def do_GET(self): + if ((root / 'bad-index').exists() and self.path in ['/project1/', '/project1/index.html']) or ( + (root / 'bad-asset').exists() and '/assets/' in self.path + ): + self.send_response(200) + self.end_headers() + self.wfile.write(b'wrong release') + else: + super().do_GET() + + def log_message(self, *args): + pass + +server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) +(root / 'port').write_text(str(server.server_port)) +server.serve_forever() +PY +server_pid=$! +for ((attempt=0; attempt<100; attempt++)); do + [[ -s $scratch/port ]] && break + sleep 0.05 +done +[[ -s $scratch/port ]] || { printf 'HTTP server did not start.\n' >&2; exit 1; } + +export T31_DEPLOY_ROOT=$scratch/site +T31_DEPLOY_URL=http://127.0.0.1:$(cat "$scratch/port")/project1/ +export T31_DEPLOY_URL +first=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1 +second=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1 +failed=cccccccccccccccccccccccccccccccccccccccc-3-1 +deploy() { bash "$repo_root/scripts/deploy-project1.sh" "$scratch/build" "$1"; } +expect_failure() { + if deploy "$1" > "$scratch/failure.log" 2>&1; then + printf 'Expected deployment %s to fail.\n' "$1" >&2 + exit 1 + fi +} + +touch "$scratch/bad-index" +expect_failure "$failed" +[[ ! -e $scratch/site/current && ! -L $scratch/site/current ]] +rm "$scratch/bad-index" +deploy "$first" +[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$first" ]] +cmp "$scratch/build/assets/app.js" "$scratch/site/current/assets/app.js" +[[ $(stat -c '%a' "$scratch/site/current/index.html") == 644 ]] + +printf 'console.log("release two");\n' > "$scratch/build/assets/app.js" +deploy "$second" +[[ -f $scratch/site/releases/$first/index.html ]] +[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]] +expect_failure "$second" +expect_failure '../../escape' +ln -s /etc/passwd "$scratch/build/assets/link" +expect_failure "${failed%1}2" +rm "$scratch/build/assets/link" + +touch "$scratch/bad-index" +expect_failure "${failed%1}3" +[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]] +rm "$scratch/bad-index" +touch "$scratch/bad-asset" +expect_failure "${failed%1}4" +[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]] +rm "$scratch/bad-asset" +curl --fail --silent --noproxy '*' "$T31_DEPLOY_URL/release.txt" | cmp - "$scratch/site/current/release.txt" +[[ -z $(find "$scratch/site" -maxdepth 1 -name '.deploy-*' -print -quit) ]] +printf 'Deployment checks passed: first release, update, validation, index/asset rollback and cleanup.\n' diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..0f20225 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,191 @@ +/**********/ +/* 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); + + margin-inline: auto; +} + +/**********/ +/* Header */ +/**********/ + +.app-header { + background-color: var(--color-header-background); + + border-radius: var(--radius-md); + + box-shadow: var(--shadow-md); + + padding: var(--space-md); + + margin-block-start: var(--space-sm); + margin-block-end: var(--space-lg); + + display: grid; + + grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); + + gap: var(--space-lg); +} + +.app-header__title-container { + display: flex; + flex-direction: column; +} + +.app-header__weather-controls-section { + display: flex; + flex-direction: column; + + gap: var(--space-md); +} + +.app-header__weather-controls-title { + font-weight: var(--weight-medium); + font-size: var(--font-md); +} + +.app-header__title-container p { + color: var(--color-text-muted); +} + +.app-header__weather-controls { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); +} + +.app-header__weather-controls label { + display: flex; + flex-direction: column; + gap: var(--space-xs); + + flex: 1 1 10rem; +} + +.app-header__weather-controls select { + width: 100%; +} + +/*******************/ +/* Weather Details */ +/*******************/ + +.details-overlay { + position: fixed; + inset: 0; + z-index: 100; + + display: grid; + place-items: center; + + padding: var(--space-md); + + background: rgb(23 61 53 / 35%); +} + +.details-backdrop { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + border: 0; + padding: 0; + + background: transparent; +} + +.details-dialog { + position: relative; + z-index: 1; + + width: min(42rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + overflow: auto; + + background-color: var(--color-background); + + padding: var(--space-lg); + + border-radius: var(--radius-lg); + + border: 1px solid var(--color-border); + + box-shadow: var(--shadow-lg); +} + +.details-dialog__close-button { + width: 100%; + + padding: var(--space-xs); + + border-radius: var(--radius-md); + + 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); +} + +.details-dialog__close-button: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; +} + +.details-dialog__temperature-condition > p:first-child { + 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); +} + +.details-dialog__detail-row { + display: flex; + gap: var(--space-xs); +} + +.details-dialog__detail-row dd { + font-weight: var(--weight-medium); +} + +.details-dialog__detail-row dt { + 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); +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..1783441 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useQueries } from '@tanstack/react-query'; +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 { 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 sortModeSchema = z.enum(sortModes); +const cityIdSchema = z + .string() + .refine((id): id is CityId => cityIds.has(id as CityId)); +const favoriteIdsSchema = z.array(cityIdSchema); + +function Header({ + selectedCityId, + activity, + sortMode, + onCityChange, + onActivityChange, + onSortChange, +}: { + selectedCityId: CityId; + activity: ActivityId; + sortMode: SortMode; + onCityChange: (cityId: CityId) => void; + onActivityChange: (activity: ActivityId) => void; + onSortChange: (sortMode: SortMode) => void; +}) { + return ( +
+
+

Turvær Norge

+

IT2810 · Gruppe 31

+
+ +
+

+ 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 scoreWeather(weather: CityWeather | null, activity: ActivityId) { + if (!weather) return Number.NEGATIVE_INFINITY; + + const { + temperature_2m: temperature, + precipitation, + wind_speed_10m: wind, + } = weather.current; + + const profiles: Record< + ActivityId, + { + idealTemperature: number; + temperatureWeight: number; + rainWeight: number; + windWeight: number; + } + > = { + walking: { + idealTemperature: 14, + temperatureWeight: 2, + rainWeight: 16, + windWeight: 3, + }, + cycling: { + idealTemperature: 16, + temperatureWeight: 2, + rainWeight: 20, + windWeight: 5, + }, + running: { + idealTemperature: 11, + temperatureWeight: 3, + rainWeight: 24, + windWeight: 4, + }, + skiing: { + idealTemperature: -3, + temperatureWeight: 4, + rainWeight: 10, + windWeight: 2, + }, + }; + + const profile = profiles[activity]; + + const snowCode = + weather.current.weather_code >= 71 && weather.current.weather_code <= 77; + + const snowBonus = activity === 'skiing' && snowCode ? 35 : 0; + + return ( + 100 - + Math.abs(temperature - profile.idealTemperature) * + profile.temperatureWeight - + precipitation * profile.rainWeight - + wind * profile.windWeight + + snowBonus + ); +} + +function getActivityName(activityId: ActivityId) { + return ( + activities.find((activity) => activity.id === activityId)?.name ?? + activityId + ); +} + +function DetailsOverlay({ + children, + onClose, +}: { + children: ReactNode; + onClose: () => void; +}) { + const overlayRef = useRef(null); + + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const focusableSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const getFocusable = () => + Array.from( + overlay.querySelectorAll(focusableSelector), + ).filter((element) => !element.hasAttribute('disabled')); + + (getFocusable()[0] ?? overlay).focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab') return; + + const focusable = getFocusable(); + if (focusable.length === 0) { + event.preventDefault(); + overlay.focus(); + return; + } + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + + return ( +
+
+ ); +} + +function MainWeather({ + city, + weather, + activity, + feedback, + onClose, +}: { + city: City; + weather: CityWeather | null; + activity: ActivityId; + feedback: ReactNode; + onClose: () => void; +}) { + return ( +
+
+
+

Valgt by

+

Været i {city.name}

+
+
+

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

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

{weather.current.temperature_2m}°

+

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

+
+
+
+
+
Nedbør:
+
{weather.current.precipitation} mm
+
+
+
Vind:
+
{weather.current.wind_speed_10m} m/s
+
+
+

+ Oppdatert{' '} + +

+ + )} + + +
+ ); +} + +export function App() { + const [selectedCityId, setSelectedCityId] = useStoredState( + 'sessionStorage', + 't31-selected-city', + cityIdSchema, + exampleCity.id, + ); + const [activity, setActivity] = useStoredState( + 'sessionStorage', + 't31-activity', + activitySchema, + 'walking', + ); + 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 selectedCity = + cities.find(({ id }) => id === selectedCityId) ?? exampleCity; + const items = useMemo( + () => + cities.map((city, index) => { + const query = weatherQueries[index]; + return { + city, + weather: query?.data ?? null, + feedback: query && ( + + ), + isFavorite: favoriteIds.includes(city.id), + score: scoreWeather(query?.data ?? null, activity), + rank: null, + }; + }), + [activity, favoriteIds, weatherQueries], + ); + 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 ( + <> +
+
+ 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 bc6021b..5e4c2c8 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -1,7 +1,11 @@ import { expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + onlineManager, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { WeatherExample } from './WeatherExample'; import { WEATHER_URL, fetchWeather } from './features/weather/weather'; @@ -25,15 +29,146 @@ 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 () => { +it('shows a loading message until the response arrives', async () => { + let finishRequest = () => {}; + const responseReady = new Promise((resolve) => { + finishRequest = resolve; + }); + server.use( - http.get(WEATHER_URL, () => new HttpResponse(null, { status: 503 })), + http.get(WEATHER_URL, async () => { + await responseReady; + return HttpResponse.json(weatherResponse); + }), + ); + + renderExample(); + + try { + expect(screen.getByRole('status')).toHaveTextContent('Henter værdata…'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + finishRequest(); + } + + 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 = () => {}; + const responseReady = new Promise((resolve) => { + finishRetry = resolve; + }); + server.use( + http.get(WEATHER_URL, async () => { + calls++; + if (calls === 1) return new HttpResponse(null, { status: 503 }); + await responseReady; + return HttpResponse.json(weatherResponse); + }), ); renderExample(); - await screen.findByRole('alert'); - server.resetHandlers(); - await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + try { + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Kunne ikke hente værdata.', + ); + expect(calls).toBe(1); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Henter værdata…', + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + finishRetry(); + } await screen.findByText('14 °C'); + expect(calls).toBe(2); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Prøv igjen' }), + ).not.toBeInTheDocument(); +}); +it.each([ + ['HTTP 204', () => new HttpResponse(null, { status: 204 })], + ['null response', () => HttpResponse.json(null)], + [ + 'null observation', + () => HttpResponse.json({ ...weatherResponse, current: null }), + ], + [ + 'missing observation', + () => + HttpResponse.json({ + timezone: weatherResponse.timezone, + current_units: weatherResponse.current_units, + }), + ], +])( + 'shows an empty state for %s and allows a new request', + async (_name, response) => { + server.use(http.get(WEATHER_URL, response)); + renderExample(); + await screen.findByText( + 'Ingen værdata er tilgjengelige for Trondheim akkurat nå. Prøv igjen om litt.', + ); + expect(screen.getByRole('status')).toHaveTextContent('Ingen værdata'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('definition')).not.toBeInTheDocument(); + server.resetHandlers(); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + await screen.findByText('14 °C'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }, +); +it.each([ + ['missing fields', () => HttpResponse.json({})], + [ + 'partial observation', + () => + HttpResponse.json({ + ...weatherResponse, + current: { temperature_2m: 14 }, + }), + ], + [ + 'null measurement', + () => + HttpResponse.json({ + ...weatherResponse, + current: { ...weatherResponse.current, temperature_2m: null }, + }), + ], + ['invalid JSON', () => new HttpResponse('{')], + ['network failure', () => HttpResponse.error()], +])( + 'handles %s without crashing and allows recovery', + async (_name, response) => { + server.use(http.get(WEATHER_URL, response)); + renderExample(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Kunne ikke hente værdata.', + ); + expect(screen.getByRole('heading', { name: 'Turvær Norge' })).toBeVisible(); + expect(screen.queryByRole('definition')).not.toBeInTheDocument(); + server.resetHandlers(); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + await screen.findByText('14 °C'); + }, +); +it('explains a paused offline request and resumes when connected', async () => { + onlineManager.setOnline(false); + try { + renderExample(); + expect(screen.getByRole('status')).toHaveTextContent('Du er frakoblet.'); + act(() => { + onlineManager.setOnline(true); + }); + await screen.findByText('14 °C'); + } finally { + onlineManager.setOnline(true); + } }); it('rejects malformed provider data and unexpected units', async () => { server.use( @@ -60,7 +195,7 @@ it('includes coordinates, explicit units, and cancellation in requests', async ( return HttpResponse.json(weatherResponse); }), ); - expect((await fetchWeather(exampleCity)).city.id).toBe('trondheim'); + expect((await fetchWeather(exampleCity))?.city.id).toBe('trondheim'); const controller = new AbortController(); controller.abort(); await expect(fetchWeather(exampleCity, controller.signal)).rejects.toThrow(); diff --git a/web/src/WeatherExample.tsx b/web/src/WeatherExample.tsx index 3045ab4..2e3c417 100644 --- a/web/src/WeatherExample.tsx +++ b/web/src/WeatherExample.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { QueryFeedback } from './components/feedback'; import { exampleCity } from './features/weather/cities'; import { weatherQuery } from './features/weather/weather'; @@ -14,20 +15,17 @@ export function WeatherExample() {

Været i {exampleCity.name}

- {weather.isPending &&

Henter værdata…

} - {weather.isError && ( -
-

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

- -
- )} + {weather.data && ( <>
diff --git a/web/src/WeatherList.test.tsx b/web/src/WeatherList.test.tsx new file mode 100644 index 0000000..32d1993 --- /dev/null +++ b/web/src/WeatherList.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { vi, expect, it } from 'vitest'; +import { WeatherList } from './components/WeatherList/WeatherList'; +import { exampleCity } from './features/weather/cities'; + +it('shows feedback when there are no weather cards to display', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: 'Været i norske byer' }), + ).toBeVisible(); + 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 new file mode 100644 index 0000000..1c0a9b1 --- /dev/null +++ b/web/src/components/WeatherCard/WeatherCard.css @@ -0,0 +1,124 @@ +h3 { + font-size: var(--font-xl); +} + +.weather-card { + 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); + + 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); +} + +.weather-card-selected { + border-color: var(--color-card-border-selected); + background-color: var(--color-card-selected); + + border-width: 2px; +} + +.weather-card__header { + display: flex; + flex-wrap: wrap; + + justify-content: space-between; + align-items: center; +} + +.weather-card__favorite-button { + background-color: transparent; + border: 0; + + font-size: var(--control-font-2xl); +} + +.weather-card__favorite-button:hover, +.weather-card__favorite-button:focus-visible { + border-radius: var(--radius-lg); + + background-color: var(--color-action-surface-hover); + + border: 1px solid var(--color-border-hover); +} + +.weather-card__favorite-button:active { + background-color: var(--color-action-surface-active); +} + +.weather-card__button-full-width { + width: 100%; + + padding: var(--space-xs); + + border-radius: var(--radius-md); + + 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); +} + +.weather-card__button-full-width:active { + background-color: var(--color-action-surface-active); +} + +.weather-card__temperature-condition { + display: flex; + flex-direction: column; +} + +.weather-card__temperature-condition > dd:first-of-type { + font-size: var(--font-lg); + font-weight: var(--weight-semibold); +} + +.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); +} + +.weather-card__detail-row { + display: flex; + gap: var(--space-xs); +} + +.weather-card__detail-row dd { + font-weight: var(--weight-medium); +} + +.weather-card__detail-row dt { + color: var(--color-text-secondary); +} + +.weather-card__rank { + padding: var(--space-xs); + + background-color: var(--color-action-surface); + + border-radius: var(--radius-md); +} + +.weather-card__weather-error { + display: grid; + gap: var(--space-sm); +} diff --git a/web/src/components/WeatherCard/WeatherCard.tsx b/web/src/components/WeatherCard/WeatherCard.tsx new file mode 100644 index 0000000..0e4fee0 --- /dev/null +++ b/web/src/components/WeatherCard/WeatherCard.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from 'react'; +import type { City } from '../../features/weather/cities'; +import type { CityWeather } from '../../features/weather/weather'; +import './WeatherCard.css'; + +export type WeatherCardProps = { + city: City; + weather: CityWeather | null; + feedback: ReactNode; + isSelected: boolean; + onSelect: (city: City) => void; + onOpenDetails: (city: City) => void; + isFavorite: boolean; + onToggleFavorite: (city: City) => void; + rank: number | null; +}; + +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, + feedback, + isSelected, + onSelect, + onOpenDetails, + isFavorite, + onToggleFavorite, + rank, +}: WeatherCardProps) { + const weatherType = weather + ? (weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype') + : null; + + return ( +
+
+

{city.name}

+ + +
+ + {rank !== null && ( +

#{rank} for valgt aktivitet

+ )} + + {feedback} + {weather && ( +
+
+
Temperatur
+
{weather.current.temperature_2m} °C
+
Værtype
+
{weatherType}
+
+ +
+
Nedbør:
+
{weather.current.precipitation} mm
+
+
+
Vind:
+
{weather.current.wind_speed_10m} m/s
+
+
+ )} + + + + +
+ ); +} diff --git a/web/src/components/WeatherList/WeatherList.css b/web/src/components/WeatherList/WeatherList.css new file mode 100644 index 0000000..4d6b646 --- /dev/null +++ b/web/src/components/WeatherList/WeatherList.css @@ -0,0 +1,9 @@ +.weather-list { + display: grid; + grid-template-columns: repeat( + auto-fit, + minmax(var(--weather-card-min-width), 1fr) + ); + + gap: var(--space-md); +} diff --git a/web/src/components/WeatherList/WeatherList.tsx b/web/src/components/WeatherList/WeatherList.tsx new file mode 100644 index 0000000..3d1ccbe --- /dev/null +++ b/web/src/components/WeatherList/WeatherList.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import type { City } from '../../features/weather/cities'; +import type { CityWeather } from '../../features/weather/weather'; +import { WeatherCard } from '../WeatherCard/WeatherCard'; +import './WeatherList.css'; + +export type WeatherListItem = { + city: City; + weather: CityWeather | null; + feedback: ReactNode; + isFavorite: boolean; + score: number; + rank: number | null; +}; + +export type WeatherListProps = { + items: WeatherListItem[]; + selectedCity: City; + onCitySelect: (city: City) => void; + onOpenDetails: (city: City) => void; + onToggleFavorite: (city: City) => void; + expandedCityId: City['id'] | null; + details: ReactNode; +}; + +export function WeatherList({ + items, + selectedCity, + onCitySelect, + onOpenDetails, + onToggleFavorite, + expandedCityId, + details, +}: WeatherListProps) { + return ( +
+

+ Været i norske byer +

+
+ {items.length === 0 ? ( +

Ingen værkort å vise.

+ ) : ( + items.map((item) => ( +
+ + {item.city.id === expandedCityId && details} +
+ )) + )} +
+
+ ); +} diff --git a/web/src/components/feedback/FeedbackMessage.test.tsx b/web/src/components/feedback/FeedbackMessage.test.tsx new file mode 100644 index 0000000..45037ab --- /dev/null +++ b/web/src/components/feedback/FeedbackMessage.test.tsx @@ -0,0 +1,79 @@ +import { expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, +} from './FeedbackMessage'; + +it('renders standalone status messages without a query provider', () => { + render( + <> + + + , + ); + const statuses = screen.getAllByRole('status'); + expect(statuses[0]).toHaveTextContent('Henter favoritter…'); + expect(statuses[1]).toHaveTextContent('Du er frakoblet.'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + +it('provides an accessible error with a keyboard action outside the live region', async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const alert = screen.getByRole('alert'); + const button = screen.getByRole('button', { name: 'Prøv Bergen igjen' }); + expect(alert).toHaveTextContent('Noe gikk galt. Prøv igjen om litt.'); + expect(alert).not.toContainElement(button); + expect(button).toHaveAttribute('type', 'button'); + await user.tab(); + expect(button).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onRetry).toHaveBeenCalledOnce(); +}); + +it('supports a custom empty-result action and prevents disabled actions', async () => { + const onReset = vi.fn(); + const action = { label: 'Nullstill filtre', onClick: onReset }; + const { rerender } = render( + , + ); + expect(screen.getByRole('status')).toHaveTextContent( + 'Ingen byer passer filtrene dine.', + ); + await userEvent.click(screen.getByRole('button', { name: action.label })); + expect(onReset).toHaveBeenCalledOnce(); + rerender(); + expect(screen.getByRole('button', { name: action.label })).toBeDisabled(); + await userEvent.click(screen.getByRole('button', { name: action.label })); + expect(onReset).toHaveBeenCalledOnce(); +}); + +it('keeps messages and actions independent when several cards need feedback', async () => { + const retryBergen = vi.fn(); + const retryOslo = vi.fn(); + render( + <> + + + , + ); + expect(screen.getAllByRole('alert')).toHaveLength(2); + await userEvent.click( + screen.getByRole('button', { name: 'Prøv Bergen igjen' }), + ); + expect(retryBergen).toHaveBeenCalledOnce(); + expect(retryOslo).not.toHaveBeenCalled(); +}); diff --git a/web/src/components/feedback/FeedbackMessage.tsx b/web/src/components/feedback/FeedbackMessage.tsx new file mode 100644 index 0000000..10101e9 --- /dev/null +++ b/web/src/components/feedback/FeedbackMessage.tsx @@ -0,0 +1,69 @@ +import './feedback.css'; + +export type FeedbackAction = { + label: string; + onClick: () => void; + disabled?: boolean; +}; + +export type FeedbackMessageProps = { + message?: string | undefined; + action?: FeedbackAction | undefined; +}; + +type FeedbackKind = 'loading' | 'error' | 'empty' | 'offline'; + +function FeedbackMessage({ + kind, + message, + action, +}: FeedbackMessageProps & { kind: FeedbackKind; message: string }) { + return ( +
+

+ {message} +

+ {action && ( + + )} +
+ ); +} + +export function LoadingMessage({ + message = 'Laster…', + action, +}: FeedbackMessageProps) { + return ; +} + +export function ErrorMessage({ + message = 'Noe gikk galt. Prøv igjen om litt.', + action, +}: FeedbackMessageProps) { + return ; +} + +export function EmptyMessage({ + message = 'Ingen resultater.', + action, +}: FeedbackMessageProps) { + return ; +} + +export function OfflineMessage({ + message = 'Du er frakoblet. Venter på nettforbindelse.', + action, +}: FeedbackMessageProps) { + return ; +} diff --git a/web/src/components/feedback/QueryFeedback.test.tsx b/web/src/components/feedback/QueryFeedback.test.tsx new file mode 100644 index 0000000..9c865c3 --- /dev/null +++ b/web/src/components/feedback/QueryFeedback.test.tsx @@ -0,0 +1,89 @@ +import { expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; +import { QueryFeedback } from './QueryFeedback'; + +function QueryExample({ + load, + isEmpty, + enabled = true, +}: { + load: () => Promise; + isEmpty?: (data: NonNullable) => boolean; + enabled?: boolean; +}) { + const query = useQuery({ + queryKey: ['feedback-example'], + queryFn: load, + enabled, + retry: false, + gcTime: 0, + }); + return ( + <> + + {query.isSuccess && ( + {JSON.stringify(query.data)} + )} + + ); +} + +it('supports list-specific emptiness and a manual retry through TanStack Query', async () => { + const load = vi + .fn<() => Promise>() + .mockResolvedValueOnce([]) + .mockResolvedValue(['Bergen']); + render( + + cities.length === 0} /> + , + ); + await screen.findByText('Ingen byer funnet.'); + await userEvent.click( + screen.getByRole('button', { name: 'Hent byer på nytt' }), + ); + await screen.findByText('["Bergen"]'); + expect(screen.queryByText('Ingen byer funnet.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); +}); + +it.each([0, false, ''])( + 'does not mistake the valid value %j for an empty result', + async (value) => { + render( + + Promise.resolve(value)} /> + , + ); + expect(await screen.findByLabelText('Resultat')).toHaveTextContent( + JSON.stringify(value), + ); + expect(screen.queryByText('Ingen byer funnet.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }, +); + +it('does not show a loading message or start a disabled query', () => { + const load = vi.fn<() => Promise>().mockResolvedValue([]); + render( + + + , + ); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(load).not.toHaveBeenCalled(); +}); diff --git a/web/src/components/feedback/QueryFeedback.tsx b/web/src/components/feedback/QueryFeedback.tsx new file mode 100644 index 0000000..adcba52 --- /dev/null +++ b/web/src/components/feedback/QueryFeedback.tsx @@ -0,0 +1,57 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, + type FeedbackAction, +} from './FeedbackMessage'; + +export type QueryFeedbackProps = { + query: Pick< + UseQueryResult, + 'data' | 'status' | 'fetchStatus' | 'refetch' + >; + messages?: Partial>; + /** Null/undefined are always empty; supply domain rules such as list.length === 0. */ + isEmpty?: ((data: NonNullable) => boolean) | undefined; + retryLabel?: string; +}; + +/** Renders query feedback only; the feature owns its data view and query options. */ +export function QueryFeedback({ + query, + messages = {}, + isEmpty, + retryLabel = 'Prøv igjen', +}: QueryFeedbackProps) { + const empty = + query.status === 'success' && + (query.data == null || isEmpty?.(query.data) === true); + const action: FeedbackAction | undefined = + query.status === 'error' || empty + ? { + label: retryLabel, + disabled: query.fetchStatus !== 'idle', + onClick: () => { + void query.refetch(); + }, + } + : undefined; + + // Paused/fetching states take precedence over the result of an earlier request. + if (query.fetchStatus === 'paused') { + return ; + } + if (query.fetchStatus === 'fetching') { + return ; + } + if (query.status === 'error') { + return ; + } + if (empty) { + return ; + } + // Includes disabled queries (pending but idle), which are not loading. + return null; +} diff --git a/web/src/components/feedback/feedback.css b/web/src/components/feedback/feedback.css new file mode 100644 index 0000000..e997c76 --- /dev/null +++ b/web/src/components/feedback/feedback.css @@ -0,0 +1,37 @@ +.feedback-message { + margin-block: 1rem; + overflow-wrap: anywhere; +} + +.feedback-message__text { + margin: 0; +} + +.feedback-message--error .feedback-message__text { + padding: 0.75rem 1rem; + border-inline-start: 4px solid #9a491c; + background: #fff3ec; + color: #673514; +} + +.feedback-message__action { + margin-block-start: 0.75rem; + font: inherit; + min-height: 44px; + padding: 0.5rem 1rem; + background: #173d35; + color: white; + border: 0; + border-radius: 0.25rem; + cursor: pointer; +} + +.feedback-message__action:disabled { + opacity: 0.65; + cursor: wait; +} + +.feedback-message__action:focus-visible { + outline: 3px solid #9a491c; + outline-offset: 4px; +} diff --git a/web/src/components/feedback/index.ts b/web/src/components/feedback/index.ts new file mode 100644 index 0000000..66f48ab --- /dev/null +++ b/web/src/components/feedback/index.ts @@ -0,0 +1,9 @@ +export { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, + type FeedbackAction, + type FeedbackMessageProps, +} from './FeedbackMessage'; +export { QueryFeedback, type QueryFeedbackProps } from './QueryFeedback'; diff --git a/web/src/data/activities.ts b/web/src/data/activities.ts new file mode 100644 index 0000000..89d4d20 --- /dev/null +++ b/web/src/data/activities.ts @@ -0,0 +1,24 @@ +import type { Activity } from '../types/activity'; + +export const activities: Activity[] = [ + { + id: 'walking', + name: 'Gåtur', + description: 'En rolig tur utendørs.', + }, + { + id: 'cycling', + name: 'Sykkeltur', + description: 'En tur på sykkel.', + }, + { + id: 'running', + name: 'Løping', + description: 'Løping eller jogging utendørs.', + }, + { + id: 'skiing', + name: 'Skitur', + description: 'Langrenn eller annen aktivitet på ski.', + }, +]; diff --git a/web/src/data/cities.ts b/web/src/data/cities.ts new file mode 100644 index 0000000..bc9e8a5 --- /dev/null +++ b/web/src/data/cities.ts @@ -0,0 +1,56 @@ +import type { City } from '../types/city'; + +export const cities = [ + { + id: 'oslo', + name: 'Oslo', + latitude: 59.9139, + longitude: 10.7522, + }, + { + id: 'bergen', + name: 'Bergen', + latitude: 60.393, + longitude: 5.3242, + }, + { + id: 'trondheim', + name: 'Trondheim', + latitude: 63.4305, + longitude: 10.3951, + }, + { + id: 'stavanger', + name: 'Stavanger', + latitude: 58.97, + longitude: 5.7331, + }, + { + id: 'tromso', + name: 'Tromsø', + latitude: 69.6492, + longitude: 18.9553, + }, + { + id: 'bodo', + name: 'Bodø', + latitude: 67.28, + longitude: 14.405, + }, + { + id: 'kristiansand', + name: 'Kristiansand', + latitude: 58.1467, + longitude: 7.9956, + }, + { + id: 'alesund', + name: 'Ålesund', + latitude: 62.4722, + longitude: 6.1495, + }, +] as const satisfies readonly City[]; + +export type { CityId } from '../types/city'; + +export const exampleCity = cities[2]; diff --git a/web/src/features/weather/cities.ts b/web/src/features/weather/cities.ts index 04dc116..a681fae 100644 --- a/web/src/features/weather/cities.ts +++ b/web/src/features/weather/cities.ts @@ -1,18 +1,3 @@ -export const cities = [ - { id: 'oslo', name: 'Oslo', latitude: 59.9139, longitude: 10.7522 }, - { id: 'bergen', name: 'Bergen', latitude: 60.393, longitude: 5.3242 }, - { id: 'trondheim', name: 'Trondheim', latitude: 63.4305, longitude: 10.3951 }, - { id: 'stavanger', name: 'Stavanger', latitude: 58.97, longitude: 5.7331 }, - { id: 'tromso', name: 'Tromsø', latitude: 69.6492, longitude: 18.9553 }, - { id: 'bodo', name: 'Bodø', latitude: 67.28, longitude: 14.405 }, - { - id: 'kristiansand', - name: 'Kristiansand', - latitude: 58.1467, - longitude: 7.9956, - }, - { id: 'alesund', name: 'Ålesund', latitude: 62.4722, longitude: 6.1495 }, -] as const; -export type City = (typeof cities)[number]; -export type CityId = City['id']; -export const exampleCity = cities[2]; +export { cities, exampleCity } from '../../data/cities'; +export type { CityId } from '../../data/cities'; +export type { City } from '../../types/city'; diff --git a/web/src/features/weather/weather.ts b/web/src/features/weather/weather.ts index ef440b6..b755fc6 100644 --- a/web/src/features/weather/weather.ts +++ b/web/src/features/weather/weather.ts @@ -21,6 +21,12 @@ export const weatherResponseSchema = z.object({ }); export type WeatherResponse = z.infer; +// A successful response may contain no current observation. Partial observations +// still fail validation instead of reaching the UI with missing fields. +const weatherResultSchema = weatherResponseSchema + .extend({ current: weatherResponseSchema.shape.current.nullish() }) + .nullable(); + export async function fetchWeather(city: City, signal?: AbortSignal) { const url = new URL(WEATHER_URL); url.search = new URLSearchParams({ @@ -38,11 +44,13 @@ export async function fetchWeather(city: City, signal?: AbortSignal) { }); if (!response.ok) throw new Error(`Weather API returned HTTP ${response.status}`); + if (response.status === 204) return null; const body: unknown = await response.json(); - const parsed = weatherResponseSchema.parse(body); + const parsed = weatherResultSchema.parse(body); + if (!parsed?.current) return null; return { city, current: parsed.current }; } -export type CityWeather = Awaited>; +export type CityWeather = NonNullable>>; export function weatherQuery(city: City) { return queryOptions({ 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( - + , ); diff --git a/web/src/services/weatherService.ts b/web/src/services/weatherService.ts new file mode 100644 index 0000000..0cce017 --- /dev/null +++ b/web/src/services/weatherService.ts @@ -0,0 +1,60 @@ +import type { City } from '../types/city'; +import type { WeatherData } from '../types/weather'; + +interface OpenMeteoCurrent { + time: string; // Local time (ISO 8601) + temperature_2m: number; // °C + apparent_temperature: number; // °C + relative_humidity_2m: number; // % + precipitation: number; // mm + weather_code: number; // WMO weather code + wind_speed_10m: number; // m/s + is_day: number; // 0 = night, 1 = day +} + +interface OpenMeteoResponse { + current: OpenMeteoCurrent; +} + +const API_URL = 'https://api.open-meteo.com/v1/forecast'; + +export async function getWeather(city: City): Promise { + const parameters = new URLSearchParams({ + latitude: city.latitude.toString(), + longitude: city.longitude.toString(), + current: [ + 'temperature_2m', + 'apparent_temperature', + 'relative_humidity_2m', + 'precipitation', + 'weather_code', + 'wind_speed_10m', + 'is_day', + ].join(','), + wind_speed_unit: 'ms', + timezone: 'auto', + }); + + const response = await fetch(`${API_URL}?${parameters.toString()}`); + + if (!response.ok) { + throw new Error( + `Kunne ikke hente værdata for ${city.name}. HTTP ${response.status}`, + ); + } + + const apiData = (await response.json()) as OpenMeteoResponse; + const current = apiData.current; + + return { + cityId: city.id, + time: current.time, + temperature: current.temperature_2m, + apparentTemperature: current.apparent_temperature, + humidity: current.relative_humidity_2m, + precipitation: current.precipitation, + weatherCode: current.weather_code, + windSpeed: current.wind_speed_10m, + isDay: current.is_day === 1, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index 8943805..d28d649 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,64 +1,242 @@ -:root { - font-family: system-ui, sans-serif; - line-height: 1.6; - color: #173d35; - background: #f2f5f0; -} +/********************************************************************/ +/* Includes: CSS resets, CSS variables, Utilities, and Base Layout */ +/********************************************************************/ + +/**************/ +/* CSS resets */ +/**************/ + * { box-sizing: border-box; } + body { margin: 0; + line-height: 1.5; + min-height: 100dvh; } -main { - width: min(680px, 100% - 2rem); - margin: 3rem auto; + +/* Responsive media by default */ +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +/* Make form controls inherit font */ +button, +input, +select, +textarea { + font: inherit; } -h1 { - font-size: clamp(2rem, 6vw, 3rem); - line-height: 1.1; + +/* Avoid text overflowing containers */ +p, +h1, +h2, +h3, +h4 { + overflow-wrap: break-word; } -header { - margin-bottom: 2rem; + +/* Remove default margins */ +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; } -section { - border: 1px solid #bacac1; - background: #fff; - padding: clamp(1rem, 4vw, 2rem); - border-radius: 0.5rem; + +/*****************/ +/* CSS variables */ +/*****************/ + +:root { + /* Surfaces */ + --color-background: #f2f5f0; + --color-surface: #ffffff; + --color-surface-hover: #f8fafc; + --color-surface-selected: #f1f5f9; + + /* Text Color */ + --color-text: #173d35; + --color-text-secondary: #475569; + --color-text-muted: #596980; + --color-text-inverse: #ffffff; + + /* Borders */ + --color-border: #e2e8f0; + --color-border-hover: #cbd5e1; + --color-border-focus: #3b82f6; + + /* Primary */ + --color-primary: #398d4b; + --color-primary-hover: #327d42; + --color-primary-active: #296c38; + --color-primary-selected: #398d4b; + + --color-primary-focus: #2563eb; + --color-primary-text: #ffffff; + + /* Secondary */ + --color-action-surface: #ddeaf7; + --color-action-surface-hover: #bdcadb; + --color-action-surface-active: #cbd5e1; + + --color-action-surface-border: #b2cbe4; + + --color-action-text: #1e293b; + --color-action-focus: #2563eb; + + /* Header */ + --color-header-background: #f3f9ff; + + /* Footer */ + --color-footer-background: #afc4d5; + + /* Cards */ + --color-card-background: #ffffff; + --color-card-hover: #f8fafc; + --color-card-selected: #f3f9fc; + + --color-card-border: #e2e8f0; + --color-card-border-hover: #cbd5e1; + --color-card-border-selected: #94a3b8; + --color-card-focus: #2563eb; + + /* Weather Card */ + --weather-card-min-width: 17rem; + + /* Spacing */ + --space-xs: clamp(0.25rem, 0.2rem + 0.2vw, 0.5rem); + --space-sm: clamp(0.5rem, 0.4rem + 0.3vw, 0.75rem); + --space-md: clamp(1rem, 0.8rem + 0.5vw, 1.5rem); + --space-lg: clamp(1.5rem, 1.1rem + 1vw, 2.5rem); + --space-xl: clamp(2rem, 1.2rem + 2vw, 4rem); + --space-2xl: clamp(3rem, 1.5rem + 4vw, 7rem); + + /* Padding */ + --padding-xs: clamp(0.375rem, 0.3rem + 0.2vw, 0.5rem); + --padding-sm: clamp(0.5rem, 0.4rem + 0.3vw, 0.75rem); + --padding-md: clamp(0.75rem, 0.5rem + 0.7vw, 1.25rem); + --padding-lg: clamp(1rem, 0.5rem + 1.5vw, 2rem); + --padding-xl: clamp(1.5rem, 0.5rem + 3vw, 4rem); + + /* Font Size */ + --font-xs: clamp(0.75rem, 0.72rem + 0.1vw, 0.8rem); + --font-sm: clamp(0.875rem, 0.84rem + 0.1vw, 0.95rem); + --font-md: clamp(1rem, 0.95rem + 0.2vw, 1.125rem); + --font-lg: clamp(1.125rem, 1rem + 0.4vw, 1.375rem); + --font-xl: clamp(1.5rem, 1.2rem + 1vw, 2rem); + --font-2xl: clamp(2rem, 1.4rem + 2vw, 3.25rem); + --font-3xl: clamp(2.5rem, 1.5rem + 4vw, 5rem); + + /* Font Weight */ + --weight-normal: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Radius */ + --radius-xs: 0.25rem; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --radius-full: 9999px; + + /* Interactice Controls Font Size */ + --control-font-sm: 0.875rem; + --control-font-md: 1rem; + --control-font-lg: 1.25rem; + --control-font-xl: 1.5rem; + --control-font-2xl: 2rem; + --control-font-3xl: 2.7rem; + + /* Bow Shadow */ + --shadow-sm: 0 1px 3px rgb(0 0 0 / 10%); + --shadow-md: 0 4px 12px rgb(0 0 0 / 15%); + --shadow-lg: 0 12px 32px rgb(0 0 0 / 20%); + + /* Wrappers */ + --main-wrapper: min(1120px, 100% - 2rem); } -dl { - display: flex; - flex-wrap: wrap; - gap: 1.5rem; + +/*************/ +/* Utilities */ +/*************/ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -dt { - font-size: 0.85rem; + +.hidden { + display: none; } -dd { - margin: 0.25rem 0; - font-size: 1.5rem; - font-weight: 600; + +/* Margin block */ +.margin-block-start-sm { + margin-block-start: var(--space-sm); } -button { - font: inherit; - min-height: 44px; - padding: 0.5rem 1rem; - background: #173d35; - color: white; - border: 0; - border-radius: 0.25rem; - cursor: pointer; -} -a { - color: inherit; - text-underline-offset: 0.2em; -} -:focus-visible { - outline: 3px solid #9a491c; - outline-offset: 4px; -} -footer { - margin-top: 2rem; - font-size: 0.85rem; + +.margin-block-start-md { + margin-block-start: var(--space-md); +} + +.margin-block-start-lg { + margin-block-start: var(--space-lg); +} + +.margin-block-end-sm { + margin-block-end: var(--space-sm); +} + +.margin-block-end-md { + margin-block-end: var(--space-md); +} + +.margin-block-end-lg { + margin-block-end: var(--space-lg); +} + +/***************/ +/* Base layout */ +/***************/ + +main { + width: var(--main-wrapper); + margin-inline: auto; + + margin-block-start: var(--space-md); + + /* Fill any unused vertical space so the footer is pushed down */ + flex: 1; +} + +:root { + font-family: system-ui, sans-serif; + color: var(--color-text); + background: var(--color-background); +} + +/* Make the React app at least as tall as the viewport, and stack main + footer vertically */ +#root { + min-height: 100dvh; + display: flex; + flex-direction: column; } diff --git a/web/src/types/activity.ts b/web/src/types/activity.ts new file mode 100644 index 0000000..9da88bd --- /dev/null +++ b/web/src/types/activity.ts @@ -0,0 +1,7 @@ +export type ActivityId = 'walking' | 'cycling' | 'running' | 'skiing'; + +export interface Activity { + id: ActivityId; + name: string; + description: string; +} diff --git a/web/src/types/city.ts b/web/src/types/city.ts new file mode 100644 index 0000000..98c996d --- /dev/null +++ b/web/src/types/city.ts @@ -0,0 +1,16 @@ +export type CityId = + | 'oslo' + | 'bergen' + | 'trondheim' + | 'stavanger' + | 'tromso' + | 'bodo' + | 'kristiansand' + | 'alesund'; + +export interface City { + id: CityId; + name: string; + latitude: number; + longitude: number; +} diff --git a/web/src/types/weather.ts b/web/src/types/weather.ts new file mode 100644 index 0000000..3e8c5a6 --- /dev/null +++ b/web/src/types/weather.ts @@ -0,0 +1,11 @@ +export interface WeatherData { + cityId: string; + time: string; + temperature: number; + apparentTemperature: number; + humidity: number; + precipitation: number; + weatherCode: number; + windSpeed: number; + isDay: boolean; +}