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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,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)
5 changes: 4 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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.
- `project2/contracts/`: generated resolver/client contracts; regenerate with `npm run types:api`.

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
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions docs/ui-feedback.md
Original file line number Diff line number Diff line change
@@ -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';

<EmptyMessage
message="Ingen byer passer filtrene dine."
action={{ label: 'Nullstill filtre', onClick: resetFilters }}
/>;
```

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';

<QueryFeedback
query={citiesQuery}
isEmpty={(cities) => 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.
119 changes: 119 additions & 0 deletions e2e/weather-states.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { weatherResponse } from '../web/src/test/fixtures';

test('announces loading while the API request is pending', async ({ page }) => {
let finishRequest = () => {};
const responseReady = new Promise<void>((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('Henter værdata…');
await expect(page.getByRole('alert')).toHaveCount(0);
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
} finally {
finishRequest();
}
await expect(page.getByText('14 °C')).toBeVisible();
await expect(page.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<void>((resolve) => {
finishRetry = resolve;
});
await page.route('https://api.open-meteo.com/**', async (route) => {
calls++;
if (calls === 1) {
await route.fulfill({ status: 503 });
return;
}
await responseReady;
await route.fulfill({ json: weatherResponse });
});
await page.goto('./');
try {
await expect(page.getByRole('alert')).toContainText(
'Kunne ikke hente værdata.',
);
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
expect(calls).toBe(1);
await page.keyboard.press('Tab');
await expect(
page.getByRole('button', { name: 'Prøv igjen' }),
).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('status')).toHaveText('Henter værdata…');
await expect(page.getByRole('alert')).toHaveCount(0);
} finally {
finishRetry();
}
await expect(page.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<void>((resolve) => {
finishRetry = resolve;
});
await page.route('https://api.open-meteo.com/**', async (route) => {
calls++;
if (calls === 1) {
await route.fulfill({ status: 204 });
return;
}
await responseReady;
await route.fulfill({ json: weatherResponse });
});
await page.goto('./');
try {
await expect(page.getByRole('status')).toContainText(
'Ingen værdata er tilgjengelige for Trondheim akkurat nå.',
);
await expect(page.getByRole('alert')).toHaveCount(0);
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
await page.getByRole('button', { name: 'Prøv igjen' }).click();
await expect(page.getByRole('status')).toHaveText('Henter værdata…');
await expect(
page.getByRole('button', { name: 'Prøv igjen' }),
).toBeDisabled();
} finally {
finishRetry();
}
await expect(page.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')).toContainText(
'Kunne ikke hente værdata.',
);
await expect(
page.getByRole('heading', { name: 'Turvær Norge' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled();
expect(pageErrors).toEqual([]);
});
Loading