From 284b675048207e2c35a9970e8f1c7b96776cc426 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 13:50:05 +0200 Subject: [PATCH 1/3] fix: fill the wheel with equal segments and simplify the pointer --- client/src/components/wheel/SpinWheel.tsx | 21 ++++++++------- client/src/pages/ChallengesPage.css | 9 ++++--- client/src/utils/wheel.test.ts | 20 ++++++++++++++ client/src/utils/wheel.ts | 32 +++++++---------------- 4 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 client/src/utils/wheel.test.ts diff --git a/client/src/components/wheel/SpinWheel.tsx b/client/src/components/wheel/SpinWheel.tsx index b3fed9f..a1487d8 100644 --- a/client/src/components/wheel/SpinWheel.tsx +++ b/client/src/components/wheel/SpinWheel.tsx @@ -11,7 +11,6 @@ import { layoutSegments, nextRotation, segmentPath, - SPOKE_COUNT, truncate, } from '../../utils/wheel' import { ResultDialog } from './ResultDialog' @@ -41,9 +40,11 @@ type SpinWheelProps = { const SPIN_MS = 4500 const COLOR_COUNT = 4 -/** Pinne med håndtak, tegnet rett opp fra midten av roret. */ -const SPOKE_PATH = - 'M-2.6 -14V-106L-3.4 -112Q-6.2 -120 -4.8 -128Q0 -135 4.8 -128Q6.2 -120 3.4 -112L2.6 -106V-14Z' +/** Håndtak som stikker ut fra kanten, tegnet rett opp fra midten av roret. */ +const HANDLE_PATH = + 'M-2.6 -106L-3.4 -112Q-6.2 -120 -4.8 -128Q0 -135 4.8 -128Q6.2 -120 3.4 -112L2.6 -106Z' +/** Antall håndtak rundt roret. */ +const HANDLE_COUNT = 8 const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches @@ -207,18 +208,20 @@ export function SpinWheel({ })} - {Array.from({ length: SPOKE_COUNT }, (_, spoke) => ( + {Array.from({ length: HANDLE_COUNT }, (_, handle) => ( - + ))} - + { + it.each([1, 2, 5, 8, 13, 26, 40])( + 'makes %i equal segments that fill the whole wheel', + (count) => { + const segments = layoutSegments(count) + const sizes = segments.map(({ start, end }) => end - start) + + expect(segments).toHaveLength(count) + sizes.forEach((size) => expect(size).toBeCloseTo(sizes[0])) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(360, 1) + + segments.slice(1).forEach(({ start }, index) => { + expect(start).toBeCloseTo(segments[index].end) + }) + }, + ) +}) diff --git a/client/src/utils/wheel.ts b/client/src/utils/wheel.ts index 007b3ce..200a98e 100644 --- a/client/src/utils/wheel.ts +++ b/client/src/utils/wheel.ts @@ -2,11 +2,6 @@ export const WHEEL_RADIUS = 100 /** Feltene starter utenfor navet. */ export const INNER_RADIUS = 24 -/** Antall pinner på roret. Feltene deles likt mellom dem. */ -export const SPOKE_COUNT = 8 -/** Hvor mange grader på hver side av en pinne som holdes fri for felt. */ -const SPOKE_CLEARANCE = 4 - export type Segment = { start: number; end: number; mid: number } const round = (value: number) => Number(value.toFixed(3)) @@ -21,27 +16,18 @@ function pointAt(angle: number, radius: number) { } /** - * Plasserer feltene mellom pinnene. Pinne `k` står på `k * 45` grader, og - * feltene fordeles jevnt over rommene mellom dem uten å krysse en pinne. + * Plasserer feltene side om side rundt hele hjulet. Alle felt er like store, + * uansett hvor mange det er. */ export function layoutSegments(count: number): Segment[] { - const sector = 360 / SPOKE_COUNT - const segments: Segment[] = [] - - for (let room = 0; room < SPOKE_COUNT; room++) { - const first = Math.floor((count * room) / SPOKE_COUNT) - const last = Math.floor((count * (room + 1)) / SPOKE_COUNT) - const inRoom = last - first - const from = room * sector + SPOKE_CLEARANCE - const step = (sector - 2 * SPOKE_CLEARANCE) / Math.max(inRoom, 1) - - for (let i = 0; i < inRoom; i++) { - const start = from + i * step - segments.push({ start, end: start + step, mid: start + step / 2 }) - } - } + // Ett enkelt felt kan ikke dekke hele sirkelen, for da faller start og slutt + // sammen og buen forsvinner. + const step = Math.min(360 / Math.max(count, 1), 359.99) - return segments + return Array.from({ length: count }, (_, index) => { + const start = index * step + return { start, end: start + step, mid: start + step / 2 } + }) } /** Tegner ett felt som en ringbit mellom navet og kanten. */ From 4564782758934a4b2b54e4a4127d2b88c342ecf9 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 13:50:12 +0200 Subject: [PATCH 2/3] feat: add search and multi-select filters to the home page Also slows down the wave animation of the site header and keeps the home page layout stable. --- client/src/App.css | 111 +++++++++++++++++++++++++- client/src/components/PlaceFilter.tsx | 93 +++++++++++++++++++++ client/src/index.css | 2 + client/src/pages/HomePage.test.tsx | 66 ++++++++++++++- client/src/pages/HomePage.tsx | 92 ++++++++++++++++++--- client/src/utils/placeSearch.ts | 13 +++ 6 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 client/src/components/PlaceFilter.tsx create mode 100644 client/src/utils/placeSearch.ts diff --git a/client/src/App.css b/client/src/App.css index 8cafc3d..1aab49e 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -28,6 +28,20 @@ background: var(--header-end); mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 8'%3E%3Cpath d='M0 4q12-8 24 0t24 0 24 0 24 0V0H0Z'/%3E%3C/svg%3E") repeat-x top / 6rem 0.5rem; + animation: wave-drift 24s linear infinite; +} + +/* Bølgen glir sidelengs. Den ene bølgelengden er 6rem, så løkken er sømløs. */ +@keyframes wave-drift { + to { + mask-position: 6rem top; + } +} + +@media (prefers-reduced-motion: reduce) { + .site-header::after { + animation: none; + } } /* Sidemargen holder tittelen unna hjørneknappene. */ @@ -310,11 +324,106 @@ .page { display: grid; gap: 2rem; - max-width: 72rem; + /* Fast bredde, ellers krymper siden etter innholdet og boksene flytter seg. */ + width: 100%; + max-width: 54rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +/* Søk og filter ligger øverst, over begge kolonnene. */ +.place-filter, +.no-results { + grid-column: 1 / -1; +} + +.place-filter { + /* Trekker innholdet litt opp mot raden, som alltid har plass til filtrene. */ + margin-bottom: -0.75rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.place-filter input { + flex: 1 1 14rem; + min-width: 0; + padding: 0.6rem 1rem; + border: 2px solid var(--navy-900); + border-radius: 999px; + background: var(--surface); + color: var(--text); + font: inherit; + transition: box-shadow 0.15s; +} + +.place-filter input:hover { + box-shadow: 0 0 0 3px var(--blue-300); +} + +.place-filter select { + padding: 0.5rem 0.8rem; + border: 2px solid var(--navy-900); + border-radius: 0.6rem; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.filter-chips { + display: flex; + flex: 0 0 100%; + gap: 0.5rem; + align-items: center; + height: 3rem; + margin: 0; + padding: 0.25rem; + overflow-x: auto; + list-style: none; + scrollbar-width: thin; +} + +.filter-chips li { + flex: none; +} + +.filter-hint { + padding-left: 0.5rem; + color: var(--muted); +} + +.place-filter .button:disabled { + cursor: default; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.8rem; + border: 2px solid var(--navy-700); + border-radius: 999px; + background: var(--blue-100); + color: var(--navy-900); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.filter-chip:hover { + background: var(--navy-900); + color: var(--eggshell); +} + +.no-results { + margin: 0; + color: var(--muted); +} + @media (min-width: 56rem) { .page { grid-template-columns: minmax(0, 26rem) minmax(0, 1fr); diff --git a/client/src/components/PlaceFilter.tsx b/client/src/components/PlaceFilter.tsx new file mode 100644 index 0000000..534d996 --- /dev/null +++ b/client/src/components/PlaceFilter.tsx @@ -0,0 +1,93 @@ +type PlaceFilterProps = { + query: string + /** Filtrene som er valgt, i den rekkefølgen de ble lagt til. */ + filters: string[] + /** Filtrene som kan legges til. */ + options: string[] + onQueryChange: (query: string) => void + onAddFilter: (filter: string) => void + onRemoveFilter: (filter: string) => void + onReset: () => void +} + +/** Søk og filter øverst på hovedsiden. Valgte filter vises som merkelapper. */ +export function PlaceFilter({ + query, + filters, + options, + onQueryChange, + onAddFilter, + onRemoveFilter, + onReset, +}: PlaceFilterProps) { + const isActive = query !== '' || filters.length > 0 + + return ( + + + onQueryChange(event.target.value)} + /> + + {options.length > 0 && ( + <> + + {/* Valget legges til som filter, så menyen står alltid på overskriften. */} + + + )} + + + + {/* Raden har fast høyde også uten filter, så resten av siden ligger i ro. */} +
    + {filters.length === 0 && ( +
  • Ingen filter valgt
  • + )} + {filters.map((filter) => ( +
  • + +
  • + ))} +
+
+ ) +} diff --git a/client/src/index.css b/client/src/index.css index 7e68479..c30e991 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -28,6 +28,8 @@ line-height: 1.5; color: var(--text); background: var(--eggshell); + /* Plass til rullefeltet er alltid reservert, så siden ikke hopper sidelengs. */ + scrollbar-gutter: stable; } * { diff --git a/client/src/pages/HomePage.test.tsx b/client/src/pages/HomePage.test.tsx index 4f2967b..b5a838a 100644 --- a/client/src/pages/HomePage.test.tsx +++ b/client/src/pages/HomePage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { makePlace } from '../test/places' @@ -19,6 +19,22 @@ const places = [ }), ] +vi.mock('../api/places', () => ({ + fetchAllPlaces: () => Promise.resolve(places), +})) + +vi.mock('../hooks/useCurrentUser', () => ({ + useCurrentUser: () => ({ + user: { + id: 'u1', + name: 'Test', + avatarUrl: null, + favouritePlaces: [places[1]], + }, + status: 'ready', + }), +})) + vi.mock('../hooks/useNow', () => ({ useNow: () => new Date(2026, 9, 10, 20, 0), })) @@ -58,6 +74,54 @@ describe('HomePage', () => { ).toBeInTheDocument() }) + it('searches and filters all places and can be reset', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const user = userEvent.setup() + + render() + + await user.type( + screen.getByRole('searchbox', { name: 'Søk etter steder' }), + 'gløshaugen', + ) + expect( + await screen.findByRole('heading', { name: 'Gløshaugen Bar' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Vis mer' }), + ).not.toBeInTheDocument() + + await user.clear(screen.getByRole('searchbox')) + await user.selectOptions(screen.getByLabelText('Legg til filter'), 'Pub') + expect( + await screen.findByRole('heading', { name: 'Nidelven Pub' }), + ).toBeInTheDocument() + + await user.selectOptions(screen.getByLabelText('Legg til filter'), 'Bar') + const chips = screen.getByRole('list', { name: 'Valgte filter' }) + expect(within(chips).getAllByRole('listitem')).toHaveLength(2) + + await user.selectOptions( + screen.getByLabelText('Legg til filter'), + 'Favoritter', + ) + expect( + await screen.findByRole('heading', { name: 'Gløshaugen Bar' }), + ).toBeInTheDocument() + expect( + screen.queryAllByRole('button', { name: /Nidelven Pub/ }), + ).toHaveLength(0) + + await user.click(screen.getByRole('button', { name: 'Fjern filteret Bar' })) + expect( + screen.queryByRole('heading', { name: 'Gløshaugen Bar' }), + ).not.toBeInTheDocument() + expect(await screen.findByText('Ingen steder passer søket.')).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Nullstill' })) + expect(screen.getByRole('button', { name: 'Vis mer' })).toBeInTheDocument() + }) + it('forwards the list expansion actions', async () => { vi.spyOn(Math, 'random').mockReturnValue(0) const user = userEvent.setup() diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index b4da6ae..e6a2407 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,9 +1,17 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { fetchAllPlaces } from '../api/places' import { FeaturedPlace } from '../components/FeaturedPlace' +import { PlaceFilter } from '../components/PlaceFilter' import { PlaceList } from '../components/PlaceList' +import { useCurrentUser } from '../hooks/useCurrentUser' +import { useFetch } from '../hooks/useFetch' import { useNow } from '../hooks/useNow' import { PAGE_SIZE, usePlaces } from '../hooks/usePlaces' import type { Place } from '../types/place' +import { matchesQuery } from '../utils/placeSearch' + +/** Filteret som bare viser brukerens favorittsteder. */ +const FAVOURITES = 'Favoritter' export function HomePage() { const now = useNow() @@ -23,28 +31,86 @@ export function HomePage() { // fra listen. const [selected, setSelected] = useState(null) - const featured: Place | undefined = - selected ?? - places[Math.floor(startFraction * Math.min(places.length, PAGE_SIZE))] + const [query, setQuery] = useState('') + const [filters, setFilters] = useState([]) + const { user } = useCurrentUser() + + // Søk og filter gjelder alle stedene, ikke bare de som er lastet i listen. + const { data: allPlaces, status: allStatus } = useFetch(fetchAllPlaces) + const types = useMemo( + () => [...new Set((allPlaces ?? []).map((place) => place.type))], + [allPlaces], + ) + + // Filter av ulik sort kombineres: stedet må passe med alle sortene, men bare + // en av typene (Pub og Vinbar gir steder som er det ene eller det andre). + const options = useMemo( + () => [FAVOURITES, ...types].filter((option) => !filters.includes(option)), + [types, filters], + ) + + const isFiltering = query.trim() !== '' || filters.length > 0 + const results = useMemo(() => { + const chosenTypes = filters.filter((filter) => filter !== FAVOURITES) + const favouriteIds = filters.includes(FAVOURITES) + ? new Set((user?.favouritePlaces ?? []).map(({ id }) => id)) + : null + + return (allPlaces ?? []).filter( + (place) => + (chosenTypes.length === 0 || chosenTypes.includes(place.type)) && + (!favouriteIds || favouriteIds.has(place.id)) && + matchesQuery(place, query), + ) + }, [allPlaces, filters, query, user]) + + // Under søk og filter bladr man gjennom treffene i stedet for listen. + const shown = isFiltering ? results : places + const featured: Place | undefined = isFiltering + ? (results.find(({ id }) => id === selected?.id) ?? results[0]) + : (selected ?? + places[Math.floor(startFraction * Math.min(places.length, PAGE_SIZE))]) /** Blar til forrige (-1) eller neste (1) sted, og starter om i endene. */ const step = (direction: -1 | 1) => { if (!featured) return - const index = places.findIndex((place) => place.id === featured.id) + const index = shown.findIndex((place) => place.id === featured.id) // Er stedet skjult av «Vis mindre», starter vi i enden av listen. const next = index === -1 ? direction === 1 ? 0 - : places.length - 1 - : (index + direction + places.length) % places.length + : shown.length - 1 + : (index + direction + shown.length) % shown.length - setSelected(places[next]) + setSelected(shown[next]) } return (
+ setFilters((current) => [...current, filter])} + onRemoveFilter={(filter) => + setFilters((current) => current.filter((item) => item !== filter)) + } + onReset={() => { + setQuery('') + setFilters([]) + }} + /> + + {isFiltering && !featured && ( +

+ {allStatus === 'loading' + ? 'Laster steder …' + : 'Ingen steder passer søket.'} +

+ )} {status === 'loading' &&

Laster steder …

} {status === 'error' && (

Kunne ikke hente steder. Prøv å laste siden på nytt.

@@ -59,13 +125,13 @@ export function HomePage() { onNext={() => step(1)} /> { + const needle = query.trim().toLocaleLowerCase('nb-NO') + + return ( + needle === '' || + [place.name, place.type, place.area, ...place.tags].some((text) => + text.toLocaleLowerCase('nb-NO').includes(needle), + ) + ) +} From d894b2e9da716efdd6afc7ac2dcb0560146624d3 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 14:50:59 +0200 Subject: [PATCH 3/3] fix: show the number of matches when searching or filtering Related to #19 --- client/src/components/PlaceList.tsx | 6 +++++- client/src/pages/HomePage.test.tsx | 1 + client/src/pages/HomePage.tsx | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/components/PlaceList.tsx b/client/src/components/PlaceList.tsx index 4c1b3e3..9c58762 100644 --- a/client/src/components/PlaceList.tsx +++ b/client/src/components/PlaceList.tsx @@ -8,6 +8,7 @@ type PlaceListProps = { featuredId: string loadedCount: number totalCount: number + isFiltered: boolean hasMore: boolean canShowLess: boolean isLoadingMore: boolean @@ -23,6 +24,7 @@ export function PlaceList({ featuredId, loadedCount, totalCount, + isFiltered, hasMore, canShowLess, isLoadingMore, @@ -63,7 +65,9 @@ export function PlaceList({

- Viser {loadedCount} av {totalCount} steder + {isFiltered + ? `Viser ${loadedCount} treff` + : `Viser ${loadedCount} av ${totalCount} steder`}