From 99599b7d7606abe4167a08cf1f27ea6b7bdbc7d7 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:01:20 +0100 Subject: [PATCH 01/23] feat(backend): legg til tabellen reviews for vurderinger av annonser --- .../1793307661044_create-reviews-table.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/migrations/1793307661044_create-reviews-table.ts diff --git a/backend/migrations/1793307661044_create-reviews-table.ts b/backend/migrations/1793307661044_create-reviews-table.ts new file mode 100644 index 0000000..f44082f --- /dev/null +++ b/backend/migrations/1793307661044_create-reviews-table.ts @@ -0,0 +1,58 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** Samme grense som REVIEW_COMMENT_MAX_LENGTH i backend og frontend. */ +const COMMENT_MAX_LENGTH = 500; + +/** + * Vurderinger av annonser: en score fra 1 til 5 og en valgfri kommentar. + * Grensene sjekkes også i databasen, slik at ugyldige vurderinger ikke kan + * lagres selv om valideringen i backenden skulle bli omgått. + * + * Slettes en annonse, slettes vurderingene av den også (ON DELETE CASCADE). + * + * Indeksen har samme rekkefølge som listen over de nyeste vurderingene, så den + * kan leses rett fra indeksen. Den brukes også når snittscore og antall regnes + * ut for én annonse, fordi listing_id står først. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable('reviews', { + id: { type: 'text', primaryKey: true }, + listing_id: { + type: 'text', + notNull: true, + references: 'listings', + onDelete: 'CASCADE', + }, + score: { type: 'integer', notNull: true }, + comment: { type: 'text', notNull: true, default: '' }, + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); + + pgm.addConstraint('reviews', 'reviews_score_check', { + check: 'score BETWEEN 1 AND 5', + }); + + pgm.addConstraint('reviews', 'reviews_comment_length_check', { + check: `char_length(comment) <= ${COMMENT_MAX_LENGTH}`, + }); + + pgm.createIndex( + 'reviews', + [ + 'listing_id', + { name: 'created_at', sort: 'DESC' }, + { name: 'id', sort: 'DESC' }, + ], + { name: 'reviews_listing_id_created_at_id_idx' }, + ); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable('reviews'); +} From 3716fd0293beb526c93027b7267056e8996fdde1 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:01:47 +0100 Subject: [PATCH 02/23] feat(backend): valider nye vurderinger --- .../src/validation/createReviewInput.test.ts | 79 +++++++++++++++++++ backend/src/validation/createReviewInput.ts | 70 ++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 backend/src/validation/createReviewInput.test.ts create mode 100644 backend/src/validation/createReviewInput.ts diff --git a/backend/src/validation/createReviewInput.test.ts b/backend/src/validation/createReviewInput.test.ts new file mode 100644 index 0000000..9e21b6e --- /dev/null +++ b/backend/src/validation/createReviewInput.test.ts @@ -0,0 +1,79 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_REVIEWS_LIMIT, + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, + validateCreateReviewInput, + validateReviewsLimit, +} from './createReviewInput.js'; + +function expectBadUserInput(run: () => unknown) { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(GraphQLError); + expect((error as GraphQLError).extensions.code).toBe('BAD_USER_INPUT'); + return; + } + throw new Error('Forventet BAD_USER_INPUT, men ingen feil ble kastet.'); +} + +const valid = { listingId: 'listing-001', score: 4, comment: 'Fin drill.' }; + +describe('validateCreateReviewInput', () => { + it('godtar en gyldig vurdering', () => { + expect(validateCreateReviewInput(valid)).toEqual(valid); + }); + + it.each([1, 5])('godtar grenseverdien %i', (score) => { + expect(validateCreateReviewInput({ ...valid, score }).score).toBe(score); + }); + + it.each([0, 6, -1, 4.5])('avviser score %s', (score) => { + expectBadUserInput(() => validateCreateReviewInput({ ...valid, score })); + }); + + it('fjerner mellomrom i endene av kommentaren', () => { + expect( + validateCreateReviewInput({ ...valid, comment: ' Fin drill. ' }) + .comment, + ).toBe('Fin drill.'); + }); + + it.each([undefined, null, '', ' '])( + 'gjør kommentaren %o om til tom tekst', + (comment) => { + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe(''); + }, + ); + + it('godtar en kommentar på nøyaktig maksimal lengde', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH); + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe( + comment, + ); + }); + + it('avviser en for lang kommentar', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1); + expectBadUserInput(() => validateCreateReviewInput({ ...valid, comment })); + }); +}); + +describe('validateReviewsLimit', () => { + it('bruker standardverdien når ingenting er oppgitt', () => { + expect(validateReviewsLimit(undefined)).toBe(DEFAULT_REVIEWS_LIMIT); + expect(validateReviewsLimit(null)).toBe(DEFAULT_REVIEWS_LIMIT); + }); + + it('godtar grenseverdiene', () => { + expect(validateReviewsLimit(1)).toBe(1); + expect(validateReviewsLimit(MAX_REVIEWS_LIMIT)).toBe(MAX_REVIEWS_LIMIT); + }); + + it.each([0, -1, MAX_REVIEWS_LIMIT + 1])('avviser %i', (limit) => { + expectBadUserInput(() => validateReviewsLimit(limit)); + }); +}); diff --git a/backend/src/validation/createReviewInput.ts b/backend/src/validation/createReviewInput.ts new file mode 100644 index 0000000..2e8d5a0 --- /dev/null +++ b/backend/src/validation/createReviewInput.ts @@ -0,0 +1,70 @@ +import { GraphQLError } from 'graphql'; + +/** Laveste og høyeste score. Samme grenser som i databasen. */ +export const REVIEW_SCORE_MIN = 1; +export const REVIEW_SCORE_MAX = 5; + +/** Lengste kommentar, etter at mellomrom i endene er fjernet. */ +export const REVIEW_COMMENT_MAX_LENGTH = 500; + +/** Antall vurderinger Listing.reviews gir når klienten ikke ber om noe annet. */ +export const DEFAULT_REVIEWS_LIMIT = 10; +/** Flest vurderinger én spørring kan hente. */ +export const MAX_REVIEWS_LIMIT = 50; + +export interface CreateReviewInput { + listingId: string; + score: number; + comment?: string | null; +} + +export interface ValidatedCreateReviewInput { + listingId: string; + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** + * Validerer createReview-input. GraphQL sikrer at score er et heltall, så her + * sjekkes grensene. Kommentaren er valgfri, og null blir tom tekst. Om + * annonsen finnes, sjekkes når vurderingen lagres. + */ +export function validateCreateReviewInput( + input: CreateReviewInput, +): ValidatedCreateReviewInput { + const { score } = input; + if ( + !Number.isInteger(score) || + score < REVIEW_SCORE_MIN || + score > REVIEW_SCORE_MAX + ) { + invalidInput( + `Velg en vurdering fra ${REVIEW_SCORE_MIN} til ${REVIEW_SCORE_MAX}.`, + ); + } + + const comment = input.comment?.trim() ?? ''; + if (comment.length > REVIEW_COMMENT_MAX_LENGTH) { + invalidInput(`Kommentaren kan ha høyst ${REVIEW_COMMENT_MAX_LENGTH} tegn.`); + } + + return { listingId: input.listingId, score, comment }; +} + +/** Validerer hvor mange vurderinger Listing.reviews skal gi. */ +export function validateReviewsLimit(limit: number | null | undefined): number { + const value = limit ?? DEFAULT_REVIEWS_LIMIT; + if (!Number.isInteger(value) || value < 1 || value > MAX_REVIEWS_LIMIT) { + invalidInput( + `Antall vurderinger (limit) må være et heltall fra 1 til ${MAX_REVIEWS_LIMIT}.`, + ); + } + return value; +} From bd8ec13d16454da87f5070d9ff909f5db0cc050b Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:03:10 +0100 Subject: [PATCH 03/23] feat(backend): legg til vurderinger, snittscore og createReview i GraphQL-API-et --- backend/src/db/listingsRepository.ts | 49 +++++++++++++++---- backend/src/db/reviewsRepository.ts | 70 ++++++++++++++++++++++++++++ backend/src/graphql/resolvers.ts | 34 ++++++++++++++ backend/src/graphql/schema.ts | 39 ++++++++++++++++ backend/src/types/listing.ts | 12 ++++- backend/src/types/review.ts | 31 ++++++++++++ 6 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/reviewsRepository.ts create mode 100644 backend/src/types/review.ts diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts index 83c2bf4..6b1d8bb 100644 --- a/backend/src/db/listingsRepository.ts +++ b/backend/src/db/listingsRepository.ts @@ -27,6 +27,26 @@ const LISTING_COLUMNS = ` created_at `; +/** + * Antall vurderinger og snittscore for annonsen l. Brukes med CROSS JOIN + * LATERAL, så det regnes ut bare for annonsene som returneres, og hver + * utregning slår opp i indeksen på reviews.listing_id. En side med 24 + * annonser gir da én spørring, ikke én ekstra spørring per annonse. + * + * En aggregering uten GROUP BY gir alltid én rad, så annonser uten + * vurderinger får 0 og null. AVG gir numeric, som pg leverer som tekst, så + * den gjøres om til float8 for å bli et tall i JavaScript. + */ +const REVIEW_STATS_JOIN = ` + CROSS JOIN LATERAL ( + SELECT + COUNT(*)::int AS review_count, + AVG(score)::float8 AS average_score + FROM reviews + WHERE reviews.listing_id = l.id + ) AS review_stats +`; + export interface ListingPage { items: Listing[]; totalCount: number; @@ -55,13 +75,20 @@ export async function findListingsPage({ const where = buildListingsWhere(filter); const limitParam = where.values.length + 1; + // Siden velges først i den indre spørringen. Vurderingene hentes etterpå, + // bare for annonsene på siden, og rekkefølgen settes på nytt etter joinen. const [itemsResult, countResult] = await Promise.all([ pool.query( - `SELECT ${LISTING_COLUMNS} - FROM listings - ${where.sql} - ORDER BY ${LISTING_ORDER_BY[sort]} - LIMIT $${limitParam} OFFSET $${limitParam + 1}`, + `SELECT l.*, review_stats.* + FROM ( + SELECT ${LISTING_COLUMNS} + FROM listings + ${where.sql} + ORDER BY ${LISTING_ORDER_BY[sort]} + LIMIT $${limitParam} OFFSET $${limitParam + 1} + ) AS l + ${REVIEW_STATS_JOIN} + ORDER BY ${LISTING_ORDER_BY[sort]}`, [...where.values, limit, offset], ), pool.query<{ total: string }>( @@ -93,7 +120,10 @@ export async function findListingAreas(): Promise { export async function findListingById(id: string): Promise { const result = await pool.query( - `SELECT ${LISTING_COLUMNS} FROM listings WHERE id = $1`, + `SELECT l.*, review_stats.* + FROM listings AS l + ${REVIEW_STATS_JOIN} + WHERE l.id = $1`, [id], ); const row = result.rows[0]; @@ -108,7 +138,9 @@ export async function insertListing( ): Promise { const id = `listing-${randomUUID()}`; - const result = await pool.query( + const result = await pool.query< + Omit + >( `INSERT INTO listings ( id, title, @@ -140,7 +172,8 @@ export async function insertListing( throw new Error('INSERT RETURNING ga ingen rad.'); } - return listingFromRow(row); + // En ny annonse har ingen vurderinger ennå. + return listingFromRow({ ...row, review_count: 0, average_score: null }); } export async function deleteListingById(id: string): Promise { diff --git a/backend/src/db/reviewsRepository.ts b/backend/src/db/reviewsRepository.ts new file mode 100644 index 0000000..60c43f6 --- /dev/null +++ b/backend/src/db/reviewsRepository.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; + +import type { Review, ReviewRow } from '../types/review.js'; +import { reviewFromRow } from '../types/review.js'; +import type { ValidatedCreateReviewInput } from '../validation/createReviewInput.js'; +import { pool } from './pool.js'; + +const REVIEW_COLUMNS = 'id, listing_id, score, comment, created_at'; + +/** PostgreSQL-koden for brudd på en fremmednøkkel. */ +const FOREIGN_KEY_VIOLATION = '23503'; + +function isForeignKeyViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === FOREIGN_KEY_VIOLATION + ); +} + +/** + * Lagrer en vurdering. Returnerer null når annonsen ikke finnes. Det sjekkes + * av fremmednøkkelen i databasen, så en annonse som slettes samtidig, ikke + * kan få en vurdering som peker på ingenting. + */ +export async function insertReview( + input: ValidatedCreateReviewInput, +): Promise { + const id = `review-${randomUUID()}`; + + try { + const result = await pool.query( + `INSERT INTO reviews (id, listing_id, score, comment) + VALUES ($1, $2, $3, $4) + RETURNING ${REVIEW_COLUMNS}`, + [id, input.listingId, input.score, input.comment], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('INSERT RETURNING ga ingen rad.'); + } + return reviewFromRow(row); + } catch (error) { + if (isForeignKeyViolation(error)) { + return null; + } + throw error; + } +} + +/** + * De nyeste vurderingene av én annonse, nyeste først. id er siste + * sorteringsnøkkel, slik at vurderinger med samme tidspunkt alltid kommer i + * samme rekkefølge. Rekkefølgen er den samme som i indeksen + * reviews_listing_id_created_at_id_idx. + */ +export async function findReviewsForListing( + listingId: string, + limit: number, +): Promise { + const result = await pool.query( + `SELECT ${REVIEW_COLUMNS} + FROM reviews + WHERE listing_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [listingId, limit], + ); + return result.rows.map(reviewFromRow); +} diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 7f31b65..884c3b5 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,11 +1,23 @@ +import { GraphQLError } from 'graphql'; + import { findListingAreas, findListingById, findListingsPage, insertListing, } from '../db/listingsRepository.js'; +import { + findReviewsForListing, + insertReview, +} from '../db/reviewsRepository.js'; +import type { Listing } from '../types/listing.js'; import type { CreateListingInput } from '../validation/createListingInput.js'; import { validateCreateListingInput } from '../validation/createListingInput.js'; +import type { CreateReviewInput } from '../validation/createReviewInput.js'; +import { + validateCreateReviewInput, + validateReviewsLimit, +} from '../validation/createReviewInput.js'; import type { ListingFilterArgs } from '../validation/listingFilterArgs.js'; import { validateListingFilterArgs } from '../validation/listingFilterArgs.js'; import type { PaginationArgs } from '../validation/paginationArgs.js'; @@ -28,5 +40,27 @@ export const resolvers = { const validated = validateCreateListingInput(args.input); return insertListing(validated); }, + createReview: async ( + _parent: unknown, + args: { input: CreateReviewInput }, + ) => { + const validated = validateCreateReviewInput(args.input); + const review = await insertReview(validated); + // Annonsen hentes etter at vurderingen er lagret, så snitt og antall + // tar med den nye vurderingen. + const listing = review && (await findListingById(validated.listingId)); + if (!review || !listing) { + throw new GraphQLError('Fant ikke annonsen.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return { review, listing }; + }, + }, + Listing: { + // Hentes bare når klienten ber om feltet, som i detaljvisningen. Katalogen + // spør ikke etter det, så den gir ingen ekstra spørringer. + reviews: (parent: Listing, args: { limit?: number | null }) => + findReviewsForListing(parent.id, validateReviewsLimit(args.limit)), }, }; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 9e69bde..61d22b9 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -26,6 +26,23 @@ export const typeDefs = `#graphql imageUrl: String "Når annonsen ble opprettet, som ISO 8601-tekst i UTC." createdAt: String! + "Snittet av alle vurderingene, fra 1 til 5. null når annonsen ikke har vurderinger." + averageScore: Float + "Antall vurderinger av annonsen." + reviewCount: Int! + "De nyeste vurderingene, nyeste først. limit er fra 1 til 50." + reviews(limit: Int = 10): [Review!]! + } + + "En vurdering av en annonse. Hvem som helst kan gi en vurdering." + type Review { + id: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Tom tekst når vurderingen ikke har kommentar." + comment: String! + "Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC." + createdAt: String! } "Rekkefølgen annonsene sorteres i." @@ -95,7 +112,29 @@ export const typeDefs = `#graphql listingAreas: [String!]! } + input CreateReviewInput { + listingId: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Valgfri, høyst 500 tegn. Mellomrom i endene fjernes." + comment: String + } + + """ + Svaret på createReview. listing har oppdatert snitt, antall og liste over + vurderinger, slik at klienten kan oppdatere visningen uten ny spørring. + """ + type CreateReviewPayload { + review: Review! + listing: Listing! + } + type Mutation { createListing(input: CreateListingInput!): Listing! + """ + Lagrer en vurdering av en annonse. Gir BAD_USER_INPUT når score ikke er + fra 1 til 5, kommentaren er for lang eller annonsen ikke finnes. + """ + createReview(input: CreateReviewInput!): CreateReviewPayload! } `; diff --git a/backend/src/types/listing.ts b/backend/src/types/listing.ts index 8cc2ad2..8658245 100644 --- a/backend/src/types/listing.ts +++ b/backend/src/types/listing.ts @@ -17,9 +17,15 @@ export interface Listing { imageUrl: string | null; /** Når annonsen ble opprettet, som ISO 8601-tekst i UTC. */ createdAt: string; + /** Snittet av vurderingene, fra 1 til 5, eller null uten vurderinger. */ + averageScore: number | null; + reviewCount: number; } -/** Rad fra PostgreSQL-tabellen listings. */ +/** + * Rad fra PostgreSQL-tabellen listings, med antall vurderinger og snittscore + * regnet ut fra tabellen reviews. + */ export interface ListingRow { id: string; title: string; @@ -32,6 +38,8 @@ export interface ListingRow { image_url: string | null; /** pg gjør timestamptz om til Date. */ created_at: Date; + review_count: number; + average_score: number | null; } export function listingFromRow(row: ListingRow): Listing { @@ -46,5 +54,7 @@ export function listingFromRow(row: ListingRow): Listing { accessories: row.accessories, imageUrl: row.image_url, createdAt: row.created_at.toISOString(), + averageScore: row.average_score, + reviewCount: row.review_count, }; } diff --git a/backend/src/types/review.ts b/backend/src/types/review.ts new file mode 100644 index 0000000..fd50994 --- /dev/null +++ b/backend/src/types/review.ts @@ -0,0 +1,31 @@ +/** Speiler typen Review i GraphQL-skjemaet. */ +export interface Review { + id: string; + listingId: string; + /** Fra 1 til 5, der 5 er best. */ + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; + /** Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC. */ + createdAt: string; +} + +/** Rad fra PostgreSQL-tabellen reviews. */ +export interface ReviewRow { + id: string; + listing_id: string; + score: number; + comment: string; + /** pg gjør timestamptz om til Date. */ + created_at: Date; +} + +export function reviewFromRow(row: ReviewRow): Review { + return { + id: row.id, + listingId: row.listing_id, + score: row.score, + comment: row.comment, + createdAt: row.created_at.toISOString(), + }; +} From 457d0e9a5bfe53e95727ad8664f9cec3ecb7e0fb Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:04:12 +0100 Subject: [PATCH 04/23] test(backend): test vurderinger, snittscore og createReview --- backend/src/graphql/reviews.api.test.ts | 306 ++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 backend/src/graphql/reviews.api.test.ts diff --git a/backend/src/graphql/reviews.api.test.ts b/backend/src/graphql/reviews.api.test.ts new file mode 100644 index 0000000..5e4b2bb --- /dev/null +++ b/backend/src/graphql/reviews.api.test.ts @@ -0,0 +1,306 @@ +import type { ApolloServer } from '@apollo/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, +} from '../validation/createReviewInput.js'; + +/** Annonsene testene vurderer. Ordet MARKER gjør dem lette å søke fram. */ +const MARKER = 'vurderingstest'; +const LISTING_ID = 'test-review-listing'; +const OTHER_LISTING_ID = 'test-review-other'; +const FIXTURE_IDS = [LISTING_ID, OTHER_LISTING_ID]; +const UNKNOWN_LISTING_ID = 'listing-finnes-ikke'; + +const CREATE_REVIEW = `mutation ($input: CreateReviewInput!) { + createReview(input: $input) { + review { id score comment createdAt } + listing { + id + averageScore + reviewCount + reviews(limit: 10) { id score comment } + } + } +}`; + +const LISTING_WITH_REVIEWS = `query ($id: ID!, $limit: Int) { + listing(id: $id) { + averageScore + reviewCount + reviews(limit: $limit) { id score comment createdAt } + } +}`; + +interface ReviewResult { + id: string; + score: number; + comment: string; + createdAt: string; +} + +interface ListingResult { + averageScore: number | null; + reviewCount: number; + reviews: ReviewResult[]; +} + +describe.skipIf(!hasTestDatabase)('vurderinger', () => { + let server: ApolloServer; + + async function run(query: string, variables: Record) { + const response = await server.executeOperation({ query, variables }); + if (response.body.kind !== 'single') { + throw new Error('Forventet ett svar fra GraphQL.'); + } + return response.body.singleResult; + } + + async function createReview(input: Record) { + return run(CREATE_REVIEW, { + input: { listingId: LISTING_ID, ...input }, + }); + } + + async function fetchListing( + id = LISTING_ID, + limit?: number, + ): Promise { + const result = await run(LISTING_WITH_REVIEWS, { id, limit }); + expect(result.errors).toBeUndefined(); + return result.data?.listing as ListingResult; + } + + async function pool() { + return (await import('../db/pool.js')).pool; + } + + beforeAll(async () => { + server = await createTestApolloServer(); + const db = await pool(); + for (const id of FIXTURE_IDS) { + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ($1, 'Testannonse', 'handTools', $2, 'Moholt', 50, 'good') + ON CONFLICT (id) DO NOTHING`, + [id, `Test av vurderinger. ${MARKER}`], + ); + } + }); + + // Hver test starter uten vurderinger, så antall og snitt er kjent. + beforeEach(async () => { + const db = await pool(); + await db.query('DELETE FROM reviews WHERE listing_id = ANY($1)', [ + FIXTURE_IDS, + ]); + }); + + afterAll(async () => { + const db = await pool(); + // Vurderingene slettes sammen med annonsene (ON DELETE CASCADE). + await db.query('DELETE FROM listings WHERE id = ANY($1)', [FIXTURE_IDS]); + await server.stop(); + await closeTestPool(); + }); + + it('gir 0 vurderinger og snitt null for en annonse uten vurderinger', async () => { + expect(await fetchListing()).toEqual({ + averageScore: null, + reviewCount: 0, + reviews: [], + }); + }); + + it('lagrer en vurdering og gir annonsen med oppdatert snitt', async () => { + const result = await createReview({ + score: 4, + comment: ' Fin drill, ladet fort. ', + }); + expect(result.errors).toBeUndefined(); + + const payload = result.data?.createReview as { + review: ReviewResult; + listing: ListingResult & { id: string }; + }; + expect(payload.review).toMatchObject({ + score: 4, + comment: 'Fin drill, ladet fort.', + }); + expect(payload.review.id).toMatch(/^review-/); + expect(new Date(payload.review.createdAt).toISOString()).toBe( + payload.review.createdAt, + ); + expect(payload.listing).toMatchObject({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 1, + }); + expect(payload.listing.reviews.map((r) => r.id)).toEqual([ + payload.review.id, + ]); + }); + + it('lagrer en vurdering uten kommentar som tom tekst', async () => { + const result = await createReview({ score: 5 }); + expect(result.errors).toBeUndefined(); + const payload = result.data?.createReview as { review: ReviewResult }; + expect(payload.review.comment).toBe(''); + }); + + it('regner ut snittet av alle vurderingene', async () => { + for (const score of [4, 5, 2]) { + expect((await createReview({ score })).errors).toBeUndefined(); + } + const listing = await fetchListing(); + expect(listing.reviewCount).toBe(3); + expect(listing.averageScore).toBeCloseTo(11 / 3); + }); + + it('holder vurderingene for hver annonse adskilt', async () => { + await createReview({ score: 1 }); + expect(await fetchListing(OTHER_LISTING_ID)).toMatchObject({ + averageScore: null, + reviewCount: 0, + }); + }); + + it('viser de nyeste vurderingene først, og like tidspunkt etter id', async () => { + const db = await pool(); + await db.query( + `INSERT INTO reviews (id, listing_id, score, comment, created_at) VALUES + ('test-review-a', $1, 3, 'Eldst', '2098-01-01T00:00:00Z'), + ('test-review-b', $1, 4, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-c', $1, 5, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-d', $1, 2, 'Nyest', '2098-01-03T00:00:00Z')`, + [LISTING_ID], + ); + const listing = await fetchListing(); + expect(listing.reviews.map((r) => r.id)).toEqual([ + 'test-review-d', + 'test-review-c', + 'test-review-b', + 'test-review-a', + ]); + }); + + it('gir bare så mange vurderinger som limit sier', async () => { + for (const score of [1, 2, 3]) { + await createReview({ score }); + } + const listing = await fetchListing(LISTING_ID, 2); + expect(listing.reviews).toHaveLength(2); + expect(listing.reviewCount).toBe(3); + }); + + it('viser snitt og antall på annonsene i katalogen', async () => { + await createReview({ score: 5 }); + await createReview({ score: 3 }); + const result = await run( + `query ($search: String) { + listings(search: $search, limit: 10) { + items { id averageScore reviewCount } + } + }`, + { search: MARKER }, + ); + expect(result.errors).toBeUndefined(); + const items = ( + result.data?.listings as { + items: { + id: string; + averageScore: number | null; + reviewCount: number; + }[]; + } + ).items; + expect(items).toContainEqual({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 2, + }); + expect(items).toContainEqual({ + id: OTHER_LISTING_ID, + averageScore: null, + reviewCount: 0, + }); + }); + + describe('ugyldige vurderinger', () => { + it.each([0, 6, -1])( + 'avviser score %i og lagrer ingenting', + async (score) => { + const result = await createReview({ score }); + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect((await fetchListing()).reviewCount).toBe(0); + }, + ); + + it('avviser en for lang kommentar', async () => { + const result = await createReview({ + score: 3, + comment: 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1), + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toContain( + `${REVIEW_COMMENT_MAX_LENGTH}`, + ); + }); + + it('avviser en vurdering av en annonse som ikke finnes', async () => { + const result = await run(CREATE_REVIEW, { + input: { listingId: UNKNOWN_LISTING_ID, score: 4 }, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toBe('Fant ikke annonsen.'); + }); + + it.each([0, MAX_REVIEWS_LIMIT + 1])( + 'avviser reviews(limit: %i)', + async (limit) => { + const result = await run(LISTING_WITH_REVIEWS, { + id: LISTING_ID, + limit, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }, + ); + }); + + describe('databasen', () => { + it('avviser score utenfor 1–5 også uten om backenden', async () => { + const db = await pool(); + await expect( + db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-x', $1, 6)`, + [LISTING_ID], + ), + ).rejects.toThrow(/reviews_score_check/); + }); + + it('sletter vurderingene når annonsen slettes', async () => { + const db = await pool(); + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ('test-review-temp', 'Midlertidig', 'garden', 'Slettes.', 'Lade', 0, 'good')`, + ); + await db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-temp-1', 'test-review-temp', 4)`, + ); + await db.query(`DELETE FROM listings WHERE id = 'test-review-temp'`); + const remaining = await db.query( + `SELECT 1 FROM reviews WHERE id = 'test-review-temp-1'`, + ); + expect(remaining.rowCount).toBe(0); + }); + }); +}); From b6c8a5cab5da001c66c519311b0c5e883e3569d6 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:04:53 +0100 Subject: [PATCH 05/23] feat(backend): legg til eksempelvurderinger i seed --- backend/src/scripts/seed.ts | 23 ++++++++++ backend/src/seed/reviews.ts | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 backend/src/seed/reviews.ts diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts index 604f21e..97c7f5b 100644 --- a/backend/src/scripts/seed.ts +++ b/backend/src/scripts/seed.ts @@ -1,5 +1,6 @@ import { pool } from '../db/pool.js'; import { seedListings } from '../seed/listings.js'; +import { seedReviews } from '../seed/reviews.js'; const INSERT_SQL = ` INSERT INTO listings ( @@ -17,6 +18,12 @@ const INSERT_SQL = ` ON CONFLICT (id) DO NOTHING `; +const INSERT_REVIEW_SQL = ` + INSERT INTO reviews (id, listing_id, score, comment, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING +`; + async function seed(): Promise { if (seedListings.length !== 10) { throw new Error(`Forventet 10 seed-annonser, fant ${seedListings.length}.`); @@ -45,6 +52,19 @@ async function seed(): Promise { } } + // Vurderingene legges inn etter annonsene, fordi de peker på dem. + let insertedReviews = 0; + for (const review of seedReviews) { + const result = await pool.query(INSERT_REVIEW_SQL, [ + review.id, + review.listingId, + review.score, + review.comment, + review.createdAt, + ]); + insertedReviews += result.rowCount ?? 0; + } + const countResult = await pool.query<{ count: string }>( 'SELECT COUNT(*)::text AS count FROM listings', ); @@ -53,6 +73,9 @@ async function seed(): Promise { console.info( `Seed fullført: ${inserted} nye, ${skipped} hoppet over (finnes fra før). Totalt ${total} rader i listings.`, ); + console.info( + `Vurderinger: ${insertedReviews} nye, ${seedReviews.length - insertedReviews} hoppet over.`, + ); } try { diff --git a/backend/src/seed/reviews.ts b/backend/src/seed/reviews.ts new file mode 100644 index 0000000..ea579ae --- /dev/null +++ b/backend/src/seed/reviews.ts @@ -0,0 +1,83 @@ +/** + * Eksempelvurderinger av noen av seed-annonsene, slik at snittscore og + * vurderinger kan prøves uten å legge dem inn for hånd. Vurderingene er + * oppdiktede og har ingen personopplysninger. + * + * Faste ID-er gjør at seed-skriptet kan kjøres flere ganger uten duplikater. + * Tidspunktene er faste, så rekkefølgen i detaljvisningen er den samme hver + * gang. + */ + +export interface SeedReview { + id: string; + listingId: string; + score: number; + comment: string; + createdAt: string; +} + +export const seedReviews: SeedReview[] = [ + { + id: 'review-seed-001', + listingId: 'listing-001', + score: 5, + comment: 'Batteriet holdt hele dagen. Enkel henting på Moholt.', + createdAt: '2026-09-02T16:20:00.000Z', + }, + { + id: 'review-seed-002', + listingId: 'listing-001', + score: 4, + comment: 'Gjorde jobben med hyllene. Kunne hatt flere bits.', + createdAt: '2026-09-14T09:05:00.000Z', + }, + { + id: 'review-seed-003', + listingId: 'listing-001', + score: 5, + comment: '', + createdAt: '2026-10-01T18:40:00.000Z', + }, + { + id: 'review-seed-004', + listingId: 'listing-002', + score: 4, + comment: 'Kraftig nok til betongvegg. Litt tung å holde over hodet.', + createdAt: '2026-09-21T12:00:00.000Z', + }, + { + id: 'review-seed-005', + listingId: 'listing-003', + score: 3, + comment: 'Saget greit, men bladet var ikke så skarpt som beskrevet.', + createdAt: '2026-09-08T15:30:00.000Z', + }, + { + id: 'review-seed-006', + listingId: 'listing-005', + score: 5, + comment: 'Gratis og i fin stand. Tusen takk!', + createdAt: '2026-09-28T10:15:00.000Z', + }, + { + id: 'review-seed-007', + listingId: 'listing-005', + score: 4, + comment: 'Fin spade til kjøkkenhagen.', + createdAt: '2026-10-05T08:45:00.000Z', + }, + { + id: 'review-seed-008', + listingId: 'listing-008', + score: 5, + comment: 'Alt vi trengte for å male hybelen. Rullene var rene.', + createdAt: '2026-09-17T19:00:00.000Z', + }, + { + id: 'review-seed-009', + listingId: 'listing-010', + score: 2, + comment: 'Tepperenseren virket, men slangen lakk litt.', + createdAt: '2026-10-08T13:25:00.000Z', + }, +]; From a1bde3b9981a1b71bad6ba05241c6e4f9ab7b0fb Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:06:29 +0100 Subject: [PATCH 06/23] feat(graphql): hent snittscore og vurderinger, og legg til createReview i frontend --- src/data/mockListings.ts | 22 +++++++ src/graphql/listingQueries.ts | 35 +++++++++- src/graphql/reviewMutations.ts | 56 ++++++++++++++++ src/test/listingMocks.ts | 113 ++++++++++++++++++++++++++++++++- src/types/listing.ts | 13 +++- src/types/review.ts | 10 +++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/graphql/reviewMutations.ts create mode 100644 src/types/review.ts diff --git a/src/data/mockListings.ts b/src/data/mockListings.ts index 19abe72..3f9ae6d 100644 --- a/src/data/mockListings.ts +++ b/src/data/mockListings.ts @@ -5,6 +5,8 @@ * * Dataene er valgt for å dekke ulike tilfeller i visningene: gratis og betalt * utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. + * Snittscore og antall vurderinger er de samme som eksempelvurderingene i + * backend/src/seed/reviews.ts gir, og noen annonser har ingen vurderinger. * * Datakilden erstattes senere av GraphQL-backenden. Komponenter bør derfor få * annonser gjennom props i stedet for å importere denne filen direkte. @@ -36,6 +38,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['2 batterier', 'Lader', 'Bitssett'], imageUrl: drillImage, + averageScore: 14 / 3, + reviewCount: 3, }, { id: 'listing-002', @@ -48,6 +52,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['Borsett for betong', 'Koffert', 'Dybdestopper'], imageUrl: drillImage, + averageScore: 4, + reviewCount: 1, }, { id: 'listing-003', @@ -59,6 +65,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: [], imageUrl: handsawImage, + averageScore: 3, + reviewCount: 1, }, { id: 'listing-004', @@ -71,6 +79,8 @@ export const mockListings: Listing[] = [ condition: 'worn', accessories: ['Målebånd', 'Unbrakonøkler'], imageUrl: null, + averageScore: null, + reviewCount: 0, }, { id: 'listing-005', @@ -82,6 +92,8 @@ export const mockListings: Listing[] = [ condition: 'worn', accessories: [], imageUrl: spadeImage, + averageScore: 4.5, + reviewCount: 2, }, { id: 'listing-006', @@ -94,6 +106,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['Rive', 'Arbeidshansker'], imageUrl: spadeImage, + averageScore: null, + reviewCount: 0, }, { id: 'listing-007', @@ -106,6 +120,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['2 ruller', 'Malebrett', 'Teleskopskaft'], imageUrl: paintRollerImage, + averageScore: null, + reviewCount: 0, }, { id: 'listing-008', @@ -124,6 +140,8 @@ export const mockListings: Listing[] = [ 'Maskeringstape', ], imageUrl: paintRollerImage, + averageScore: 5, + reviewCount: 1, }, { id: 'listing-009', @@ -136,6 +154,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['Stativ', 'Batterier'], imageUrl: null, + averageScore: null, + reviewCount: 0, }, { id: 'listing-010', @@ -148,5 +168,7 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['Slange', '2 munnstykker'], imageUrl: null, + averageScore: 2, + reviewCount: 1, }, ]; diff --git a/src/graphql/listingQueries.ts b/src/graphql/listingQueries.ts index 5d345e9..1ca5191 100644 --- a/src/graphql/listingQueries.ts +++ b/src/graphql/listingQueries.ts @@ -5,6 +5,7 @@ import type { ToolCategory, ToolCondition, } from '../types/listing.ts'; +import type { Review } from '../types/review.ts'; import { DEFAULT_LISTING_SORT, type ListingSort, @@ -148,6 +149,8 @@ export const LISTINGS_PAGE_QUERY: TypedDocumentNode< area dailyPrice imageUrl + averageScore + reviewCount } totalCount hasNextPage @@ -173,8 +176,36 @@ export const LISTING_AREAS_QUERY: TypedDocumentNode< } `; +/** Antall vurderinger detaljvisningen viser, de nyeste først. */ +export const REVIEWS_LIMIT = 10; + +/** + * Snitt, antall og de nyeste vurderingene av en annonse. Brukes både i + * detaljspørringen og i svaret på createReview. Feltet reviews har samme + * argument begge steder, og Apollo lagrer annonsen under samme id, så svaret + * på en ny vurdering oppdaterer detaljvisningen uten en ny spørring. + */ +export const LISTING_REVIEWS_FRAGMENT = gql` + fragment ListingReviews on Listing { + id + averageScore + reviewCount + reviews(limit: ${REVIEWS_LIMIT}) { + id + score + comment + createdAt + } + } +`; + +/** Annonsen i detaljvisningen, med de nyeste vurderingene. */ +export interface ListingDetail extends Listing { + reviews: Review[]; +} + export interface ListingData { - listing: Listing | null; + listing: ListingDetail | null; } export interface ListingVariables { @@ -195,6 +226,8 @@ export const LISTING_QUERY: TypedDocumentNode = condition accessories imageUrl + ...ListingReviews } } + ${LISTING_REVIEWS_FRAGMENT} `; diff --git a/src/graphql/reviewMutations.ts b/src/graphql/reviewMutations.ts new file mode 100644 index 0000000..e25f448 --- /dev/null +++ b/src/graphql/reviewMutations.ts @@ -0,0 +1,56 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; +import type { Review } from '../types/review.ts'; +import { + LISTING_REVIEWS_FRAGMENT, + type ListingDetail, +} from './listingQueries.ts'; + +/** Laveste og høyeste score. Samme grenser som i backend. */ +export const REVIEW_SCORE_MIN = 1; +export const REVIEW_SCORE_MAX = 5; + +/** Lengste kommentar. Samme grense som REVIEW_COMMENT_MAX_LENGTH i backend. */ +export const REVIEW_COMMENT_MAX_LENGTH = 500; + +export interface CreateReviewInput { + listingId: string; + score: number; + /** Utelates når vurderingen ikke har kommentar. */ + comment?: string; +} + +export interface CreateReviewData { + createReview: { + review: Pick; + listing: Pick< + ListingDetail, + 'id' | 'averageScore' | 'reviewCount' | 'reviews' + >; + }; +} + +export interface CreateReviewVariables { + input: CreateReviewInput; +} + +/** + * Lagrer en vurdering. Svaret har annonsen med nytt snitt, antall og liste + * over vurderinger, i samme form som detaljspørringen. Apollo oppdaterer da + * detaljvisningen og annonsekortet i cachen, uten at noe hentes på nytt. + */ +export const CREATE_REVIEW_MUTATION: TypedDocumentNode< + CreateReviewData, + CreateReviewVariables +> = gql` + mutation CreateReview($input: CreateReviewInput!) { + createReview(input: $input) { + review { + id + } + listing { + ...ListingReviews + } + } + } + ${LISTING_REVIEWS_FRAGMENT} +`; diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts index ff7a3ec..1442fc3 100644 --- a/src/test/listingMocks.ts +++ b/src/test/listingMocks.ts @@ -11,7 +11,12 @@ import { listingsPageVariables, type CatalogQuery, } from '../graphql/listingQueries.ts'; +import { + CREATE_REVIEW_MUTATION, + type CreateReviewInput, +} from '../graphql/reviewMutations.ts'; import type { Listing } from '../types/listing.ts'; +import type { Review } from '../types/review.ts'; /** * Mockede GraphQL-svar for testene. Annonsene er de samme eksemplene som i @@ -20,7 +25,16 @@ import type { Listing } from '../types/listing.ts'; */ function toSummary(listing: Listing) { - const { id, title, category, area, dailyPrice, imageUrl } = listing; + const { + id, + title, + category, + area, + dailyPrice, + imageUrl, + averageScore, + reviewCount, + } = listing; return { __typename: 'Listing' as const, id, @@ -29,6 +43,8 @@ function toSummary(listing: Listing) { area, dailyPrice, imageUrl, + averageScore, + reviewCount, }; } @@ -86,21 +102,112 @@ export function listingAreasMock( }; } -/** Svar på detaljspørringen. null betyr at annonsen ikke finnes. */ +/** Vurderinger i samme form som svaret fra GraphQL. */ +function withReviewTypename(reviews: Review[]) { + return reviews.map((review) => ({ __typename: 'Review', ...review })); +} + +/** + * Svar på detaljspørringen. null betyr at annonsen ikke finnes. Vurderingene + * er tomme hvis ikke testen sender med noen. + */ export function listingMock( id: string, listing: Listing | null = mockListings.find((item) => item.id === id) ?? null, + reviews: Review[] = [], ): MockLink.MockedResponse { return { request: { query: LISTING_QUERY, variables: { id } }, result: { data: { - listing: listing && { __typename: 'Listing', ...listing }, + listing: listing && { + __typename: 'Listing', + ...listing, + reviews: withReviewTypename(reviews), + }, + }, + }, + }; +} + +/** Eksempelvurderinger, nyeste først, slik serveren sorterer dem. */ +export const mockReviews: Review[] = [ + { + id: 'review-3', + score: 5, + comment: '', + createdAt: '2026-10-01T18:40:00.000Z', + }, + { + id: 'review-2', + score: 4, + comment: 'Gjorde jobben med hyllene. Kunne hatt flere bits.', + createdAt: '2026-09-14T09:05:00.000Z', + }, + { + id: 'review-1', + score: 5, + comment: 'Batteriet holdt hele dagen.', + createdAt: '2026-09-02T16:20:00.000Z', + }, +]; + +export function createReviewRequest( + input: CreateReviewInput, +): MockLink.MockedResponse['request'] { + return { query: CREATE_REVIEW_MUTATION, variables: { input } }; +} + +interface CreateReviewMockOptions { + /** Vurderingene serveren svarer med, nyeste først. */ + reviews: Review[]; + averageScore: number; +} + +/** + * Vellykket createReview. Svaret har annonsen med nytt snitt og ny liste, slik + * serveren gir den. delay holder svaret igjen, så testene kan se at knappen + * er deaktivert mens vurderingen lagres. + */ +export function createReviewMock( + input: CreateReviewInput, + { reviews, averageScore }: CreateReviewMockOptions, +): MockLink.MockedResponse { + const [newest] = reviews; + return { + request: createReviewRequest(input), + delay: 20, + result: { + data: { + createReview: { + __typename: 'CreateReviewPayload', + review: { __typename: 'Review', id: newest?.id ?? 'review-ny' }, + listing: { + __typename: 'Listing', + id: input.listingId, + averageScore, + reviewCount: reviews.length, + reviews: withReviewTypename(reviews), + }, + }, }, }, }; } +/** Serveren avviser vurderingen, som når valideringen i backenden feiler. */ +export function createReviewRejectedMock( + input: CreateReviewInput, + message: string, +): MockLink.MockedResponse { + return { + request: createReviewRequest(input), + result: { + errors: [{ message, extensions: { code: 'BAD_USER_INPUT' } }], + }, + }; +} + /** Nettverksfeil, som når backenden ikke svarer. */ export function networkErrorMock( request: MockLink.MockedResponse['request'], diff --git a/src/types/listing.ts b/src/types/listing.ts index b2ec889..b2d74bd 100644 --- a/src/types/listing.ts +++ b/src/types/listing.ts @@ -25,10 +25,21 @@ export interface Listing { accessories: string[]; /** Adresse til bilde av verktøyet, eller null når annonsen mangler bilde. */ imageUrl: string | null; + /** Snittet av vurderingene, fra 1 til 5, eller null uten vurderinger. */ + averageScore: number | null; + /** Antall vurderinger av annonsen. */ + reviewCount: number; } /** Feltene annonsekortet i katalogen trenger. */ export type ListingSummary = Pick< Listing, - 'id' | 'title' | 'category' | 'area' | 'dailyPrice' | 'imageUrl' + | 'id' + | 'title' + | 'category' + | 'area' + | 'dailyPrice' + | 'imageUrl' + | 'averageScore' + | 'reviewCount' >; diff --git a/src/types/review.ts b/src/types/review.ts new file mode 100644 index 0000000..f2d6f29 --- /dev/null +++ b/src/types/review.ts @@ -0,0 +1,10 @@ +/** En vurdering av en annonse, slik backenden gir den. */ +export interface Review { + id: string; + /** Fra 1 til 5, der 5 er best. */ + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; + /** Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC. */ + createdAt: string; +} From 11c767ecb7388e87abab4c26224be1c143e5ccbc Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:07:45 +0100 Subject: [PATCH 07/23] =?UTF-8?q?feat(components):=20vis=20snittscore=20me?= =?UTF-8?q?d=20tekst=20og=20stjerner=20p=C3=A5=20annonsekortet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ListingCard/ListingCard.tsx | 5 ++ .../ReviewScore/ReviewScore.module.css | 32 ++++++++++++ .../ReviewScore/ReviewScore.test.tsx | 34 +++++++++++++ src/components/ReviewScore/ReviewScore.tsx | 40 +++++++++++++++ src/utils/reviewFormat.test.ts | 49 +++++++++++++++++++ src/utils/reviewFormat.ts | 44 +++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 src/components/ReviewScore/ReviewScore.module.css create mode 100644 src/components/ReviewScore/ReviewScore.test.tsx create mode 100644 src/components/ReviewScore/ReviewScore.tsx create mode 100644 src/utils/reviewFormat.test.ts create mode 100644 src/utils/reviewFormat.ts diff --git a/src/components/ListingCard/ListingCard.tsx b/src/components/ListingCard/ListingCard.tsx index 6c17c19..6cc3a62 100644 --- a/src/components/ListingCard/ListingCard.tsx +++ b/src/components/ListingCard/ListingCard.tsx @@ -4,6 +4,7 @@ import { catalogReturnState } from '../../utils/catalogPage.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import ListingImage from '../ListingImage/ListingImage.tsx'; import PriceTag from '../PriceTag/PriceTag.tsx'; +import ReviewScore from '../ReviewScore/ReviewScore.tsx'; import styles from './ListingCard.module.css'; interface ListingCardProps { @@ -37,6 +38,10 @@ function ListingCard({ listing }: ListingCardProps) {

{categoryLabels[listing.category]} · {listing.area}

+ diff --git a/src/components/ReviewScore/ReviewScore.module.css b/src/components/ReviewScore/ReviewScore.module.css new file mode 100644 index 0000000..672f0aa --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.module.css @@ -0,0 +1,32 @@ +.score { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin: 0; + font-size: var(--font-size-sm); +} + +/* De fylte stjernene ligger over de tomme og klippes til snittet. */ +.stars { + position: relative; + display: inline-block; + line-height: 1; + letter-spacing: 0.1em; + white-space: nowrap; +} + +.empty { + color: var(--color-border-strong); +} + +.filled { + position: absolute; + inset: 0 auto 0 0; + overflow: hidden; + color: var(--color-accent); +} + +.none { + color: var(--color-text-muted); +} diff --git a/src/components/ReviewScore/ReviewScore.test.tsx b/src/components/ReviewScore/ReviewScore.test.tsx new file mode 100644 index 0000000..aba2169 --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import ReviewScore from './ReviewScore.tsx'; + +describe('ReviewScore', () => { + it('viser snittet som tekst ved siden av stjernene', () => { + const { container } = render( + , + ); + expect(screen.getByText('4,7 av 5 (3 vurderinger)')).toBeVisible(); + // Stjernene er pynt og skal ikke leses opp. + const stars = container.querySelector('[aria-hidden="true"]'); + expect(stars).toHaveTextContent('★'); + }); + + it('leser bare opp teksten, ikke stjernesymbolene', () => { + const { container } = render( + , + ); + const visibleText = [...container.querySelectorAll('span')] + .filter((span) => !span.closest('[aria-hidden="true"]')) + .map((span) => span.textContent) + .join(''); + expect(visibleText).toBe('4,0 av 5 (1 vurdering)'); + }); + + it('viser «Ingen vurderinger ennå» uten stjerner', () => { + const { container } = render( + , + ); + expect(screen.getByText('Ingen vurderinger ennå')).toBeVisible(); + expect(container).not.toHaveTextContent('★'); + }); +}); diff --git a/src/components/ReviewScore/ReviewScore.tsx b/src/components/ReviewScore/ReviewScore.tsx new file mode 100644 index 0000000..c2cd106 --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.tsx @@ -0,0 +1,40 @@ +import { REVIEW_SCORE_MAX } from '../../graphql/reviewMutations.ts'; +import { scoreSummary } from '../../utils/reviewFormat.ts'; +import styles from './ReviewScore.module.css'; + +interface ReviewScoreProps { + averageScore: number | null; + reviewCount: number; +} + +/** + * Snittscore som stjerner og tekst, for eksempel «★★★★☆ 4,7 av 5 (3 + * vurderinger)». Stjernene er bare pynt og skjules for skjermlesere. Teksten + * står alltid ved siden av, så scoren ikke bare vises med symboler eller + * farge. Uten vurderinger vises bare «Ingen vurderinger ennå». + */ +function ReviewScore({ averageScore, reviewCount }: ReviewScoreProps) { + const hasScore = averageScore !== null && reviewCount > 0; + + return ( +

+ {hasScore && ( + + )} + + {scoreSummary(averageScore, reviewCount)} + +

+ ); +} + +export default ReviewScore; diff --git a/src/utils/reviewFormat.test.ts b/src/utils/reviewFormat.test.ts new file mode 100644 index 0000000..4066499 --- /dev/null +++ b/src/utils/reviewFormat.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + formatReviewDate, + reviewCountLabel, + scoreLabel, + scoreSummary, +} from './reviewFormat.ts'; + +describe('scoreSummary', () => { + it('viser snittet med én desimal og antall vurderinger', () => { + expect(scoreSummary(14 / 3, 3)).toBe('4,7 av 5 (3 vurderinger)'); + }); + + it('viser én desimal også for hele tall', () => { + expect(scoreSummary(4, 1)).toBe('4,0 av 5 (1 vurdering)'); + }); + + it('sier fra når annonsen ikke har vurderinger', () => { + expect(scoreSummary(null, 0)).toBe('Ingen vurderinger ennå'); + }); +}); + +describe('reviewCountLabel', () => { + it('bøyer ordet etter antallet', () => { + expect(reviewCountLabel(1)).toBe('1 vurdering'); + expect(reviewCountLabel(0)).toBe('0 vurderinger'); + expect(reviewCountLabel(12)).toBe('12 vurderinger'); + }); +}); + +describe('scoreLabel', () => { + it('viser scoren av 5', () => { + expect(scoreLabel(4)).toBe('4 av 5'); + }); +}); + +describe('formatReviewDate', () => { + it('viser datoen på norsk i norsk tid', () => { + expect(formatReviewDate('2026-09-02T16:20:00.000Z')).toBe( + '2. september 2026', + ); + }); + + it('bruker norsk tid, ikke UTC, rett før midnatt', () => { + expect(formatReviewDate('2026-09-02T22:30:00.000Z')).toBe( + '3. september 2026', + ); + }); +}); diff --git a/src/utils/reviewFormat.ts b/src/utils/reviewFormat.ts new file mode 100644 index 0000000..bd7e6a5 --- /dev/null +++ b/src/utils/reviewFormat.ts @@ -0,0 +1,44 @@ +import { REVIEW_SCORE_MAX } from '../graphql/reviewMutations.ts'; + +/** Én desimal med norsk desimalkomma, for eksempel «4,7». */ +const scoreFormatter = new Intl.NumberFormat('nb-NO', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}); + +const dateFormatter = new Intl.DateTimeFormat('nb-NO', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'Europe/Oslo', +}); + +/** «1 vurdering» eller «3 vurderinger». */ +export function reviewCountLabel(count: number): string { + return count === 1 ? '1 vurdering' : `${count} vurderinger`; +} + +/** + * Snittscore og antall som tekst, for eksempel «4,7 av 5 (3 vurderinger)». + * Uten vurderinger blir teksten «Ingen vurderinger ennå». Teksten står ved + * siden av stjernene, så scoren ikke bare vises med symboler. + */ +export function scoreSummary( + averageScore: number | null, + reviewCount: number, +): string { + if (averageScore === null || reviewCount === 0) { + return 'Ingen vurderinger ennå'; + } + return `${scoreFormatter.format(averageScore)} av ${REVIEW_SCORE_MAX} (${reviewCountLabel(reviewCount)})`; +} + +/** Én score som tekst, for eksempel «4 av 5». */ +export function scoreLabel(score: number): string { + return `${score} av ${REVIEW_SCORE_MAX}`; +} + +/** Datoen en vurdering ble gitt, for eksempel «2. september 2026». */ +export function formatReviewDate(createdAt: string): string { + return dateFormatter.format(new Date(createdAt)); +} From bf6b7926140b41e8a1081425ebb1196689fa245a Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:09:28 +0100 Subject: [PATCH 08/23] =?UTF-8?q?feat(components):=20lag=20skjema=20for=20?= =?UTF-8?q?=C3=A5=20gi=20en=20vurdering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewForm/ReviewForm.module.css | 76 ++++++++ src/components/ReviewForm/ReviewForm.test.tsx | 154 ++++++++++++++++ src/components/ReviewForm/ReviewForm.tsx | 171 ++++++++++++++++++ src/utils/reviewForm.test.ts | 51 ++++++ src/utils/reviewForm.ts | 68 +++++++ 5 files changed, 520 insertions(+) create mode 100644 src/components/ReviewForm/ReviewForm.module.css create mode 100644 src/components/ReviewForm/ReviewForm.test.tsx create mode 100644 src/components/ReviewForm/ReviewForm.tsx create mode 100644 src/utils/reviewForm.test.ts create mode 100644 src/utils/reviewForm.ts diff --git a/src/components/ReviewForm/ReviewForm.module.css b/src/components/ReviewForm/ReviewForm.module.css new file mode 100644 index 0000000..4b98252 --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.module.css @@ -0,0 +1,76 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 32rem; +} + +.scores { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0; + padding: 0; + border: 0; +} + +.scores legend { + margin-bottom: var(--space-2); + padding: 0; +} + +/* Valgene står på rad når det er plass, og under hverandre på mobil. */ +.options { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Hele etiketten er klikkbar og stor nok til å treffe med fingeren. */ +.option { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: var(--tap-target); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); + cursor: pointer; +} + +.option input { + width: 1.25rem; + height: 1.25rem; + margin: 0; + accent-color: var(--color-accent); +} + +/* Valgt score får tykkere kant i tillegg til den fylte radioknappen. */ +.option:has(input:checked) { + border: 2px solid var(--color-accent); +} + +.option:has(input[aria-invalid='true']) { + border-color: var(--color-error); +} + +.status p, +.error p { + margin: 0; +} + +/* Bekreftelsen får ramme bare når den har innhold. */ +.status:not(:empty) { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.error { + padding: var(--space-3) var(--space-4); + border: 2px solid var(--color-error); + border-radius: var(--radius-md); + background: var(--color-surface); +} diff --git a/src/components/ReviewForm/ReviewForm.test.tsx b/src/components/ReviewForm/ReviewForm.test.tsx new file mode 100644 index 0000000..85e852a --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.test.tsx @@ -0,0 +1,154 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { + createReviewMock, + createReviewRejectedMock, + createReviewRequest, + mockReviews, + networkErrorMock, +} from '../../test/listingMocks.ts'; +import ReviewForm from './ReviewForm.tsx'; + +const LISTING_ID = 'listing-001'; + +function renderForm(mocks: MockLink.MockedResponse[] = []) { + return render( + + + , + ); +} + +function scoreGroup() { + return screen.getByRole('group', { name: 'Vurdering' }); +} + +function commentField() { + return screen.getByRole('textbox', { name: 'Kommentar (valgfri)' }); +} + +function submitButton() { + return screen.getByRole('button', { name: /Legg ut vurdering|Lagrer/ }); +} + +describe('ReviewForm', () => { + it('har etiketter på alle valgene og feltet', () => { + renderForm(); + const radios = within(scoreGroup()).getAllByRole('radio'); + expect(radios.map((radio) => radio.getAttribute('value'))).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]); + expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); + expect(commentField()).toHaveAccessibleDescription(/Høyst 500 tegn/); + }); + + it('kan velge score med piltastene', async () => { + renderForm(); + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.keyboard('{ArrowRight}'); + expect(screen.getByRole('radio', { name: '4 – Bra' })).toBeChecked(); + }); + + it('viser feil ved scoren og flytter fokus dit når ingen score er valgt', async () => { + // Ingen mocks: en forespørsel ville gitt feil i stedet for valideringsfeil. + renderForm(); + await userEvent.click(submitButton()); + + expect(scoreGroup()).toHaveAccessibleDescription( + 'Velg en vurdering fra 1 til 5.', + ); + expect( + screen.getByRole('radio', { name: '1 – Svært dårlig' }), + ).toHaveFocus(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('viser feil ved kommentaren når den er for lang', async () => { + renderForm(); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + await userEvent.click(commentField()); + await userEvent.paste('a'.repeat(501)); + await userEvent.click(submitButton()); + + expect(commentField()).toHaveAttribute('aria-invalid', 'true'); + expect(commentField()).toHaveAccessibleDescription( + /høyst 500 tegn\. Nå har den 501\./, + ); + expect(commentField()).toHaveFocus(); + }); + + it('lagrer vurderingen, bekrefter og tømmer skjemaet', async () => { + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 4, comment: 'Fin drill.' }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + await userEvent.type(commentField(), ' Fin drill. '); + await userEvent.click(submitButton()); + + expect(submitButton()).toBeDisabled(); + expect(submitButton()).toHaveTextContent('Lagrer'); + + expect( + await screen.findByText( + 'Takk! Vurderingen din er lagt ut og vises over.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Takk!'); + expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); + expect(commentField()).toHaveValue(''); + expect(submitButton()).toBeEnabled(); + }); + + it('sender ikke med en tom kommentar', async () => { + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 5 }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '5 – Svært bra' })); + await userEvent.type(commentField(), ' '); + await userEvent.click(submitButton()); + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + }); + + it('viser feilen fra serveren og beholder det som er fylt ut', async () => { + renderForm([ + createReviewRejectedMock( + { listingId: LISTING_ID, score: 2 }, + 'Fant ikke annonsen.', + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '2 – Dårlig' })); + await userEvent.click(submitButton()); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Vurderingen ble ikke lagret. Fant ikke annonsen.', + ); + expect(screen.getByRole('radio', { name: '2 – Dårlig' })).toBeChecked(); + }); + + it('forteller når serveren ikke svarer', async () => { + renderForm([ + networkErrorMock( + createReviewRequest({ listingId: LISTING_ID, score: 3 }), + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.click(submitButton()); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Vi fikk ikke kontakt med serveren', + ); + }); +}); diff --git a/src/components/ReviewForm/ReviewForm.tsx b/src/components/ReviewForm/ReviewForm.tsx new file mode 100644 index 0000000..e502aca --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.tsx @@ -0,0 +1,171 @@ +import { useMutation } from '@apollo/client/react'; +import { useEffect, useId, useRef, useState, type SubmitEvent } from 'react'; +import { serverValidationMessage } from '../../graphql/listingMutations.ts'; +import { + CREATE_REVIEW_MUTATION, + REVIEW_COMMENT_MAX_LENGTH, +} from '../../graphql/reviewMutations.ts'; +import { + emptyReviewForm, + scoreOptions, + toCreateReviewInput, + validateReviewForm, + type ReviewFormValues, +} from '../../utils/reviewForm.ts'; +import Button from '../Button/Button.tsx'; +import TextAreaField from '../TextAreaField/TextAreaField.tsx'; +import fieldStyles from '../TextField/TextField.module.css'; +import styles from './ReviewForm.module.css'; + +interface ReviewFormProps { + listingId: string; +} + +/** Resultatet av siste innsending, som vises under knappen. */ +type SubmitResult = + | { kind: 'created' } + | { kind: 'rejected'; message: string } + | { kind: 'networkError' }; + +/** + * Skjema for å gi en annonse en vurdering fra 1 til 5 med en valgfri + * kommentar. Svaret fra createReview har annonsen med nytt snitt og ny liste + * over vurderinger, og Apollo oppdaterer visningen fra det. Den nye + * vurderingen vises derfor med en gang, uten at siden lastes på nytt. + * + * Scoren velges med radioknapper i en gruppe, så den kan velges med + * piltastene. Feil vises som tekst ved feltet etter første forsøk på + * innsending, og fokus flyttes til første felt med feil. + */ +function ReviewForm({ listingId }: ReviewFormProps) { + const id = useId(); + const scoreErrorId = `${id}-score-error`; + const [values, setValues] = useState(emptyReviewForm); + const [submitCount, setSubmitCount] = useState(0); + const [result, setResult] = useState(null); + const formRef = useRef(null); + + const [createReview, { loading: isSaving }] = useMutation( + CREATE_REVIEW_MUTATION, + ); + + const errors = validateReviewForm(values); + const isValid = Object.keys(errors).length === 0; + const visibleErrors = submitCount > 0 ? errors : {}; + + useEffect(() => { + if (submitCount === 0) { + return; + } + formRef.current + ?.querySelector('[aria-invalid="true"]') + ?.focus(); + }, [submitCount]); + + function updateValues(changes: Partial) { + setValues((current) => ({ ...current, ...changes })); + // En gammel bekreftelse eller feil gjelder ikke lenger når skjemaet endres. + setResult(null); + } + + async function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + // Enter i et felt kan sende skjemaet selv om knappen er deaktivert. + if (isSaving) { + return; + } + setSubmitCount((count) => count + 1); + setResult(null); + if (!isValid) { + return; + } + + try { + await createReview({ + variables: { input: toCreateReviewInput(listingId, values) }, + }); + setResult({ kind: 'created' }); + setValues(emptyReviewForm); + setSubmitCount(0); + } catch (error) { + // Det brukeren har fylt ut, beholdes, så hen kan prøve igjen. + const message = serverValidationMessage(error); + setResult( + message ? { kind: 'rejected', message } : { kind: 'networkError' }, + ); + } + } + + return ( +
+
+ Vurdering +
+ {scoreOptions.map(({ score, label }) => ( + + ))} +
+ {visibleErrors.score && ( +

+ {visibleErrors.score} +

+ )} +
+ + updateValues({ comment: event.target.value })} + /> + +
+ +
+ + {/* Alltid i DOM-en, slik at skjermlesere leser opp ny tekst i den. */} +
+ {result?.kind === 'created' && ( +

Takk! Vurderingen din er lagt ut og vises over.

+ )} +
+ + {result && result.kind !== 'created' && ( +
+

+ Vurderingen ble ikke lagret.{' '} + {result.kind === 'rejected' + ? result.message + : 'Vi fikk ikke kontakt med serveren. Det du har fylt ut, er tatt vare på, så du kan prøve igjen om litt.'} +

+
+ )} + + ); +} + +export default ReviewForm; diff --git a/src/utils/reviewForm.test.ts b/src/utils/reviewForm.test.ts new file mode 100644 index 0000000..887e216 --- /dev/null +++ b/src/utils/reviewForm.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { REVIEW_COMMENT_MAX_LENGTH } from '../graphql/reviewMutations.ts'; +import { + emptyReviewForm, + toCreateReviewInput, + validateReviewForm, +} from './reviewForm.ts'; + +describe('validateReviewForm', () => { + it('krever at en score er valgt', () => { + expect(validateReviewForm(emptyReviewForm)).toEqual({ + score: 'Velg en vurdering fra 1 til 5.', + }); + }); + + it('godtar en score uten kommentar', () => { + expect(validateReviewForm({ score: 4, comment: '' })).toEqual({}); + }); + + it('godtar en kommentar på nøyaktig maksimal lengde', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH); + expect(validateReviewForm({ score: 4, comment })).toEqual({}); + }); + + it('teller ikke mellomrom i endene med i lengden', () => { + const comment = ` ${'a'.repeat(REVIEW_COMMENT_MAX_LENGTH)} `; + expect(validateReviewForm({ score: 4, comment })).toEqual({}); + }); + + it('avviser en for lang kommentar og sier hvor lang den er', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 3); + expect(validateReviewForm({ score: 4, comment }).comment).toBe( + 'Kommentaren kan ha høyst 500 tegn. Nå har den 503.', + ); + }); +}); + +describe('toCreateReviewInput', () => { + it('sender med kommentaren uten mellomrom i endene', () => { + expect( + toCreateReviewInput('listing-001', { score: 5, comment: ' Topp! ' }), + ).toEqual({ listingId: 'listing-001', score: 5, comment: 'Topp!' }); + }); + + it.each(['', ' '])('utelater en tom kommentar («%s»)', (comment) => { + expect(toCreateReviewInput('listing-001', { score: 3, comment })).toEqual({ + listingId: 'listing-001', + score: 3, + }); + }); +}); diff --git a/src/utils/reviewForm.ts b/src/utils/reviewForm.ts new file mode 100644 index 0000000..d9c5e9f --- /dev/null +++ b/src/utils/reviewForm.ts @@ -0,0 +1,68 @@ +/** + * Validering av skjemaet for vurderinger. Serveren gjør den samme kontrollen + * når vurderingen lagres (backend/src/validation/createReviewInput.ts). + */ + +import { + REVIEW_COMMENT_MAX_LENGTH, + REVIEW_SCORE_MAX, + REVIEW_SCORE_MIN, + type CreateReviewInput, +} from '../graphql/reviewMutations.ts'; + +export interface ReviewFormValues { + /** null til brukeren har valgt en score. */ + score: number | null; + comment: string; +} + +export type ReviewFormErrors = Partial>; + +export const emptyReviewForm: ReviewFormValues = { score: null, comment: '' }; + +/** Valgene i skjemaet, med en kort tekst som forklarer hver score. */ +export const scoreOptions: { score: number; label: string }[] = [ + { score: 1, label: 'Svært dårlig' }, + { score: 2, label: 'Dårlig' }, + { score: 3, label: 'Grei' }, + { score: 4, label: 'Bra' }, + { score: 5, label: 'Svært bra' }, +]; + +export function validateReviewForm(values: ReviewFormValues): ReviewFormErrors { + const errors: ReviewFormErrors = {}; + + if ( + values.score === null || + values.score < REVIEW_SCORE_MIN || + values.score > REVIEW_SCORE_MAX + ) { + errors.score = `Velg en vurdering fra ${REVIEW_SCORE_MIN} til ${REVIEW_SCORE_MAX}.`; + } + + const commentLength = values.comment.trim().length; + if (commentLength > REVIEW_COMMENT_MAX_LENGTH) { + errors.comment = `Kommentaren kan ha høyst ${REVIEW_COMMENT_MAX_LENGTH} tegn. Nå har den ${commentLength}.`; + } + + return errors; +} + +/** + * Gjør skjemaverdiene om til input for createReview. Skal bare kalles når + * skjemaet er gyldig. En tom kommentar sendes ikke med. + */ +export function toCreateReviewInput( + listingId: string, + values: ReviewFormValues, +): CreateReviewInput { + const input: CreateReviewInput = { + listingId, + score: values.score ?? REVIEW_SCORE_MIN, + }; + const comment = values.comment.trim(); + if (comment) { + input.comment = comment; + } + return input; +} From 7cc6c4f43cf631b34dd93bb21ed911769be88ad3 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:10:53 +0100 Subject: [PATCH 09/23] =?UTF-8?q?test(src):=20gj=C3=B8r=20testen=20for=20l?= =?UTF-8?q?asting=20av=20nytt=20s=C3=B8k=20mindre=20avhengig=20av=20tid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index d4d058f..f72b6b0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -225,7 +225,9 @@ describe('søk i katalogen', () => { search: 'drill', items: drill, totalCount: 1, - delay: 50, + // Lang nok til at svaret ikke rekker å komme før sjekken under, også + // når mange tester kjører samtidig. + delay: 300, }), ]); await findCards(); From dbe1d8f34f83c53f1ba4df588985f2698ea0027b Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:11:30 +0100 Subject: [PATCH 10/23] feat(pages): vis vurderinger og skjema for nye vurderinger i detaljvisningen --- .../ReviewList/ReviewList.module.css | 57 ++++++++++++++++ src/components/ReviewList/ReviewList.test.tsx | 46 +++++++++++++ src/components/ReviewList/ReviewList.tsx | 65 +++++++++++++++++++ .../ListingDetailPage.module.css | 18 +++++ .../ListingDetailPage/ListingDetailPage.tsx | 30 ++++++++- 5 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/components/ReviewList/ReviewList.module.css create mode 100644 src/components/ReviewList/ReviewList.test.tsx create mode 100644 src/components/ReviewList/ReviewList.tsx diff --git a/src/components/ReviewList/ReviewList.module.css b/src/components/ReviewList/ReviewList.module.css new file mode 100644 index 0000000..6d17a0a --- /dev/null +++ b/src/components/ReviewList/ReviewList.module.css @@ -0,0 +1,57 @@ +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.review { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin: 0; +} + +.stars { + color: var(--color-accent); + letter-spacing: 0.1em; + white-space: nowrap; +} + +.emptyStars { + color: var(--color-border-strong); +} + +.date, +.more, +.empty, +.noComment { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.comment, +.noComment, +.more, +.empty { + margin: 0; +} + +.comment { + /* Lange ord og lenker brytes i stedet for å gi horisontal scrolling. */ + overflow-wrap: anywhere; + white-space: pre-line; +} diff --git a/src/components/ReviewList/ReviewList.test.tsx b/src/components/ReviewList/ReviewList.test.tsx new file mode 100644 index 0000000..26a316c --- /dev/null +++ b/src/components/ReviewList/ReviewList.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { mockReviews } from '../../test/listingMocks.ts'; +import ReviewList from './ReviewList.tsx'; + +describe('ReviewList', () => { + it('viser vurderingene i rekkefølgen den får, med score, dato og kommentar', () => { + render(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + + const second = within(items[1]!); + expect(second.getByText('4 av 5')).toBeInTheDocument(); + expect(second.getByText('14. september 2026')).toHaveAttribute( + 'dateTime', + '2026-09-14T09:05:00.000Z', + ); + expect( + second.getByText('Gjorde jobben med hyllene. Kunne hatt flere bits.'), + ).toBeInTheDocument(); + }); + + it('sier fra når en vurdering ikke har kommentar', () => { + render(); + expect( + within(screen.getAllByRole('listitem')[0]!).getByText('Ingen kommentar.'), + ).toBeInTheDocument(); + }); + + it('forteller hvor mange som vises når det finnes flere', () => { + render(); + expect( + screen.getByText('Viser de 3 nyeste av 12 vurderinger.'), + ).toBeInTheDocument(); + }); + + it('viser en melding når annonsen ikke har vurderinger', () => { + render(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect( + screen.getByText( + 'Ingen har vurdert dette verktøyet ennå. Bli den første!', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/ReviewList/ReviewList.tsx b/src/components/ReviewList/ReviewList.tsx new file mode 100644 index 0000000..fed6346 --- /dev/null +++ b/src/components/ReviewList/ReviewList.tsx @@ -0,0 +1,65 @@ +import { REVIEW_SCORE_MAX } from '../../graphql/reviewMutations.ts'; +import type { Review } from '../../types/review.ts'; +import { + formatReviewDate, + reviewCountLabel, + scoreLabel, +} from '../../utils/reviewFormat.ts'; +import styles from './ReviewList.module.css'; + +interface ReviewListProps { + /** De nyeste vurderingene, nyeste først. */ + reviews: Review[]; + /** Antall vurderinger totalt, som kan være flere enn i listen. */ + reviewCount: number; +} + +/** + * De nyeste vurderingene av en annonse. Hver vurdering viser scoren som tekst + * («4 av 5») ved siden av stjernene, datoen og kommentaren. Har annonsen + * flere vurderinger enn listen viser, står det hvor mange som vises. + */ +function ReviewList({ reviews, reviewCount }: ReviewListProps) { + if (reviews.length === 0) { + return ( +

+ Ingen har vurdert dette verktøyet ennå. Bli den første! +

+ ); + } + + return ( + <> + {reviewCount > reviews.length && ( +

+ Viser de {reviews.length} nyeste av {reviewCountLabel(reviewCount)}. +

+ )} +
    + {reviews.map((review) => ( +
  • +

    + + {scoreLabel(review.score)} + +

    + {review.comment ? ( +

    {review.comment}

    + ) : ( +

    Ingen kommentar.

    + )} +
  • + ))} +
+ + ); +} + +export default ReviewList; diff --git a/src/pages/ListingDetailPage/ListingDetailPage.module.css b/src/pages/ListingDetailPage/ListingDetailPage.module.css index 95a8ff2..7420c5e 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.module.css +++ b/src/pages/ListingDetailPage/ListingDetailPage.module.css @@ -63,6 +63,24 @@ padding-left: var(--space-6); } +.reviews { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 40rem; + margin-bottom: var(--space-8); +} + +.reviews h2, +.reviewFormHeading { + margin: 0; +} + +.reviewFormHeading { + margin-top: var(--space-4); + font-size: var(--font-size-lg); +} + .request { max-width: 40rem; padding: var(--space-6); diff --git a/src/pages/ListingDetailPage/ListingDetailPage.tsx b/src/pages/ListingDetailPage/ListingDetailPage.tsx index 5384027..30f7083 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage/ListingDetailPage.tsx @@ -4,10 +4,15 @@ import ListingImage from '../../components/ListingImage/ListingImage.tsx'; import PriceTag from '../../components/PriceTag/PriceTag.tsx'; import QueryError from '../../components/QueryError/QueryError.tsx'; import RentalRequestForm from '../../components/RentalRequestForm/RentalRequestForm.tsx'; -import { LISTING_QUERY } from '../../graphql/listingQueries.ts'; +import ReviewForm from '../../components/ReviewForm/ReviewForm.tsx'; +import ReviewList from '../../components/ReviewList/ReviewList.tsx'; +import ReviewScore from '../../components/ReviewScore/ReviewScore.tsx'; +import { + LISTING_QUERY, + type ListingDetail, +} from '../../graphql/listingQueries.ts'; import { usePageTitle } from '../../hooks/usePageTitle.ts'; import { categoryLabels, conditionLabels } from '../../utils/listingFormat.ts'; -import type { Listing } from '../../types/listing.ts'; import { catalogBackLink } from '../../utils/catalogPage.ts'; import styles from './ListingDetailPage.module.css'; @@ -77,7 +82,7 @@ function ListingDetails({ listing, backTo, }: { - listing: Listing; + listing: ListingDetail; backTo: string; }) { return ( @@ -92,6 +97,10 @@ function ListingDetails({

{listing.title}

+
@@ -124,6 +133,21 @@ function ListingDetails({
+
+

Vurderinger

+ + +

Gi en vurdering

+ {/* key nullstiller skjemaet når brukeren går til en annen annonse. */} + +
+
Date: Thu, 29 Oct 2026 22:12:52 +0100 Subject: [PATCH 11/23] test(src): test vurderinger i katalogen og detaljvisningen --- src/App.test.tsx | 109 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/App.test.tsx b/src/App.test.tsx index f72b6b0..5f8c40d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,9 +3,11 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { mockListings } from './data/mockListings.ts'; import { + createReviewMock, listingMock, listingsPageMock, mockAreas, + mockReviews, networkErrorMock, } from './test/listingMocks.ts'; import { renderApp } from './test/renderApp.tsx'; @@ -961,3 +963,110 @@ describe('detaljvisningen', () => { ).toBeInTheDocument(); }); }); + +describe('vurderinger', () => { + const drill = mockListings.find((listing) => listing.id === 'listing-001')!; + const newReview = { + id: 'review-ny', + score: 3, + comment: 'Grei nok, men tung.', + createdAt: '2026-10-20T12:00:00.000Z', + }; + // Snittet etter den nye vurderingen: (5 + 4 + 5 + 3) / 4. + const newAverage = 17 / 4; + + function reviewsSection() { + return screen.getByRole('region', { name: 'Vurderinger' }); + } + + it('viser snittscore med tekst på annonsekortene', async () => { + renderApp(); + await findCards(); + const drillCard = screen + .getByRole('link', { name: 'Drill' }) + .closest('article')!; + expect(drillCard).toHaveTextContent('4,7 av 5 (3 vurderinger)'); + + const toolboxCard = screen + .getByRole('link', { name: /Verktøykasse/ }) + .closest('article')!; + expect(toolboxCard).toHaveTextContent('Ingen vurderinger ennå'); + }); + + it('viser snitt, antall og de nyeste vurderingene i detaljvisningen', async () => { + renderApp('/annonser/listing-001', [ + listingMock('listing-001', drill, mockReviews), + ]); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + const section = reviewsSection(); + expect(section).toHaveTextContent('4,7 av 5 (3 vurderinger)'); + const items = within(section).getAllByRole('listitem'); + expect(items).toHaveLength(mockReviews.length); + expect(items[1]).toHaveTextContent(mockReviews[1]!.comment); + expect( + within(section).getByRole('group', { name: 'Vurdering' }), + ).toBeInTheDocument(); + }); + + it('viser den nye vurderingen med en gang, uten å hente annonsen på nytt', async () => { + // Bare én mock for detaljspørringen: en ny henting ville gitt feil. + renderApp('/annonser/listing-001', [ + listingMock('listing-001', drill, mockReviews), + createReviewMock( + { listingId: 'listing-001', score: 3, comment: newReview.comment }, + { reviews: [newReview, ...mockReviews], averageScore: newAverage }, + ), + ]); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.type( + screen.getByRole('textbox', { name: 'Kommentar (valgfri)' }), + newReview.comment, + ); + await userEvent.click( + screen.getByRole('button', { name: 'Legg ut vurdering' }), + ); + + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + const section = reviewsSection(); + const items = within(section).getAllByRole('listitem'); + expect(items).toHaveLength(4); + expect(items[0]).toHaveTextContent(newReview.comment); + expect(section).toHaveTextContent('4,3 av 5 (4 vurderinger)'); + // Snittet ved prisen øverst oppdateres også. + expect( + screen.getAllByText('4,3 av 5 (4 vurderinger)').length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('oppdaterer snittet på kortet i katalogen fra cachen', async () => { + renderApp('/', [ + listingsPageMock(), + listingMock('listing-001', drill, mockReviews), + createReviewMock( + { listingId: 'listing-001', score: 3 }, + { reviews: [newReview, ...mockReviews], averageScore: newAverage }, + ), + ]); + await userEvent.click(await screen.findByRole('link', { name: 'Drill' })); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.click( + screen.getByRole('button', { name: 'Legg ut vurdering' }), + ); + await screen.findByText(/Takk!/); + + // Bare én mock for katalogen: kortet må oppdateres fra cachen. + await userEvent.click( + screen.getByRole('link', { name: 'Tilbake til katalogen' }), + ); + const drillCard = ( + await screen.findByRole('link', { name: 'Drill' }) + ).closest('article')!; + expect(drillCard).toHaveTextContent('4,3 av 5 (4 vurderinger)'); + }); +}); From 35bca3f1894572d366c0cc302fd2144ec0a899bd Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:15:56 +0100 Subject: [PATCH 12/23] docs(docs): dokumenter vurderinger av annonser --- README.md | 69 ++++++++++---- docs/design.md | 42 ++++++++- docs/testing.md | 69 +++++++------- docs/vurderinger.md | 218 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 53 deletions(-) create mode 100644 docs/vurderinger.md diff --git a/README.md b/README.md index 2ca61db..11689a5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ hvordan løsningen er tenkt brukt. Leieforespørselen er fortsatt demo. Søk, filtre og sortering i katalogen gjøres på serveren, i hele databasen, se -[Søk, filtre og sortering](#søk-filtre-og-sortering). Se også +[Søk, filtre og sortering](#søk-filtre-og-sortering). Brukere kan også gi +annonser en vurdering fra 1 til 5, se [Vurderinger](#vurderinger). Se også [Frontend mot GraphQL](#frontend-mot-graphql) og [Backend og database](#backend-og-database). @@ -48,11 +49,11 @@ låne verktøy i nærheten i stedet for å kjøpe nytt. ### Visninger og navigasjon -| Visning | Slik kommer du dit | Hva du kan gjøre | -| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | -| Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør og pris, velge datoer og se samlet pris | -| Lei ut verktøy | «Lei ut verktøy» i menyen | Fylle ut skjemaet for en ny annonse med tittel, kategori, beskrivelse, område og pris | +| Visning | Slik kommer du dit | Hva du kan gjøre | +| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | +| Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør, pris og vurderinger, gi en vurdering, velge datoer og se samlet pris | +| Lei ut verktøy | «Lei ut verktøy» i menyen | Fylle ut skjemaet for en ny annonse med tittel, kategori, beskrivelse, område og pris | Menyen ligger øverst på alle sider. Detaljvisningen har en lenke tilbake til katalogen, og en ukjent adresse gir en side med lenke til katalogen. Alt kan @@ -72,8 +73,11 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser Illustrasjonene er laget for prosjektet. - **Leieforespørselen er en demo.** Knappen heter «Send forespørsel (demo)». Ingen forespørsel blir sendt, og verktøyet blir ikke reservert. -- **Ingen innlogging.** Alle kan legge ut annonser, og annonsene har ingen - eier ennå. +- **Eksempelvurderingene er oppdiktede.** `npm run seed` legger inn 9 + vurderinger av noen av de håndskrevne annonsene, fra + `backend/src/seed/reviews.ts`. +- **Ingen innlogging.** Alle kan legge ut annonser og gi vurderinger, og + annonsene og vurderingene har ingen eier ennå. `src/data/mockListings.ts` brukes nå bare i tester og på designsiden. @@ -91,9 +95,12 @@ I den ferdige løsningen ligger dataene i en database: | Annonse | Ett fysisk eksemplar med eier, tilstand, pris, område og bilde | | Bruker | Den som legger ut annonser eller sender leieforespørsler | | Booking | Leieperiode og status: sendt, godkjent, avslått, utlevert eller levert | -| Vurdering | Leietakerens vurdering etter et gjennomført leieforhold | +| Vurdering | Score fra 1 til 5 og en valgfri kommentar til en annonse | -Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres. +Annonser og vurderinger er de brukergenererte dataene som lagres i dag. +Bookinger kommer senere. Da bør en vurdering høre til et gjennomført +leieforhold, men nå kan hvem som helst gi en vurdering, se +[docs/vurderinger.md](docs/vurderinger.md). Eksempelannonsene seedes i PostgreSQL via `backend/` (samme innhold som mock). For å teste skalering fyller `npm run seed:large` databasen med 10 000 genererte annonser. Verktøytypene og merkene er ekte og hentet fra Wikidata, @@ -196,6 +203,7 @@ Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for | `src/graphql/client.ts` | Apollo-klienten og adressen til API-et | | `src/graphql/listingQueries.ts` | Spørringene for katalogen og detaljvisningen | | `src/graphql/listingMutations.ts` | Mutasjonen som lagrer nye annonser | +| `src/graphql/reviewMutations.ts` | Mutasjonen som lagrer nye vurderinger | | `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. @@ -207,6 +215,12 @@ klientvalideringen er godkjent. Serveren validerer den på nytt. Etter lagring fjernes de hentede katalogsidene fra cachen, så katalogen hentes på nytt neste gang den vises, med den nye annonsen øverst. +**Nye vurderinger:** `createReview` svarer med annonsen med nytt snitt, antall +og liste over vurderinger, i samme form som detaljspørringen. Apollo oppdaterer +annonsen i cachen, så detaljvisningen og annonsekortet viser den nye +vurderingen uten en ny spørring, se +[docs/vurderinger.md](docs/vurderinger.md#ny-vurdering-uten-ny-henting). + **Samme adresser lokalt og på VM-en:** Appen har `base: '/project2/'` i `vite.config.ts`, og klienten sender spørringer til `/project2/api/graphql`. På VM-en sender Apache dette videre til backenden. Lokalt gjør Vite det samme @@ -308,7 +322,7 @@ psql -h localhost -U verktoyutlan_dev -d verktoyutlan -W -c "SELECT COUNT(*) FRO npm run seed # forvent 0 nye annonser andre gang ``` -Forvent **10** rader i `listings`. Seed-data tilsvarer +Forvent **10** rader i `listings` og **9** i `reviews`. Seed-data tilsvarer `src/data/mockListings.ts` (id `listing-001`–`listing-010`). **7. Stort datasett:** @@ -347,12 +361,12 @@ Serveren logger URL til GraphQL-endepunktet (standard **Queries** -| Felt | Beskrivelse | -| ------------------ | --------------------------------------------------------------- | -| `health` | Returnerer `"ok"`. | -| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | -| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | -| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | +| Felt | Beskrivelse | +| ------------------ | ------------------------------------------------------------------------------------------ | +| `health` | Returnerer `"ok"`. | +| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | +| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. Har snitt, antall og de nyeste vurderingene. | +| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | **Paginering:** `listings` returnerer en `ListingPage` med `items`, `totalCount` (antall annonser totalt) og `hasNextPage`. `limit` er @@ -398,9 +412,10 @@ trigram-indeks (`pg_trgm`) for søk i tekst. Med indeksene tar et søk under 2 m **Mutations** -| Felt | Beskrivelse | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| Felt | Beskrivelse | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| `createReview(input: CreateReviewInput!)` | Lagrer en vurdering fra 1 til 5 med valgfri kommentar, og gir annonsen med oppdatert snitt og liste. Se [docs/vurderinger.md](docs/vurderinger.md#graphql). | Feltene returneres i camelCase (`dailyPrice`, `imageUrl`, osv.). Enkelt eksempel med curl (juster URL om du har endret `PORT`): @@ -542,6 +557,19 @@ og valget er begrunnet i dokumentasjonen. Hvordan det virker, valideringen, tilgjengeligheten og filene står i [docs/sok-og-filtre.md](docs/sok-og-filtre.md). +## Vurderinger + +Brukere kan gi en annonse en vurdering fra 1 til 5 med en valgfri kommentar. +Annonsekortet viser snittet som stjerner og tekst, for eksempel «4,7 av 5 (3 +vurderinger)». Detaljvisningen viser snittet, de 10 nyeste vurderingene og et +skjema for å gi en ny. Den nye vurderingen og det nye snittet vises med en +gang, uten at siden lastes på nytt. + +Vurderingene lagres i tabellen `reviews`, og snitt og antall regnes ut i +samme spørring som annonsene, uten én ekstra spørring per kort. Datamodellen, +API-et, valideringen, tilgjengeligheten og testene står i +[docs/vurderinger.md](docs/vurderinger.md). + ## Design Felles farger, typografi, avstander og komponenter er beskrevet i @@ -591,6 +619,7 @@ dataene i appen kommer fra. | [docs/prosjektstandard.md](docs/prosjektstandard.md) | Branches, commit-format, pull requests og krav før merge | | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | +| [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | | [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | diff --git a/docs/design.md b/docs/design.md index a30b316..6e07ce4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -103,8 +103,8 @@ bakgrunn, men teksten alene er nok til å skille dem. ### ListingCard -Annonsekort med bilde, tittel, kategori, område og pris. Bildet vises med -`ListingImage`. +Annonsekort med bilde, tittel, kategori, område, snittscore og pris. Bildet +vises med `ListingImage`, og snittscoren med `ReviewScore`. ```tsx @@ -151,6 +151,44 @@ bekreftelsen sier at ingenting er sendt eller lagret. Bekreftelsen ligger i et `role="status"`-område, så skjermlesere leser den opp. Bruk `key`, slik at skjemaet nullstilles når brukeren går til en annen annonse. +### ReviewScore + +Snittscore som stjerner og tekst, på annonsekortet og i detaljvisningen. + +```tsx + +// ★★★★★ 4,7 av 5 (3 vurderinger) +``` + +De fylte stjernene ligger over fem tomme og klippes til snittet, så 4,7 gir +nesten fem fylte. Stjernene har `aria-hidden="true"`, og teksten står alltid +ved siden av, så scoren ikke bare vises med symboler eller farge. Uten +vurderinger vises bare «Ingen vurderinger ennå» i dempet tekst. Fylte +stjerner har aksentfargen, tomme har `--color-border-strong`. + +### ReviewList + +De nyeste vurderingene av en annonse, som en liste med kort. Hvert kort viser +stjerner, scoren som tekst («4 av 5»), datoen i en `
-
diff --git a/src/components/ReviewForm/ReviewForm.test.tsx b/src/components/ReviewForm/ReviewForm.test.tsx index 85e852a..e8a9f04 100644 --- a/src/components/ReviewForm/ReviewForm.test.tsx +++ b/src/components/ReviewForm/ReviewForm.test.tsx @@ -95,7 +95,10 @@ describe('ReviewForm', () => { await userEvent.type(commentField(), ' Fin drill. '); await userEvent.click(submitButton()); - expect(submitButton()).toBeDisabled(); + expect(submitButton()).toHaveAttribute('aria-disabled', 'true'); + // Ikke disabled: da mister knappen fokus i nettleseren (ikke i jsdom), og + // tastaturbrukere havner øverst på siden. + expect(submitButton()).not.toBeDisabled(); expect(submitButton()).toHaveTextContent('Lagrer'); expect( @@ -106,7 +109,24 @@ describe('ReviewForm', () => { expect(screen.getByRole('status')).toHaveTextContent('Takk!'); expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); expect(commentField()).toHaveValue(''); - expect(submitButton()).toBeEnabled(); + expect(submitButton()).not.toHaveAttribute('aria-disabled'); + }); + + it('beholder fokus på knappen og sender bare én gang ved Enter to ganger', async () => { + // Bare én mock: en ny innsending ville gitt feilmelding. + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 4 }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + submitButton().focus(); + await userEvent.keyboard('{Enter}{Enter}'); + + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + expect(submitButton()).toHaveFocus(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('sender ikke med en tom kommentar', async () => { diff --git a/src/components/ReviewForm/ReviewForm.tsx b/src/components/ReviewForm/ReviewForm.tsx index e502aca..45d4b1f 100644 --- a/src/components/ReviewForm/ReviewForm.tsx +++ b/src/components/ReviewForm/ReviewForm.tsx @@ -142,7 +142,11 @@ function ReviewForm({ listingId }: ReviewFormProps) { />
-
From a63897f65877f760e9e509ed056794f2e7090018 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:29:45 +0100 Subject: [PATCH 18/23] =?UTF-8?q?fix(pages):=20flytt=20fokus=20n=C3=A5r=20?= =?UTF-8?q?=C2=ABFjern=20alle=20filtre=C2=BB=20eller=20=C2=ABVis=20alle=20?= =?UTF-8?q?annonser=C2=BB=20forsvinner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: andretg --- src/App.test.tsx | 27 +++++++++++++++++++++++++++ src/pages/CatalogPage/CatalogPage.tsx | 26 +++++++++++++++++--------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index b129fcc..2ed9fca 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1107,6 +1107,33 @@ describe('fokus når katalogen endres', () => { expect(searchbox).toHaveFocus(); }); + it('flytter fokus til filterpanelet etter «Fjern alle filtre»', async () => { + renderApp('/?q=drill&kategori=garden', [ + listingsPageMock({ search: 'drill', category: 'garden' }), + listingsPageMock(), + ]); + await findCards(); + + await userEvent.click( + screen.getByRole('button', { name: 'Fjern alle filtre' }), + ); + expect( + screen.getByRole('group', { name: 'Filtrer og sorter' }), + ).toHaveFocus(); + }); + + it('flytter fokus til treffene etter «Vis alle annonser»', async () => { + renderApp('/?q=xyz', [ + listingsPageMock({ search: 'xyz', items: [], totalCount: 0 }), + listingsPageMock(), + ]); + await userEvent.click( + await screen.findByRole('button', { name: 'Vis alle annonser' }), + ); + expect(results()).toHaveFocus(); + expect(await findCards()).toHaveLength(mockListings.length); + }); + it('lar fokus stå i filteret når et filter starter på side 1 igjen', async () => { renderApp('/?side=2', [ listingsPageMock({ page: 2, totalCount: 30 }), diff --git a/src/pages/CatalogPage/CatalogPage.tsx b/src/pages/CatalogPage/CatalogPage.tsx index 74226e0..45c1829 100644 --- a/src/pages/CatalogPage/CatalogPage.tsx +++ b/src/pages/CatalogPage/CatalogPage.tsx @@ -77,17 +77,26 @@ function CatalogPage() { [updateFilters], ); - // Fjerner søk og filtre, men beholder sorteringen. - const clearFilters = useCallback( - () => updateFilters(NO_LISTING_FILTERS), - [updateFilters], - ); - // Når den siste filterbrikken er fjernet, finnes ikke knappen som hadde // fokus lenger. Fokus går da til filterpanelet, rett over der brikkene stod. const filtersRef = useRef(null); const focusFilters = useCallback(() => filtersRef.current?.focus(), []); + // «Fjern alle filtre» og «Vis alle annonser» fjerner søk og filtre, men + // beholder sorteringen. Begge knappene forsvinner når de er trykket, så + // fokus flyttes, ellers havner det øverst på siden. «Fjern alle filtre» + // går til filterpanelet, der brukeren kan velge nye filtre. «Vis alle + // annonser» går til treffene, som brukeren ba om å se. + const resultsRef = useRef(null); + const clearFilters = useCallback(() => { + updateFilters(NO_LISTING_FILTERS); + focusFilters(); + }, [updateFilters, focusFilters]); + const showAllListings = useCallback(() => { + updateFilters(NO_LISTING_FILTERS); + resultsRef.current?.focus({ preventScroll: true }); + }, [updateFilters]); + // Husker søket, slik at «Katalog» i menyen kan gå tilbake til det. const urlSearch = searchParams.toString(); useEffect(() => { @@ -101,7 +110,6 @@ function CatalogPage() { // på lenken nederst, mens siden viser toppen, og neste Tab ville gått ut av // siden. Endres søk eller filtre, blir fokus stående i feltet brukeren // bruker. - const resultsRef = useRef(null); const queryWithoutPage = withoutCatalogPage(searchParams).toString(); const previousPage = useRef(page); const previousQuery = useRef(queryWithoutPage); @@ -144,7 +152,7 @@ function CatalogPage() { onEmpty={focusFilters} /> - {/* Får fokus når brukeren blar, se effekten over. */} + {/* Får fokus når brukeren blar eller trykker «Vis alle annonser». */}
void refetch()} - onClearFilters={clearFilters} + onClearFilters={showAllListings} />
From e055c4552e8c0e884f4abe94db0dc8b5bcd80075 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:31:52 +0100 Subject: [PATCH 19/23] docs(docs): dokumenter gjennomgangen av tilgjengelighet Co-Authored-By: andretg --- README.md | 8 +- docs/design.md | 30 +++++--- docs/testing.md | 83 ++++++++++---------- docs/tilgjengelighet.md | 167 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 54 deletions(-) create mode 100644 docs/tilgjengelighet.md diff --git a/README.md b/README.md index 11689a5..339a6b0 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,10 @@ mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). parter ser sine leieforhold. Godkjente bookinger kan ikke overlappe for samme verktøy. Utlevering og retur registreres, og leietakeren kan gi en vurdering etterpå. Gratis utlån bruker samme flyt. -- **Tilgjengelighet:** Semantisk HTML, tastaturnavigasjon, synlig fokus og - kontrast etter WCAG 2.1 er på plass allerede. Senere kommer test med - skjermleser og en manuell brukertest. +- **Tilgjengelighet:** Appen er gått gjennom mot WCAG 2.1 AA med axe-core, + tastatur, 320 px bredde og `prefers-reduced-motion`, og funnene er rettet, + se [docs/tilgjengelighet.md](docs/tilgjengelighet.md). Test med skjermleser + gjenstår. - **Bærekraft:** Små bilder som lastes når de trengs, systemfont, buffring og spørringer som bare henter feltene som vises. Vi måler datamengde og antall forespørsler. @@ -620,6 +621,7 @@ dataene i appen kommer fra. | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | | [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | +| [docs/tilgjengelighet.md](docs/tilgjengelighet.md) | Gjennomgang mot WCAG 2.1 AA: metode, funn og rettelser | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | | [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | diff --git a/docs/design.md b/docs/design.md index 6e07ce4..51b78ab 100644 --- a/docs/design.md +++ b/docs/design.md @@ -41,22 +41,28 @@ fokusmarkering. Kontrollerte fargepar: -| Forgrunn | Bakgrunn | Kontrast | Krav | -| ----------------------- | ----------------------- | -------- | ----- | -| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | -| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | -| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | -| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | -| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| Forgrunn | Bakgrunn | Kontrast | Krav | +| ---------------------------- | ----------------------- | -------- | ----- | +| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | +| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | +| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | +| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | +| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| `--color-text-muted` | `--color-surface` | 6,3:1 | 4,5:1 | +| Fokusring (`--color-accent`) | `--color-bg` | 5,9:1 | 3:1 | `--color-border` har lav kontrast (1,3:1) og brukes bare til dekorative rammer, der innholdet er forståelig uten rammen. +Plassholdertekst i felt bruker `--color-text-muted` (6,3:1 mot hvitt). Uten +den regelen gir Firefox bare 3,5:1, se +[tilgjengelighet.md](tilgjengelighet.md#funn-og-rettelser). + ## Typografi | Variabel | Størrelse | Bruk | diff --git a/docs/testing.md b/docs/testing.md index d94fec4..25e2034 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,44 +67,45 @@ testdatabase-URL kjører fortsatt. ## Hva som testes -| Fil | Hva som testes | -| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/App.test.tsx` | Navigasjon, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke og 404. Katalog og detaljvisning mot mockede GraphQL-svar: lasting, antall fra serveren, sider i URL-en, blaing, feil med «Prøv igjen», tom katalog, side som ikke finnes og ukjent annonse. Søk mot mockede svar: søk fra URL-en, bare det ferdige søket sendes, nytt søk starter på side 1 og blaing beholder søket, forrige resultat vises under lasting, sene svar på eldre søk vises ikke, søk uten treff. Filtre og sortering: valgene leses fra URL-en og sendes til serveren, ugyldige verdier ignoreres, endrede valg starter på side 1, blaing beholder alle valgene, «Fjern alle filtre» beholder sorteringen, tomt resultat med filtre. Område, tilstand og pris: valgene fra URL-en sendes til serveren, områdene hentes fra serveren, hvert filter gir ny forespørsel fra side 1, ugyldig prisintervall gir feil og verken forespørsel eller endret URL, ugyldig intervall i URL-en ignoreres, blaing og «Fjern alle filtre», katalogen virker når områdene ikke kan hentes. Aktive filtre og tomt resultat: brikker fra URL-en, ingen brikker uten filtre, fjerning av ett filter med fokus på neste brikke, fokus til filterpanelet når siste brikke er fjernet, ulike forslag for bare søk og søk med filtre, tom katalog skilt fra resultat uten treff. Tilbake fra en annonse: lenken tar med søk, filtre, sortering og side, «Tilbake til katalogen» og nettleserens tilbakeknapp viser resultatet fra cachen uten ny forespørsel, direkte åpnet annonse og «Fant ikke annonsen» lenker til katalogen uten filtre. «Katalog» i menyen: går tilbake til siste søk etter «Lei ut verktøy» fra cachen, følger med når filtrene endres, husker fjernede filtre, navnet øverst går til katalogen uten filtre. Vurderinger: snitt på kortene, vurderinger i detaljvisningen, ny vurdering vises med en gang og kortet i katalogen oppdateres fra cachen | -| `components/Pagination/` | Lenker til forrige og neste side, ingen lenke på første og siste side, skjult når alt får plass på én side | -| `components/PriceRangeFilter/` | Etiketter og `inputmode`, debouncing, lik fra- og til-pris, feil ved «Til» når «Fra» er høyere, feil for «-5», «12,5» og «ti», ingenting sendes ved feil, tomt felt fjerner grensen, endring utenfra fjerner feil | -| `components/CatalogSearch/` | Etikett og søkeområde, debouncing med ny ventetid ved hvert tastetrykk, Enter og «Søk» søker med en gang, mellomrom i endene, «Tøm søket» med fokus i feltet, søk som endres utenfra | -| `components/CatalogFilters/` | Etiketter og navngitt gruppe, viser valgene den får, melder fra om kategori, «Alle kategorier», gratis utlån, sortering, område og tilstand, «Fjern alle filtre» bare når noe er valgt, områdeboksen starter lukket og viser valgene i overskriften, lasting og ingen områder, valgte områder kan fjernes mens listen lastes | -| `components/ActiveFilters/` | Vises ikke uten filtre, navngitt liste, fjerner riktig valg, tastatur, fokus til neste eller forrige brikke, `onEmpty` når den siste er fjernet | -| `utils/activeFilters.test.ts` | Én brikke per valg i fast rekkefølge, unike nøkler, navn som inneholder den synlige teksten, fjerning av bare ett valg, tekst for prisintervall med tusenskille, forslag når ingenting passer | -| `components/CheckboxGroup/` | Navngitt `fieldset`, avkrysning legger til og fjerner i fast rekkefølge, mellomromstasten, skjult legend beholder navnet | -| `utils/catalogMemory.test.ts` | Søket lagres og leses i `sessionStorage`, tomt søk huskes, endrede verdier brukes ikke, appen virker når lagringen er blokkert | -| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider som beholder søk og filtre, fjerning av siden, antall sider, og lenken tilbake fra en annonse med gyldig og ugyldig `state` | -| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | -| `utils/listingFilters.test.ts` | Filtre i URL-en: gyldige og ugyldige verdier, for langt søk kortes ned, gjentatte parametere for område og tilstand, ugyldige priser og makspris under minstepris ignoreres, tomme filtre fjernes, andre parametere beholdes, om noe søk eller filter er valgt, og teksten for valgte områder | -| `utils/listingSort.test.ts` | Sortering i URL-en: nyeste som standard, ukjente verdier gir standard, standard fjernes fra URL-en | -| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | -| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3», skjemaverdier gjort om til input for `createListing` | -| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | -| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | -| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | -| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | -| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes | -| `components/ReviewForm/` | Etiketter, piltaster, feil og fokus, for lang kommentar, lagring og tømt skjema, tom kommentar sendes ikke, feil fra serveren og nettverksfeil | -| `components/ReviewList/` | Rekkefølge, score, dato og kommentar, «Ingen kommentar», hvor mange som vises, ingen vurderinger | -| `components/ReviewScore/` | Snittet som tekst ved stjernene, stjernene leses ikke opp, «Ingen vurderinger ennå» | -| `utils/reviewForm.test.ts` | Validering av score og kommentar, input til `createReview` | -| `utils/reviewFormat.test.ts` | Snitt med én desimal, bøyning av antall, dato i norsk tid | -| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | -| `graphql/listingQueries.test.ts` | Variablene til katalogspørringen: posisjon fra siden, søk uten mellomrom i endene, filtre og sortering, område, tilstand og prisintervall, standardvalg og tomme lister utelates | -| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | -| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | -| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | -| `backend/src/graphql/listingsFilter.api.test.ts` | Søk, filtre og sortering i `listings`: store og små bokstaver også for æ, ø og å, søk i beskrivelsen, flere ord, tomt søk, `%` og `_` som vanlig tekst, hvert filter og kombinasjoner, ett og flere områder, områdenavn må være helt like, tilstander, prisintervall med grensene inkludert, tomme lister og `null`, `listingAreas` med nye områder sortert på norsk, hver sortering med stabil rekkefølge ved lik pris, `totalCount` og sider med filtre, søk uten treff og ugyldige verdier, også ukjent tilstand, negativ pris og `minPrice` over `maxPrice` | -| `backend/src/db/listingsQuery.test.ts` | WHERE og ORDER BY bygges med parametere, escaping av `%`, `_` og `\`, nummerering av plassholdere, `=` for ett område og `ANY` for flere, tilstand og pris, alle filtrene kombinert, norsk sortering av områder, uten database | -| `backend/src/validation/listingFilterArgs.test.ts` | Standardverdier, oppdeling av søket i ord, maksimal lengde på søket, områder uten mellomrom, tomme navn og duplikater, grenser for antall områder og lengde, duplikate tilstander, prisintervall og ugyldige priser, uten database | -| `backend/src/graphql/reviews.api.test.ts` | Vurderinger: lagring, snitt og antall, nyeste først, `limit`, snitt i katalogen, ugyldig score, kommentar og annonse, `CHECK` og `ON DELETE CASCADE` i databasen | -| `backend/src/validation/createReviewInput.test.ts` | Grenser for score, kommentar og `limit`, uten database | -| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | +| Fil | Hva som testes | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.test.tsx` | Navigasjon, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke og 404. Katalog og detaljvisning mot mockede GraphQL-svar: lasting, antall fra serveren, sider i URL-en, blaing, feil med «Prøv igjen», tom katalog, side som ikke finnes og ukjent annonse. Søk mot mockede svar: søk fra URL-en, bare det ferdige søket sendes, nytt søk starter på side 1 og blaing beholder søket, forrige resultat vises under lasting, sene svar på eldre søk vises ikke, søk uten treff. Filtre og sortering: valgene leses fra URL-en og sendes til serveren, ugyldige verdier ignoreres, endrede valg starter på side 1, blaing beholder alle valgene, «Fjern alle filtre» beholder sorteringen, tomt resultat med filtre. Område, tilstand og pris: valgene fra URL-en sendes til serveren, områdene hentes fra serveren, hvert filter gir ny forespørsel fra side 1, ugyldig prisintervall gir feil og verken forespørsel eller endret URL, ugyldig intervall i URL-en ignoreres, blaing og «Fjern alle filtre», katalogen virker når områdene ikke kan hentes. Aktive filtre og tomt resultat: brikker fra URL-en, ingen brikker uten filtre, fjerning av ett filter med fokus på neste brikke, fokus til filterpanelet når siste brikke er fjernet, ulike forslag for bare søk og søk med filtre, tom katalog skilt fra resultat uten treff. Tilbake fra en annonse: lenken tar med søk, filtre, sortering og side, «Tilbake til katalogen» og nettleserens tilbakeknapp viser resultatet fra cachen uten ny forespørsel, direkte åpnet annonse og «Fant ikke annonsen» lenker til katalogen uten filtre. «Katalog» i menyen: går tilbake til siste søk etter «Lei ut verktøy» fra cachen, følger med når filtrene endres, husker fjernede filtre, navnet øverst går til katalogen uten filtre. Vurderinger: snitt på kortene, vurderinger i detaljvisningen, ny vurdering vises med en gang og kortet i katalogen oppdateres fra cachen. Fokus: blaing flytter fokus til treffene, endret søk eller filter lar fokus stå, «Fjern alle filtre» går til filterpanelet og «Vis alle annonser» til treffene | +| `src/accessibility.test.tsx` | axe-core på alle sidene: katalogen med og uten filtre, uten treff, detaljvisningen med vurderinger og med feil i skjemaene, utleieskjemaet tomt og med feil, og sidene som ikke finnes. Se [tilgjengelighet.md](tilgjengelighet.md) | +| `components/Pagination/` | Lenker til forrige og neste side, ingen lenke på første og siste side, skjult når alt får plass på én side | +| `components/PriceRangeFilter/` | Etiketter og `inputmode`, debouncing, lik fra- og til-pris, feil ved «Til» når «Fra» er høyere, feil for «-5», «12,5» og «ti», ingenting sendes ved feil, tomt felt fjerner grensen, endring utenfra fjerner feil | +| `components/CatalogSearch/` | Etikett og søkeområde, debouncing med ny ventetid ved hvert tastetrykk, Enter og «Søk» søker med en gang, mellomrom i endene, «Tøm søket» med fokus i feltet, søk som endres utenfra | +| `components/CatalogFilters/` | Etiketter og navngitt gruppe, viser valgene den får, melder fra om kategori, «Alle kategorier», gratis utlån, sortering, område og tilstand, «Fjern alle filtre» bare når noe er valgt, områdeboksen starter lukket og viser valgene i overskriften, lasting og ingen områder, valgte områder kan fjernes mens listen lastes | +| `components/ActiveFilters/` | Vises ikke uten filtre, navngitt liste, fjerner riktig valg, tastatur, fokus til neste eller forrige brikke, `onEmpty` når den siste er fjernet | +| `utils/activeFilters.test.ts` | Én brikke per valg i fast rekkefølge, unike nøkler, navn som inneholder den synlige teksten, fjerning av bare ett valg, tekst for prisintervall med tusenskille, forslag når ingenting passer | +| `components/CheckboxGroup/` | Navngitt `fieldset`, avkrysning legger til og fjerner i fast rekkefølge, mellomromstasten, skjult legend beholder navnet | +| `utils/catalogMemory.test.ts` | Søket lagres og leses i `sessionStorage`, tomt søk huskes, endrede verdier brukes ikke, appen virker når lagringen er blokkert | +| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider som beholder søk og filtre, fjerning av siden, antall sider, og lenken tilbake fra en annonse med gyldig og ugyldig `state` | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Filtre i URL-en: gyldige og ugyldige verdier, for langt søk kortes ned, gjentatte parametere for område og tilstand, ugyldige priser og makspris under minstepris ignoreres, tomme filtre fjernes, andre parametere beholdes, om noe søk eller filter er valgt, og teksten for valgte områder | +| `utils/listingSort.test.ts` | Sortering i URL-en: nyeste som standard, ukjente verdier gir standard, standard fjernes fra URL-en | +| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3», skjemaverdier gjort om til input for `createListing` | +| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | +| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | +| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | +| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes. Knappen har `aria-disabled`, ikke `disabled`, mens det lagres, og Enter to ganger sender bare én gang | +| `components/ReviewForm/` | Etiketter, piltaster, feil og fokus, for lang kommentar, lagring og tømt skjema, tom kommentar sendes ikke, feil fra serveren og nettverksfeil. Knappen har `aria-disabled`, ikke `disabled`, mens det lagres, og Enter to ganger sender bare én gang | +| `components/ReviewList/` | Rekkefølge, score, dato og kommentar, «Ingen kommentar», hvor mange som vises, ingen vurderinger | +| `components/ReviewScore/` | Snittet som tekst ved stjernene, stjernene leses ikke opp, «Ingen vurderinger ennå» | +| `utils/reviewForm.test.ts` | Validering av score og kommentar, input til `createReview` | +| `utils/reviewFormat.test.ts` | Snitt med én desimal, bøyning av antall, dato i norsk tid | +| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | +| `graphql/listingQueries.test.ts` | Variablene til katalogspørringen: posisjon fra siden, søk uten mellomrom i endene, filtre og sortering, område, tilstand og prisintervall, standardvalg og tomme lister utelates | +| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | +| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | +| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | +| `backend/src/graphql/listingsFilter.api.test.ts` | Søk, filtre og sortering i `listings`: store og små bokstaver også for æ, ø og å, søk i beskrivelsen, flere ord, tomt søk, `%` og `_` som vanlig tekst, hvert filter og kombinasjoner, ett og flere områder, områdenavn må være helt like, tilstander, prisintervall med grensene inkludert, tomme lister og `null`, `listingAreas` med nye områder sortert på norsk, hver sortering med stabil rekkefølge ved lik pris, `totalCount` og sider med filtre, søk uten treff og ugyldige verdier, også ukjent tilstand, negativ pris og `minPrice` over `maxPrice` | +| `backend/src/db/listingsQuery.test.ts` | WHERE og ORDER BY bygges med parametere, escaping av `%`, `_` og `\`, nummerering av plassholdere, `=` for ett område og `ANY` for flere, tilstand og pris, alle filtrene kombinert, norsk sortering av områder, uten database | +| `backend/src/validation/listingFilterArgs.test.ts` | Standardverdier, oppdeling av søket i ord, maksimal lengde på søket, områder uten mellomrom, tomme navn og duplikater, grenser for antall områder og lengde, duplikate tilstander, prisintervall og ugyldige priser, uten database | +| `backend/src/graphql/reviews.api.test.ts` | Vurderinger: lagring, snitt og antall, nyeste først, `limit`, snitt i katalogen, ugyldig score, kommentar og annonse, `CHECK` og `ON DELETE CASCADE` i databasen | +| `backend/src/validation/createReviewInput.test.ts` | Grenser for score, kommentar og `limit`, uten database | +| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | Status 29. oktober 2026, frontend: 22 testfiler og 249 tester, alle grønne. @@ -201,7 +202,11 @@ gjort, og flytt resultatet opp i dokumentet. - [ ] Tastaturnavigasjon og synlig fokus kontrollert for hånd i Firefox eller Safari - [ ] Appen prøvd på en ekte telefon -- [ ] Hovedflyten prøvd med skjermleser (VoiceOver eller NVDA) +- [ ] Hovedflyten prøvd med skjermleser (VoiceOver eller NVDA), se sjekklisten + i [tilgjengelighet.md](tilgjengelighet.md#gjenstår) +- [x] Gjennomgang mot WCAG 2.1 AA med axe-core, tastatur, 320 px og + `prefers-reduced-motion` i Chromium (29. oktober 2026), se + [tilgjengelighet.md](tilgjengelighet.md) - [ ] Manuell brukertest med en person utenfor gruppen - [x] API-tester mot testdatabase (GraphQL i `backend/`, oktober 2026) - [ ] Ende-til-ende-test med Playwright for en hel leieflyt, når booking finnes diff --git a/docs/tilgjengelighet.md b/docs/tilgjengelighet.md new file mode 100644 index 0000000..f9b966a --- /dev/null +++ b/docs/tilgjengelighet.md @@ -0,0 +1,167 @@ +# Tilgjengelighet + +Gjennomgang av appen mot WCAG 2.1 nivå AA, gjort 29. oktober 2026 for +innlevering 3. Dokumentet beskriver hvordan vi sjekket, hva vi fant, hva vi +rettet, og hva som gjenstår. + +## Innhold + +- [Sammendrag](#sammendrag) +- [Slik sjekket vi](#slik-sjekket-vi) +- [Resultat med axe](#resultat-med-axe) +- [Funn og rettelser](#funn-og-rettelser) +- [Det som var på plass fra før](#det-som-var-på-plass-fra-før) +- [Kontroller etter krav](#kontroller-etter-krav) +- [Gjenstår](#gjenstår) +- [Slik sjekker du selv](#slik-sjekker-du-selv) + +## Sammendrag + +- **axe-core** fant ingen brudd på WCAG 2.1 A eller AA på noen side eller i + noen tilstand vi sjekket. +- **Fire funn** som axe ikke fanger, er rettet. Tre gjaldt fokus som + forsvant, og ett gjaldt kontrast på plassholdertekst i Firefox. Se + [Funn og rettelser](#funn-og-rettelser). +- **Automatiske tester** med axe-core kjører nå med `npm test`, så nye brudd + stopper før de havner i en pull request. +- **Skjermlesertest** med VoiceOver eller NVDA er ikke gjort ennå. Den må gjøres + av noen i gruppen, se [Gjenstår](#gjenstår). + +## Slik sjekket vi + +| Kontroll | Hvordan | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| axe i nettleser | [axe-core](https://github.com/dequelabs/axe-core) 4.14, samme motor som Lighthouse bruker, i Chromium mot ekte backend med 50 010 annonser. Reglene for WCAG 2.1 A og AA og axe sine anbefalinger (`best-practice`) | +| axe i testene | `src/accessibility.test.tsx` kjører de samme reglene på hver side med Vitest og jsdom. Kontrast kan ikke måles i jsdom og sjekkes bare i nettleseren | +| Tastatur | Hele flyten med bare Tab, Shift+Tab, Enter, mellomrom og piltaster: søk, filtre, filterbrikker, blaing, detaljvisning, vurdering og utleieskjemaet | +| Fokus | Hvor fokus havner etter blaing, sidebytte, innsending og når en knapp forsvinner | +| Bredde og zoom | Alle sidene på 320 px (tilsvarer 400 % zoom, WCAG 1.4.10) og 640 px (200 % zoom), med filterpanelene åpne. Sjekket at ingenting gir vannrett scrolling | +| Tekstavstand | Linjehøyde 1,5, avsnitt 2em, bokstavavstand 0,12em og ordavstand 0,16em (WCAG 1.4.12). Sjekket at ingen tekst klippes | +| Bevegelse | `prefers-reduced-motion: reduce` emulert. Talt opp animasjoner og overganger | +| Kontrast | Regnet ut for fargene axe ikke dekker, som plassholdertekst og fokusring | +| Struktur | Landemerker, overskrifter, statusmeldinger og alternativ tekst, lest ut fra DOM-en | + +## Resultat med axe + +Ingen brudd på noen av sidene eller tilstandene: + +| Side og tilstand | Brudd | +| ------------------------------------------------------------------- | ----- | +| Katalogen | 0 | +| Katalogen med søk, alle filtre, filterbrikker og åpne filterpaneler | 0 | +| Katalogen uten treff | 0 | +| Detaljvisningen med vurderinger | 0 | +| Detaljvisningen med feil i vurderingsskjemaet og leieforespørselen | 0 | +| Lei ut verktøy, tomt og med feil | 0 | +| «Fant ikke annonsen» og «Fant ikke siden» | 0 | + +axe kunne ikke avgjøre kontrasten for stjernene i snittscoren, fordi de bare +er symboler. Stjernene har `aria-hidden="true"`, og scoren står alltid som +tekst ved siden av («4,7 av 5 (3 vurderinger)»). De er pynt, så kravet til +tekstkontrast gjelder ikke dem. + +## Funn og rettelser + +| # | Funn | WCAG | Rettelse | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Etter «Neste» eller «Forrige» scrollet siden til toppen, men fokus ble stående på lenken nederst. Neste Tab gikk ut av siden | 2.4.3 Fokusrekkefølge | Treffene er en `section` med navnet «Treff» som får fokus når bare siden endres. Endres søk eller filtre, blir fokus i feltet brukeren bruker | +| 2 | Plassholdertekst hadde nettleserens standardfarge. I Firefox er det tekstfargen med 54 % opasitet, 3,5:1 mot hvitt | 1.4.3 Kontrast | `::placeholder` bruker `--color-text-muted`, 6,3:1, i alle nettlesere | +| 3 | «Legg ut vurdering» og «Legg ut verktøy» ble `disabled` mens skjemaet ble lagret. En deaktivert knapp mister fokus, så tastatur- og skjermleserbrukere havnet øverst på siden etter innsending | 2.4.3 Fokusrekkefølge | Knappene bruker `aria-disabled` i stedet. De ser deaktiverte ut og leses som utilgjengelige, men beholder fokus. `handleSubmit` stopper en ny innsending i mellomtiden | +| 4 | «Fjern alle filtre» og «Vis alle annonser» forsvinner når de trykkes, og fokus havnet på `` | 2.4.3 Fokusrekkefølge | «Fjern alle filtre» flytter fokus til filterpanelet. «Vis alle annonser» flytter fokus til treffene | + +I tillegg slår en ny regel i `src/styles/base.css` av animasjoner og +overganger med `prefers-reduced-motion: reduce`. Appen har ingen i dag, så +regelen sørger for at det fortsetter slik. + +Hvert funn har tester, se [testing.md](testing.md). Testene feiler hvis +rettelsen fjernes. + +## Det som var på plass fra før + +- **Hopp-lenke:** «Hopp til hovedinnhold» er første tabulatorstopp. Den er + skjult til den får fokus, og vises da øverst med fokusring. +- **Fokus ved sidebytte:** Ved bytte mellom katalog, annonse og utleieskjema + flyttes fokus til `main`, og fanetittelen endres. +- **Språk:** ``. +- **Landemerker:** `header`, `nav` «Hovedmeny», `main` og `footer` på alle + sider. Søket er et søkeområde, filtrene en gruppe «Filtrer og sorter», og + blaingen en `nav` «Sider». +- **Overskrifter:** Én `h1` per side og ingen hopp i nivå. +- **Skjemaer:** Alle felt har synlig etikett. Hjelpetekst og feil er koblet til + feltet med `aria-describedby`, ugyldige felt har `aria-invalid`, og fokus går + til første felt med feil. +- **Statusmeldinger:** Antall treff, bekreftelser og lasting står i + `role="status"`, og feil fra serveren i `role="alert"`. +- **Ikke bare farge:** Gratis utlån står som tekst, og scoren står som tekst ved + stjernene. +- **Bilder:** Illustrasjonene har `alt=""`, fordi tittelen står rett ved. +- **Kontrast:** Fargeparene i [design.md](design.md#farger) oppfyller 4,5:1 + for tekst og 3:1 for rammer og fokusring. +- **Trykkflater:** Knapper, lenker og valg er minst 44 px høye. + +## Kontroller etter krav + +| Krav fra issue #63 | Status | +| ----------------------------------------------------------------- | ------------------------------------------------------------------ | +| Hver side sjekket med axe eller Lighthouse, alvorlige funn rettet | Gjort. Ingen brudd, se [Resultat med axe](#resultat-med-axe) | +| Hopp-lenke til hovedinnholdet, synlig ved fokus | Gjort, fantes fra før og er kontrollert | +| Fokus til `h1` eller hovedinnholdet ved sidebytte | Gjort. Gjelder nå også blaing, se funn 1 | +| Søk, filtre, blaing og skjemaer prøvd med bare tastatur | Gjort i Chromium. Funn 1, 3 og 4 er rettet | +| Søk, filtre, blaing og skjemaer prøvd med VoiceOver eller NVDA | **Gjenstår**, se under | +| 200 % zoom og 320 px uten vannrett scrolling | Gjort. Ingen vannrett scrolling på noen side | +| `prefers-reduced-motion` respekteres | Gjort. Ingen animasjoner, og en regel sørger for at det fortsetter | +| Funn og rettelser i `docs/tilgjengelighet.md`, lenket fra README | Gjort | + +## Gjenstår + +**Skjermlesertest.** Strukturen er kontrollert ut fra DOM-en, men ingen har +lyttet gjennom appen med en skjermleser ennå. Det bør gjøres av noen i gruppen +før innleveringen, med VoiceOver (Cmd+F5 på Mac) i Safari eller NVDA i +Firefox. Skriv resultatet inn i tabellen. + +| Hva | Forventet | Resultat | +| -------------------------------------------------- | ------------------------------------------------------------------------ | -------- | +| Gå gjennom landemerkene (VoiceOver: VO+U, NVDA: D) | Hovedmeny, hovedinnhold, søk, «Filtrer og sorter», «Treff», «Sider» | | +| Søk etter «drill» og vent | Antall treff leses opp, for eksempel «288 treff for drill, side 1 av 12» | | +| Velg en kategori og fjern den med filterbrikken | Nytt antall leses opp. Brikken heter «Fjern filter: Hage» | | +| Trykk «Neste» | «Treff» og ny side leses opp, og neste kort er første annonse | | +| Åpne en annonse | Tittelen på annonsen leses opp | | +| Gi en vurdering uten å velge score | Feilen leses opp ved gruppen «Vurdering» | | +| Gi en gyldig vurdering | «Takk! Vurderingen din er lagt ut» leses opp, og fokus står på knappen | | +| Send utleieskjemaet tomt | Fokus går til «Tittel», og feilen leses opp | | + +**Andre kjente mangler:** + +- **«Prøv igjen»** i feilmeldingen forsvinner når den trykkes, og fokus havner + øverst på siden. Det skjer bare når backenden ikke svarer, så vi har ikke + rettet det i denne runden. +- **Bare Chromium.** Tastatur og bredde er kontrollert i Chromium, ikke i + Firefox eller Safari. Kontrasten på plassholdertekst er regnet ut for + Firefox. +- **Ingen test med brukere** utenfor gruppen, som avtalt i issuet. + +## Slik sjekker du selv + +**Automatisk:** `npm test` kjører `src/accessibility.test.tsx`, som sjekker hver +side med axe-core. Hjelpefunksjonen `expectNoAxeViolations` i +`src/test/axe.ts` kan brukes i andre tester. + +**I nettleseren:** Start appen med `npm run dev`, åpne en side og lim dette inn +i konsollen. Det laster axe-core fra `node_modules` og skriver ut bruddene: + +```js +const s = document.createElement('script'); +s.src = '/project2/node_modules/axe-core/axe.min.js'; +s.onload = async () => { + const r = await axe.run(document, { + runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'], + }); + console.table( + r.violations.map((v) => ({ id: v.id, antall: v.nodes.length })), + ); +}; +document.head.appendChild(s); +``` + +Lighthouse i Chrome DevTools (fanen «Lighthouse», kategorien +«Accessibility») bruker den samme motoren og gir det samme resultatet. From 741fb8d49cc9f8cd8759d6b090f72f739a10a78e Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:42:09 +0100 Subject: [PATCH 20/23] feat(deploy): komprimer JSON og SVG og sett cache-headere i Apache-siten Co-Authored-By: birkjr --- deploy/apache/README.md | 32 ++++++++++++++++++++--- deploy/apache/t36-project2.conf | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/deploy/apache/README.md b/deploy/apache/README.md index a55a8c4..9424d9b 100644 --- a/deploy/apache/README.md +++ b/deploy/apache/README.md @@ -26,16 +26,18 @@ npm run preview -- --base /project2/ Konfigurasjonen forutsetter at modulene er **enabled**. Sjekk: ```bash -apache2ctl -M | grep -E 'alias_module|rewrite_module|proxy_module|proxy_http_module' +apache2ctl -M | grep -E 'alias_module|rewrite_module|proxy_module|proxy_http_module|headers_module|deflate_module|filter_module' ``` Aktiver manglende moduler (krever sudo; **ikke** deaktiver prosjekt 1): ```bash -sudo a2enmod alias rewrite proxy proxy_http +sudo a2enmod alias rewrite proxy proxy_http headers deflate filter ``` -Valgfritt senere: `headers` for cache på statiske filer. +`headers` gir cache-headerne, og `deflate` og `filter` komprimerer tekst. +`deflate` er slått på som standard på Ubuntu. Uten `headers` virker appen, +men uten cache-headerne. Se [docs/baerekraft.md](../../docs/baerekraft.md). ## Filer @@ -111,7 +113,7 @@ Forutsetter: prosjekt 1 fungerer, og GraphQL kjører på 3001. ```bash export REPO_ROOT="$HOME/T36-Project-2" -sudo a2enmod alias rewrite proxy proxy_http +sudo a2enmod alias rewrite proxy proxy_http headers deflate filter sudo mkdir -p /var/www/project2 # Bygg og kopier til /var/www/project2, se «Oppdatere frontenden» @@ -141,6 +143,25 @@ curl -sS -o /dev/null -w "%{http_code}\n" -X POST \ SPA: åpne `/project2/annonser/listing-001` i nettleser og trykk refresh — skal ikke gi Apache 404. +Komprimering og cache. Bytt inn navnet på JS-filen fra `index.html`: + +```bash +B=http://it2810-36.idi.ntnu.no/project2 +# index.html: Content-Encoding: gzip og Cache-Control: no-cache +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/" | grep -i -E 'content-encoding|cache-control' +# JS: Cache-Control: public, max-age=31536000, immutable +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/assets/index-.js" | grep -i cache-control +# SVG: Content-Encoding: gzip og Cache-Control: public, max-age=604800 +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/assets/listings/drill.svg" | grep -i -E 'content-encoding|cache-control' +# GraphQL: Content-Encoding: gzip +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' -H 'Content-Type: application/json' \ + -H 'Accept: application/graphql-response+json' -X POST "$B/api/graphql" \ + -d '{"query":"{ listings { totalCount } }"}' | grep -i content-encoding +# 304 når filen er uendret +etag=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/" | grep -i etag | cut -d' ' -f2 | tr -d '\r') +curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept-Encoding: gzip' -H "If-None-Match: $etag" "$B/" +``` + ## Avhengigheter 1. Frontend-issue: `base: '/project2/'` i `vite.config.ts` + produksjonsbuild. @@ -157,3 +178,6 @@ ikke gi Apache 404. | SVG 404 | Frontend er bygget uten `public/assets/listings` | | API 502/503 | Backend ikke startet på 3001 | | `/project2` ødelegger P1 | Feil site aktivert — bruk kun `t36-project2.conf` | +| Ingen `Cache-Control` på filene | `headers` ikke enabled (`sudo a2enmod headers` og reload) | +| GraphQL-svar er ikke komprimert | `filter` eller `deflate` ikke enabled | +| Alltid 200, aldri 304 | `DeflateAlterETag NoChange` mangler, så ETag-en har `-gzip` og matcher ikke | diff --git a/deploy/apache/t36-project2.conf b/deploy/apache/t36-project2.conf index 9693155..642db26 100644 --- a/deploy/apache/t36-project2.conf +++ b/deploy/apache/t36-project2.conf @@ -26,8 +26,54 @@ RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /project2/index.html [L] + + # Cache. Målingene og begrunnelsen står i docs/baerekraft.md. + + # index.html sjekkes mot serveren hver gang (ETag gir 304 når den er + # uendret). Den peker på JS- og CSS-filene, så etter en deploy må + # nettleseren få den nye, ellers spør den etter filer som er slettet. + + Header set Cache-Control "no-cache" + + # Vite legger en hash av innholdet i navnet, for eksempel + # index-OnZCJrkh.js. Nytt innhold gir nytt navn, så filene kan caches i + # et år uten å bli spurt etter på nytt. + + Header set Cache-Control "public, max-age=31536000, immutable" + + # Bildene og favicon har ikke hash i navnet. En uke, og etter det sjekker + # nettleseren med ETag om de er endret. + + Header set Cache-Control "public, max-age=604800" + + +# Komprimering av tekst, bare for prosjekt 2. Ubuntus deflate.conf komprimerer +# allerede HTML, CSS og JS, men ikke JSON fra GraphQL eller SVG-bildene. Alle +# typene står her, så siten ikke avhenger av standardoppsettet. mod_deflate +# komprimerer ikke et svar som allerede er komprimert. +# +# Apollo Client ber om application/graphql-response+json, og Apollo Server +# svarer da med den typen, ikke application/json. Begge må stå i listen. + + + + AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript image/svg+xml + AddOutputFilterByType DEFLATE application/json application/graphql-response+json + + + + +# mod_deflate legger «-gzip» på ETag-en til komprimerte svar. Nettleseren +# sender den tilbake i If-None-Match, men Apache kjenner den ikke igjen og +# svarer 200 med hele filen i stedet for 304. NoChange beholder ETag-en, så +# uendrede filer gir 304. Direktivet kan bare stå for hele serveren og gjelder +# derfor også prosjekt 1, der virkningen er den samme. + + DeflateAlterETag NoChange + + # GraphQL-backend (127.0.0.1:3001) — /project2/api/graphql → backend /graphql From 625b2c7551685fd939949fb984afd43ce44f077a Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:44:14 +0100 Subject: [PATCH 21/23] =?UTF-8?q?feat(styles):=20legg=20til=20m=C3=B8rkt?= =?UTF-8?q?=20tema=20som=20f=C3=B8lger=20systeminnstillingen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: birkjr --- src/styles/tokens.css | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 537cee7..71fb042 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -53,4 +53,36 @@ /* Fokus */ --focus-ring: 3px solid var(--color-accent); --focus-offset: 2px; + + /* + * Nettleserens egne kontroller, som nedtrekkslister, avkrysningsbokser og + * rullefelt, følger temaet under. + */ + color-scheme: light dark; +} + +/* + * Mørkt tema når brukeren har valgt det i systemet. Bare fargene endres, så + * komponentene trenger ingen egne regler. På OLED-skjermer bruker mørke + * piksler mindre strøm, og mange synes mørkt tema er lettere å lese om + * kvelden. Kontrasten er regnet ut for de samme parene som i lyst tema, se + * docs/design.md. + */ +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #151917; + --color-surface: #1e2320; + --color-surface-muted: #2a302c; + --color-text: #edf0ec; + --color-text-muted: #b3bab5; + --color-border: #3a413c; + --color-border-strong: #8b938d; + /* Lys grønn i stedet for mørk, med mørk tekst på knappene. */ + --color-accent: #7fc6a1; + --color-accent-hover: #9bd4b6; + --color-on-accent: #0f2419; + --color-free-bg: #1c3a2a; + --color-free-text: #bfe8d0; + --color-error: #ff9187; + } } From e0748d6a92a1f77fa7c35e450c78aeebb8aa8c01 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:46:23 +0100 Subject: [PATCH 22/23] fix(components): hindre at bunnteksten hopper ned mens en side lastes Co-Authored-By: birkjr --- src/components/Layout/Layout.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/Layout/Layout.module.css b/src/components/Layout/Layout.module.css index d584e23..2eebf60 100644 --- a/src/components/Layout/Layout.module.css +++ b/src/components/Layout/Layout.module.css @@ -88,6 +88,12 @@ .main { flex: 1; + /* + * Minst like høy som vinduet, så bunnteksten står under det som vises. Ellers + * står den nederst i vinduet mens en side lastes, og hopper ned når innholdet + * kommer (layout-forskyvning, CLS). + */ + min-height: 100vh; padding-block: var(--space-8); } From 28595a76bf3b01bc86d8b80b4dd90167649237eb Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:48:16 +0100 Subject: [PATCH 23/23] =?UTF-8?q?docs(docs):=20dokumenter=20m=C3=A5lingene?= =?UTF-8?q?=20og=20tiltakene=20for=20b=C3=A6rekraft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: birkjr --- README.md | 8 +- docs/baerekraft.md | 194 ++++++++++++++++++++++++++++++++++++++++ docs/design.md | 49 +++++++++- docs/tilgjengelighet.md | 5 ++ 4 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 docs/baerekraft.md diff --git a/README.md b/README.md index 339a6b0..f9ca908 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,10 @@ mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). tastatur, 320 px bredde og `prefers-reduced-motion`, og funnene er rettet, se [docs/tilgjengelighet.md](docs/tilgjengelighet.md). Test med skjermleser gjenstår. -- **Bærekraft:** Små bilder som lastes når de trengs, systemfont, buffring og - spørringer som bare henter feltene som vises. Vi måler datamengde og antall - forespørsler. +- **Bærekraft:** Små bilder som lastes når de trengs, systemfont, mørkt tema, + komprimering og cache-headere i Apache, og spørringer som bare henter feltene + som vises. Målingene før og etter står i + [docs/baerekraft.md](docs/baerekraft.md). - **Testing:** Komponenttester med Vitest og API-tester mot en testdatabase finnes allerede. Senere kommer en ende-til-ende-test med Playwright av en hel leieflyt. @@ -622,6 +623,7 @@ dataene i appen kommer fra. | [docs/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | | [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | | [docs/tilgjengelighet.md](docs/tilgjengelighet.md) | Gjennomgang mot WCAG 2.1 AA: metode, funn og rettelser | +| [docs/baerekraft.md](docs/baerekraft.md) | Bærekraft: målinger før og etter, tiltak og vurderinger | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | | [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | diff --git a/docs/baerekraft.md b/docs/baerekraft.md new file mode 100644 index 0000000..37ca6b8 --- /dev/null +++ b/docs/baerekraft.md @@ -0,0 +1,194 @@ +# Bærekraft + +Hvordan appen holder datamengden, antall forespørsler og arbeidet på serveren +nede, hva vi har målt, og hva tiltakene i innlevering 3 ga. Mindre data over +nettet betyr mindre energi i nettverk, server og enhet, og raskere sider på +treg mobilforbindelse. + +## Innhold + +- [Sammendrag](#sammendrag) +- [Slik målte vi](#slik-målte-vi) +- [Før og etter](#før-og-etter) +- [Tiltak i innlevering 3](#tiltak-i-innlevering-3) +- [Det som var på plass fra før](#det-som-var-på-plass-fra-før) +- [JS-bunten](#js-bunten) +- [Vurdert, men ikke gjort](#vurdert-men-ikke-gjort) +- [På VM-en](#på-vm-en) + +## Sammendrag + +| Måling, katalogen | Før | Etter | +| ----------------------------------- | -------- | -------- | +| Første besøk, data over nettet | 164,3 kB | 160,3 kB | +| Første besøk, forespørsler | 10 | 10 | +| Nytt besøk, data over nettet | 5,7 kB | 2,2 kB | +| Én side med 24 annonser fra GraphQL | 5,0 kB | 1,4 kB | +| Layout-forskyvning (CLS) | 0,069 | 0 | +| JS-bunten (gzip) | 151,7 kB | 151,7 kB | + +- **GraphQL-svarene komprimeres.** Hver side i katalogen er 73 % mindre over + nettet. Det merkes hver gang brukeren søker, filtrerer eller blar. +- **Riktige cache-headere.** Filene med hash i navnet hentes én gang i året, + og `index.html` sjekkes alltid mot serveren. Før gjettet nettleseren hvor + lenge filene var gyldige, og kunne bruke en gammel `index.html` etter en + deploy. +- **Mørkt tema** følger systeminnstillingen. Det koster 0,15 kB CSS. +- **Ingen layout-forskyvning.** Bunnteksten hoppet ned når siden var lastet. +- **JS-bunten er uendret.** 93 % av den er React, Apollo, React Router og + `graphql`, se [JS-bunten](#js-bunten). + +## Slik målte vi + +Målt 29. oktober 2026 med produksjonsbygget (`npm run build`), servert av +Apache med vår site-konfigurasjon fra `deploy/apache/t36-project2.conf`, og +backenden mot databasen med 50 010 annonser. Begge målingene er gjort på +samme maskin med samme data, bare Apache-konfigurasjonen og CSS-en er +endret. + +- **Apache lokalt:** Apache 2.4 på macOS med `mod_deflate`, `mod_headers` og + `mod_proxy`. «Før» er dagens site-konfigurasjon pluss Ubuntus standard + `deflate.conf`. Den er slått på som standard på Ubuntu. +- **Sjekket mot VM-en:** Med VPN svarte VM-en det samme som «før»: gzip på + HTML, CSS og JS, ingen komprimering av SVG og GraphQL (3,4 kB per side), ingen + `Cache-Control`, og ETag med `-gzip`. +- **Nettleser:** Chromium via Playwright, 1280 × 900. Bytes over nettet er + `encodedDataLength` fra Chrome DevTools Protocol, med HTTP-headere. + «Første besøk» er med tom cache, «nytt besøk» er å åpne katalogen på nytt + senere. +- **CLS:** `PerformanceObserver` for `layout-shift` på katalogen, + detaljvisningen og utleieskjemaet, på 1280 og 375 px. +- **Bunten:** `vite build`, og en fordeling av den minifiserte bunten per + pakke ut fra sourcemapen. + +## Før og etter + +**Første besøk i katalogen**, tom cache: + +| Fil | Før | Etter | Endring | +| ------------------- | -------------- | -------------- | -------------------- | +| `index.html` | 692 B, gzip | 713 B, gzip | Lenke til ny CSS-fil | +| CSS | 3,9 kB, gzip | 4,1 kB, gzip | Mørkt tema | +| JS | 150,4 kB, gzip | 150,5 kB, gzip | Uendret | +| GraphQL, annonser | 5 038 B | 1 374 B, gzip | Komprimert | +| GraphQL, områder | 643 B | 591 B, gzip | Komprimert | +| 4 bilder og favicon | 3 624 B | 3 097 B, gzip | Komprimert | +| **Sum** | **164,3 kB** | **160,3 kB** | **−4,0 kB** | + +**Nytt besøk** i katalogen: + +| | Før | Etter | +| ----------------- | ------------------------------------ | ---------------------------------------------- | +| Over nettet | 5,7 kB | 2,2 kB | +| `index.html` | Fra cache, uten å spørre serveren | Sjekket mot serveren, 304 uten innhold (271 B) | +| JS, CSS og bilder | Fra cache, fordi nettleseren gjettet | Fra cache, fordi `Cache-Control` sier det | +| GraphQL | 5,7 kB | 2,0 kB | + +Før var det tilfeldig at det nye besøket var lite. Uten `Cache-Control` +gjetter nettleseren hvor lenge en fil er gyldig ut fra `Last-Modified`. Da kan +den bruke en gammel `index.html` etter en deploy. Den peker på JS-filer som +`rsync --delete` har fjernet, og appen blir hvit til brukeren laster siden på +nytt. + +**Søk:** Å skrive «batteridrill», 12 tegn, gir én forespørsel til GraphQL, +ikke tolv, fordi søket venter til brukeren har sluttet å skrive. + +## Tiltak i innlevering 3 + +**Komprimering av JSON og SVG.** Ubuntus standardoppsett komprimerer HTML, +CSS og JS, men ikke svarene fra GraphQL eller SVG-bildene. Site-konfigurasjonen +legger dem til for `/project2/`. Apollo Client ber om +`application/graphql-response+json`, og Apollo Server svarer da med den typen, +ikke `application/json`. Begge står i listen. GraphQL-svarene blir 73 % mindre, +og SVG-ene omtrent halvparten. + +**Cache-headere.** `mod_headers` setter: + +| Filer | `Cache-Control` | Hvorfor | +| ------------------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `index.html`, også for SPA-ruter | `no-cache` | Sjekkes hver gang, så en ny deploy tas i bruk med en gang | +| JS og CSS med hash, `index-.js` | `public, max-age=31536000, immutable` | Nytt innhold gir nytt navn, så de kan caches i et år | +| SVG-bilder og favicon | `public, max-age=604800` | Ingen hash i navnet, så en uke og deretter sjekk med ETag | + +**ETag som virker med komprimering.** `mod_deflate` legger `-gzip` på +ETag-en. Nettleseren sender den tilbake, men Apache kjenner den ikke igjen og +svarer 200 med hele filen i stedet for 304. `DeflateAlterETag NoChange` retter +det. Direktivet gjelder hele serveren, også prosjekt 1, der virkningen er den +samme. + +**Mørkt tema.** Fargene i `src/styles/tokens.css` får mørke verdier under +`prefers-color-scheme: dark`, og `color-scheme: light dark` gjør at +nettleserens egne kontroller følger med. På OLED-skjermer, som de fleste nyere +telefoner har, bruker mørke piksler mindre strøm. Alle fargeparene oppfyller +WCAG AA, se [design.md](design.md#mørkt-tema), og axe fant ingen brudd i mørkt +tema. + +**Ingen layout-forskyvning.** Bunnteksten stod nederst i vinduet mens en side +lastet, og hoppet ned når innholdet kom (CLS 0,069 i katalogen og 0,086 i +detaljvisningen på mobil). `main` er nå minst like høy som vinduet, og CLS er 0 +på alle sidene. + +## Det som var på plass fra før + +- **Bare feltene som vises.** Katalogen henter `id`, `title`, `category`, + `area`, `dailyPrice`, `imageUrl`, `averageScore` og `reviewCount`. Beskrivelse, + tilbehør og vurderinger hentes først i detaljvisningen. +- **24 annonser per side**, og søk, filtre og sortering gjøres i databasen. + Klienten får aldri hele katalogen. +- **Indekser** gjør at databasen ikke leser hele tabellen for hvert søk, se + [datasett.md](datasett.md#indekser-for-søk-og-filtre). +- **Debouncing** på søket, se over. +- **Apollo-cachen** viser en side eller annonse som er hentet før, uten ny + forespørsel, for eksempel med «Tilbake». +- **Små bilder.** Illustrasjonene er SVG på 0,6–0,9 kB. Mange annonser deler + samme bilde, så en side med 24 kort laster bare 4 bildefiler. +- **Bilder ved behov.** Bildene i katalogen har `loading="lazy"`, og + `width`, `height` og `aspect-ratio`, så plassen er satt av før de lastes. +- **Systemfont.** Ingen nettfonter å laste ned. +- **Ingen sporing**, analyseverktøy eller tredjepartsskript. + +## JS-bunten + +Bunten er 493 kB, eller 152 kB med gzip. Fordelt på pakker etter minifisering: + +| Pakke | Størrelse | Andel | +| ---------------- | --------- | ----- | +| `react-dom` | 202 kB | 42 % | +| `@apollo/client` | 112 kB | 23 % | +| `graphql` | 40 kB | 8 % | +| `react-router` | 37 kB | 8 % | +| Appens kode | 34 kB | 7 % | +| `rxjs` | 24 kB | 5 % | +| Resten | 28 kB | 6 % | + +Appens egen kode er bare 7 %. Resten er bibliotekene vi har valgt, og de er +allerede bygget for produksjon med tree-shaking. Etter første besøk ligger +bunten i cachen i et år, så den lastes bare på nytt når appen endres. + +## Vurdert, men ikke gjort + +- **Kodesplitting per side.** Vi prøvde å laste detaljvisningen og + utleieskjemaet først når de trengs. Første besøk i katalogen ble 3,5 kB JS og + 0,7 kB CSS mindre med gzip, men detaljvisningen fikk tre ekstra + forespørsler første gang den åpnes, og mange tester måtte vente på at siden + lastes. Gevinsten var for liten til å forsvare det. +- **Brotli** komprimerer tekst noe bedre enn gzip. Ubuntu har `mod_brotli`, + men vi kunne ikke teste det lokalt, og gzip gir allerede det meste av + gevinsten. +- **Bytte ut React eller Apollo** med noe mindre, som Preact. Det ville + krympet bunten mest, men er en for stor endring nå. +- **Service worker og CDN** er utenfor rammen av issuet. + +## På VM-en + +Endringene i Apache virker først når konfigurasjonen er kopiert til VM-en og +modulene er slått på: + +```bash +sudo a2enmod headers deflate filter +sudo cp ~/T36-Project-2/deploy/apache/t36-project2.conf /etc/apache2/sites-available/ +sudo apache2ctl configtest && sudo systemctl reload apache2 +``` + +Kommandoene for å kontrollere komprimering, cache-headere og 304 etterpå står +i [deploy/apache/README.md](../deploy/apache/README.md#kontroll-etter-aktivering). diff --git a/docs/design.md b/docs/design.md index 51b78ab..3c9028f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -63,6 +63,52 @@ Plassholdertekst i felt bruker `--color-text-muted` (6,3:1 mot hvitt). Uten den regelen gir Firefox bare 3,5:1, se [tilgjengelighet.md](tilgjengelighet.md#funn-og-rettelser). +### Mørkt tema + +Når systemet er satt til mørkt tema (`prefers-color-scheme: dark`), får de +samme variablene mørke verdier. Komponentene bruker bare variablene, så de +trenger ingen egne regler. `color-scheme: light dark` gjør at nedtrekkslister, +avkrysningsbokser og datofelt også blir mørke. Aksentfargen er lys grønn i +stedet for mørk, og knapper med aksentfarge har mørk tekst. Illustrasjonene +beholder sine lyse bakgrunner, som bilder. + +| Variabel | Mørk verdi | +| ----------------------- | ---------- | +| `--color-bg` | `#151917` | +| `--color-surface` | `#1e2320` | +| `--color-surface-muted` | `#2a302c` | +| `--color-text` | `#edf0ec` | +| `--color-text-muted` | `#b3bab5` | +| `--color-border` | `#3a413c` | +| `--color-border-strong` | `#8b938d` | +| `--color-accent` | `#7fc6a1` | +| `--color-accent-hover` | `#9bd4b6` | +| `--color-on-accent` | `#0f2419` | +| `--color-free-bg` | `#1c3a2a` | +| `--color-free-text` | `#bfe8d0` | +| `--color-error` | `#ff9187` | + +Kontrollerte fargepar i mørkt tema: + +| Forgrunn | Bakgrunn | Kontrast | Krav | +| ---------------------------- | ----------------------- | -------- | ----- | +| `--color-text` | `--color-bg` | 15,4:1 | 4,5:1 | +| `--color-text` | `--color-surface-muted` | 11,7:1 | 4,5:1 | +| `--color-text-muted` | `--color-bg` | 9,0:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface` | 8,1:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface-muted` | 6,8:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent` | 8,2:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent-hover` | 9,7:1 | 4,5:1 | +| `--color-accent` | `--color-bg` | 8,9:1 | 4,5:1 | +| `--color-accent` | `--color-surface` | 8,0:1 | 4,5:1 | +| `--color-free-text` | `--color-free-bg` | 9,3:1 | 4,5:1 | +| `--color-error` | `--color-surface` | 7,3:1 | 4,5:1 | +| `--color-border-strong` | `--color-surface` | 5,1:1 | 3:1 | +| Fokusring (`--color-accent`) | `--color-bg` | 8,9:1 | 3:1 | + +axe-core fant ingen brudd i mørkt tema på noen side, se +[tilgjengelighet.md](tilgjengelighet.md#resultat-med-axe). + ## Typografi | Variabel | Størrelse | Bruk | @@ -446,4 +492,5 @@ usePageTitle('Katalog'); // «Katalog – Verktøyutlån Trondheim» ## Ikke laget ennå -- Mørk modus er ikke planlagt for første innlevering. +- Et valg i appen for lyst eller mørkt tema. Temaet følger systeminnstillingen, + se [Mørkt tema](#mørkt-tema). diff --git a/docs/tilgjengelighet.md b/docs/tilgjengelighet.md index f9b966a..351549b 100644 --- a/docs/tilgjengelighet.md +++ b/docs/tilgjengelighet.md @@ -55,6 +55,11 @@ Ingen brudd på noen av sidene eller tilstandene: | Lei ut verktøy, tomt og med feil | 0 | | «Fant ikke annonsen» og «Fant ikke siden» | 0 | +Det samme gjelder i mørkt tema (`prefers-color-scheme: dark`), som kom i +[issue #64](baerekraft.md#tiltak-i-innlevering-3). Der er katalogen med alle +filtre, katalogen uten treff, detaljvisningen med feil og utleieskjemaet med +feil sjekket på nytt, også kontrasten. + axe kunne ikke avgjøre kontrasten for stjernene i snittscoren, fordi de bare er symboler. Stjernene har `aria-hidden="true"`, og scoren står alltid som tekst ved siden av («4,7 av 5 (3 vurderinger)»). De er pynt, så kravet til