From b35fde983294bf80ba8d9b8e4a036a27fb5acf5d Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 11:55:57 +0200 Subject: [PATCH 1/4] feat: add saved routes api and hook --- client/src/api/routes.test.ts | 40 +++++++++++++++ client/src/api/routes.ts | 87 +++++++++++++++++++++++++++++++++ client/src/hooks/useMyRoutes.ts | 37 ++++++++++++++ client/src/types/route.ts | 12 +++++ 4 files changed, 176 insertions(+) create mode 100644 client/src/api/routes.test.ts create mode 100644 client/src/api/routes.ts create mode 100644 client/src/hooks/useMyRoutes.ts create mode 100644 client/src/types/route.ts diff --git a/client/src/api/routes.test.ts b/client/src/api/routes.test.ts new file mode 100644 index 0000000..e39ec26 --- /dev/null +++ b/client/src/api/routes.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('routes api', () => { + beforeEach(() => { + window.localStorage.clear() + // Rutene ligger i minnet mellom kall, så hver test starter med en ny modul. + vi.resetModules() + }) + + it('starts with example routes and saves new ones first', async () => { + const { fetchMyRoutes, saveRoute } = await import('./routes') + + const initial = await fetchMyRoutes() + expect(initial.length).toBeGreaterThan(0) + + const saved = await saveRoute(' Kveldstur ', ['a', 'b']) + expect(saved).toMatchObject({ name: 'Kveldstur', stopIds: ['a', 'b'] }) + + const routes = await fetchMyRoutes() + expect(routes[0]).toEqual(saved) + expect(routes).toHaveLength(initial.length + 1) + }) + + it('keeps saved routes after a reload', async () => { + const first = await import('./routes') + await first.saveRoute('Kveldstur', ['a', 'b']) + + vi.resetModules() + const second = await import('./routes') + + expect((await second.fetchMyRoutes())[0].name).toBe('Kveldstur') + }) + + it('ignores stored data that is not a list of routes', async () => { + window.localStorage.setItem('tapsailor.routes', '{"broken":true}') + const { fetchMyRoutes } = await import('./routes') + + expect((await fetchMyRoutes()).length).toBeGreaterThan(0) + }) +}) diff --git a/client/src/api/routes.ts b/client/src/api/routes.ts new file mode 100644 index 0000000..20f05db --- /dev/null +++ b/client/src/api/routes.ts @@ -0,0 +1,87 @@ +import type { SavedRoute } from '../types/route' + +/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */ +const LATENCY_MS = 200 + +const STORAGE_KEY = 'tapsailor.routes' + +/** Eksempelruter som vises til brukeren har laget egne. */ +const exampleRoutes: SavedRoute[] = [ + { + id: 'example-elvekveld', + name: 'Elvekveld', + stopIds: ['nidelven-pub', 'bakklandet-taphus', 'brygga-bryggeri'], + }, + { + id: 'example-studentrunden', + name: 'Studentrunden', + stopIds: ['gløshaugen-bar', 'torget-vinbar', 'solsiden-sjøbod'], + }, +] + +const isSavedRoute = (value: unknown): value is SavedRoute => { + if (typeof value !== 'object' || value === null) return false + const { id, name, stopIds } = value as Partial + return ( + typeof id === 'string' && + typeof name === 'string' && + Array.isArray(stopIds) && + stopIds.every((stopId) => typeof stopId === 'string') + ) +} + +/** Rutene i minnet, slik at de også finnes når nettleserlagring ikke virker. */ +let routes: SavedRoute[] | null = null + +function loadRoutes(): SavedRoute[] { + if (routes) return routes + + try { + const stored: unknown = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) ?? 'null', + ) + if (Array.isArray(stored)) routes = stored.filter(isSavedRoute) + } catch { + // Lagringen kan være blokkert eller ødelagt. Da brukes eksemplene. + } + + routes ??= exampleRoutes + return routes +} + +function persistRoutes(next: SavedRoute[]) { + routes = next + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } catch { + // Rutene beholdes i minnet så lenge siden er åpen. + } +} + +/** + * Henter rutene til den innloggede brukeren. + * + * Foreløpig lagres de i nettleseren. Når GraphQL-backenden er klar, byttes + * innholdet i denne og neste funksjon ut med kall mot serveren. + */ +export async function fetchMyRoutes(): Promise { + await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)) + return loadRoutes() +} + +/** Lagrer en ny rute øverst i listen og returnerer den. */ +export async function saveRoute( + name: string, + stopIds: string[], +): Promise { + await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)) + + const route: SavedRoute = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + name: name.trim(), + stopIds, + } + persistRoutes([route, ...loadRoutes()]) + return route +} diff --git a/client/src/hooks/useMyRoutes.ts b/client/src/hooks/useMyRoutes.ts new file mode 100644 index 0000000..2732c6d --- /dev/null +++ b/client/src/hooks/useMyRoutes.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react' +import { fetchMyRoutes, saveRoute } from '../api/routes' +import type { SavedRoute } from '../types/route' + +type Status = 'loading' | 'ready' | 'error' + +/** Henter rutene til brukeren og lar siden legge til nye. */ +export function useMyRoutes() { + const [routes, setRoutes] = useState([]) + const [status, setStatus] = useState('loading') + + useEffect(() => { + let isCurrent = true + + fetchMyRoutes() + .then((result) => { + if (!isCurrent) return + setRoutes(result) + setStatus('ready') + }) + .catch(() => { + if (isCurrent) setStatus('error') + }) + + return () => { + isCurrent = false + } + }, []) + + const addRoute = useCallback(async (name: string, stopIds: string[]) => { + const route = await saveRoute(name, stopIds) + setRoutes((current) => [route, ...current]) + return route + }, []) + + return { routes, status, addRoute } +} diff --git a/client/src/types/route.ts b/client/src/types/route.ts new file mode 100644 index 0000000..bb26877 --- /dev/null +++ b/client/src/types/route.ts @@ -0,0 +1,12 @@ +/** En lagret rute: stedene i den rekkefølgen man besøker dem. */ +export type SavedRoute = { + id: string + name: string + stopIds: string[] +} + +/** Lengste tillatte navn på en rute. */ +export const ROUTE_NAME_MAX_LENGTH = 30 + +/** En rute må ha minst to stopp. */ +export const MIN_ROUTE_STOPS = 2 From 3227ac231fb9cb6c625bf2e08e54bb4e64feda99 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 11:56:11 +0200 Subject: [PATCH 2/4] refactor: share panel inset and place search, add route line and numbered pins to the map --- client/src/components/map/MapViewport.tsx | 70 ++++++++++++++++++++++ client/src/components/map/PlaceDetails.tsx | 13 +++- client/src/components/map/PlacePin.tsx | 14 ++++- client/src/hooks/usePanelInset.ts | 38 ++++++++++++ client/src/pages/MapPage.tsx | 48 +++------------ client/src/utils/placeSearch.ts | 13 ++++ 6 files changed, 150 insertions(+), 46 deletions(-) create mode 100644 client/src/hooks/usePanelInset.ts create mode 100644 client/src/utils/placeSearch.ts diff --git a/client/src/components/map/MapViewport.tsx b/client/src/components/map/MapViewport.tsx index d036d1a..2baf059 100644 --- a/client/src/components/map/MapViewport.tsx +++ b/client/src/components/map/MapViewport.tsx @@ -16,6 +16,12 @@ type MapViewportProps = { selectedId: string | null hoveredId: string | null inset: Inset + /** Stedene i en rute, i rekkefølge. Kartet tegner en linje mellom dem. */ + routeStops?: Place[] + /** Stoppets nummer i ruten, for hvert sted som er med. */ + stopOrder?: ReadonlyMap + /** Når disse endres, zoomer kartet så alle stedene synes. */ + fitPlaces?: Place[] | null onSelect: (id: string) => void } @@ -24,6 +30,7 @@ const BUTTON_ZOOM = 1.5 const DRAG_THRESHOLD = 4 const KEY_PAN = 80 const FLY_MS = 400 +const FIT_PADDING = 64 /** Minste zoom: kartet skal alltid fylle det synlige området. */ const minScale = (size: Size, inset: Inset) => @@ -70,6 +77,9 @@ export function MapViewport({ selectedId, hoveredId, inset, + routeStops, + stopOrder, + fitPlaces, onSelect, }: MapViewportProps) { const sectionRef = useRef(null) @@ -175,6 +185,48 @@ export function MapViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId]) + // Zoomer ut så hele ruten synes, midt i området ruten ikke dekker. + useEffect(() => { + const current = viewRef.current + if (!current || !size || !fitPlaces || fitPlaces.length === 0) return + + const points = fitPlaces.map(({ latitude, longitude }) => + toMapPoint(latitude, longitude), + ) + const xs = points.map(({ x }) => x) + const ys = points.map(({ y }) => y) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + + const min = minScale(size, inset) + const availableWidth = size.width - inset.left - FIT_PADDING * 2 + const availableHeight = size.height - inset.bottom - FIT_PADDING * 2 - 48 + const k = Math.min( + availableWidth / Math.max(maxX - minX, 1), + availableHeight / Math.max(maxY - minY, 1), + min * 3, + ) + // Merkenes spisser er under sentrum; løft kartet litt så hele merkene synes. + const centerX = inset.left + (size.width - inset.left) / 2 + const centerY = (size.height - inset.bottom) / 2 + 24 + + flyTo( + clampView( + { + k, + x: centerX - ((minX + maxX) / 2) * k, + y: centerY - ((minY + maxY) / 2) * k, + }, + size, + inset, + ), + ) + // Skal bare zoome når ruten som vises byttes, ikke når størrelsen endres. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitPlaces]) + const zoomBy = useCallback( (factor: number, px: number, py: number) => { if (!size) return @@ -332,6 +384,17 @@ export function MapViewport({ return [...places].sort((a, b) => rank(a.id) - rank(b.id)) }, [places, selectedId, hoveredId]) + // Linjen mellom stoppene, i skjermpiksler. + const routeLine = + view && routeStops && routeStops.length > 1 + ? routeStops + .map(({ latitude, longitude }) => { + const { x, y } = toMapPoint(latitude, longitude) + return `${view.x + x * view.k},${view.y + y * view.k}` + }) + .join(' ') + : null + return (
+ {routeLine && ( + + )} {orderedPlaces.map((place) => { const point = toMapPoint(place.latitude, place.longitude) return ( @@ -360,6 +429,7 @@ export function MapViewport({ y={view.y + point.y * view.k} isSelected={place.id === selectedId} isHovered={place.id === hoveredId} + order={stopOrder?.get(place.id)} onSelect={() => onSelect(place.id)} /> ) diff --git a/client/src/components/map/PlaceDetails.tsx b/client/src/components/map/PlaceDetails.tsx index d024c82..be4c6e7 100644 --- a/client/src/components/map/PlaceDetails.tsx +++ b/client/src/components/map/PlaceDetails.tsx @@ -6,11 +6,18 @@ import { PlaceImage } from '../PlaceImage' type PlaceDetailsProps = { place: Place now: Date + /** Teksten på pilen. Den er «Tilbake til søk» som standard. */ + backLabel?: string onBack: () => void } /** Alt om ett sted, med en pil som går tilbake til søket. */ -export function PlaceDetails({ place, now, onBack }: PlaceDetailsProps) { +export function PlaceDetails({ + place, + now, + backLabel = 'Tilbake til søk', + onBack, +}: PlaceDetailsProps) { const headingRef = useRef(null) // Flytter fokus hit, så skjermlesere får lest opp stedet som ble valgt. @@ -24,8 +31,8 @@ export function PlaceDetails({ place, now, onBack }: PlaceDetailsProps) {
  • Eller trykk på et sted på kartet.

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

    + {isLoading ? 'Laster steder …' : 'Ingen steder passer søket.'} +

    + ) : ( +
      + {results.map((place) => ( +
    • + +
    • + ))} +
    + )} + + ) : ( +

    + Trykk på et sted på kartet, eller «Neste stopp», for å legge til flere + stopp. +

    + )} + +
    + {isAdding && stops.length > 0 ? ( + + ) : ( + + )} + +
    +
  • + ) +} diff --git a/client/src/components/routes/RouteDetails.tsx b/client/src/components/routes/RouteDetails.tsx new file mode 100644 index 0000000..c324e57 --- /dev/null +++ b/client/src/components/routes/RouteDetails.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' +import type { Place } from '../../types/place' +import type { SavedRoute } from '../../types/route' +import { StopList } from './StopList' + +type RouteDetailsProps = { + route: SavedRoute + stops: Place[] + onBack: () => void + onSelectStop: (id: string) => void +} + +/** Én rute fra start til slutt, med en pil som går tilbake til alle rutene. */ +export function RouteDetails({ + route, + stops, + onBack, + onSelectStop, +}: RouteDetailsProps) { + const headingRef = useRef(null) + + // Flytter fokus hit, så skjermlesere får lest opp ruten som ble valgt. + useEffect(() => { + headingRef.current?.focus() + }, [route.id]) + + return ( +
    +
    + +

    + {route.name} +

    +
    + + +
    + ) +} diff --git a/client/src/components/routes/RouteList.tsx b/client/src/components/routes/RouteList.tsx new file mode 100644 index 0000000..6306778 --- /dev/null +++ b/client/src/components/routes/RouteList.tsx @@ -0,0 +1,57 @@ +import type { Place } from '../../types/place' +import type { SavedRoute } from '../../types/route' + +type RouteListProps = { + routes: SavedRoute[] + placesById: ReadonlyMap + isLoading: boolean + hasError: boolean + onOpen: (id: string) => void +} + +/** Alle rutene til brukeren. Et trykk åpner ruten på kartet. */ +export function RouteList({ + routes, + placesById, + isLoading, + hasError, + onOpen, +}: RouteListProps) { + if (routes.length === 0) { + return ( +

    + {hasError + ? 'Kunne ikke hente rutene dine. Prøv å laste siden på nytt.' + : isLoading + ? 'Laster ruter …' + : 'Du har ingen ruter ennå. Trykk «Lag ny rute» for å lage den første.'} +

    + ) + } + + return ( +
      + {routes.map((route) => { + const first = placesById.get(route.stopIds[0]) + const last = placesById.get(route.stopIds[route.stopIds.length - 1]) + + return ( +
    • + +
    • + ) + })} +
    + ) +} diff --git a/client/src/components/routes/SaveRouteDialog.tsx b/client/src/components/routes/SaveRouteDialog.tsx new file mode 100644 index 0000000..c0a1311 --- /dev/null +++ b/client/src/components/routes/SaveRouteDialog.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import type { Place } from '../../types/place' +import { ROUTE_NAME_MAX_LENGTH } from '../../types/route' +import { ResultDialog } from '../wheel/ResultDialog' +import { StopList } from './StopList' + +type SaveRouteDialogProps = { + isOpen: boolean + stops: Place[] + isSaving: boolean + hasError: boolean + onSave: (name: string) => void + onClose: () => void +} + +type SaveFormProps = Omit + +function SaveForm({ + stops, + isSaving, + hasError, + onSave, + onClose, +}: SaveFormProps) { + const [name, setName] = useState('') + const inputRef = useRef(null) + + // Dialogen åpnes etter at skjemaet er tegnet, så fokus må vente litt. + useEffect(() => { + const frame = requestAnimationFrame(() => inputRef.current?.focus()) + return () => cancelAnimationFrame(frame) + }, []) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + if (name.trim()) onSave(name) + } + + return ( +
    + + + + setName(event.target.value)} + /> + + {name.length}/{ROUTE_NAME_MAX_LENGTH} + + + {hasError && ( +

    + Kunne ikke lagre ruten. Prøv igjen. +

    + )} + +
    + + +
    + + ) +} + +/** Et lite vindu med alle stoppene, der brukeren gir ruten et navn. */ +export function SaveRouteDialog({ isOpen, ...form }: SaveRouteDialogProps) { + return ( + + {isOpen && } + + ) +} diff --git a/client/src/components/routes/StopList.tsx b/client/src/components/routes/StopList.tsx new file mode 100644 index 0000000..4e7fd75 --- /dev/null +++ b/client/src/components/routes/StopList.tsx @@ -0,0 +1,57 @@ +import type { Place } from '../../types/place' + +type StopListProps = { + places: Place[] + label: string + className?: string + onSelect?: (id: string) => void + onRemove?: (id: string) => void +} + +/** Stoppene i en rute, med en stiplet linje ned til neste stopp. */ +export function StopList({ + places, + label, + className, + onSelect, + onRemove, +}: StopListProps) { + return ( +
      + {places.map((place, index) => ( +
    1. + + + {onSelect ? ( + + ) : ( + {place.name} + )} + {onRemove && ( + + )} + +
    2. + ))} +
    + ) +} diff --git a/client/src/hooks/useRoute.ts b/client/src/hooks/useRoute.ts index c57db54..c8a7e78 100644 --- a/client/src/hooks/useRoute.ts +++ b/client/src/hooks/useRoute.ts @@ -1,10 +1,11 @@ import { useSyncExternalStore } from 'react' -export type Route = 'home' | 'map' | 'user' | 'challenges' +export type Route = 'home' | 'map' | 'routes' | 'user' | 'challenges' /** Adressene i nettleseren. Hash-adresser trenger ingen serverkonfigurasjon. */ export const HOME_HREF = '#/' export const MAP_HREF = '#/kart' +export const ROUTES_HREF = '#/ruter' export const USER_HREF = '#/bruker' export const CHALLENGES_HREF = '#/utfordringer' @@ -17,6 +18,8 @@ function getRoute(): Route { switch (window.location.hash) { case MAP_HREF: return 'map' + case ROUTES_HREF: + return 'routes' case USER_HREF: return 'user' case CHALLENGES_HREF: diff --git a/client/src/pages/RoutesPage.css b/client/src/pages/RoutesPage.css new file mode 100644 index 0000000..3733ad8 --- /dev/null +++ b/client/src/pages/RoutesPage.css @@ -0,0 +1,241 @@ +/* ---------- Rutesiden bruker samme kart og rute som kartsiden ---------- */ + +/* Valg mellom egne ruter og ny rute. */ +.route-modes { + display: grid; + flex: none; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin: 0; + padding: 0.75rem; + list-style: none; +} + +.route-modes .map-control { + width: 100%; + padding: 0.5rem; + border-radius: 999px; +} + +.route-modes .map-control[aria-pressed='true'] { + background: var(--navy-900); + color: var(--eggshell); +} + +/* ---------- Ny rute ---------- */ + +.route-builder { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + border-top: 2px solid var(--navy-900); +} + +.route-builder .map-search { + padding-bottom: 0.25rem; +} + +.route-builder .map-results { + border-top-width: 0; +} + +.route-hint { + margin: 0; + padding: 0 0.75rem 0.6rem; + color: var(--muted); + font-size: 0.9rem; +} + +.route-actions { + display: flex; + flex: none; + gap: 0.5rem; + padding: 0.75rem; + border-top: 2px solid var(--navy-900); +} + +.route-actions .button { + flex: 1; +} + +/* ---------- Stopp med stiplet linje mellom ---------- */ + +.stop-list { + display: grid; + justify-items: center; + margin: 0; + padding: 0; + list-style: none; +} + +.route-stops { + flex: 0 1 auto; + min-height: 0; + padding: 0.75rem; + overflow-y: auto; +} + +.map-details .stop-list { + padding-top: 0.25rem; +} + +.stop { + display: grid; + justify-items: center; + max-width: 100%; +} + +.stop + .stop::before { + width: 0; + height: 1.1rem; + border-left: 2px dashed var(--navy-500); + content: ''; +} + +.stop-pill { + display: flex; + align-items: center; + max-width: 100%; + border: 2px solid var(--navy-900); + border-radius: 999px; + background: var(--surface); +} + +.stop-number { + display: grid; + flex: none; + place-items: center; + width: 1.5rem; + height: 1.5rem; + margin-left: 0.4rem; + border-radius: 50%; + background: var(--navy-900); + color: var(--eggshell); + font-size: 0.8rem; + font-weight: 700; +} + +.stop-name { + min-width: 0; + padding: 0.45rem 0.9rem 0.45rem 0.6rem; + border: 0; + border-radius: 999px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + text-align: left; + overflow-wrap: anywhere; +} + +button.stop-name { + cursor: pointer; +} + +button.stop-name:hover, +button.stop-name:focus-visible { + background: var(--blue-100); +} + +.stop-remove { + flex: none; + width: 2rem; + height: 2rem; + margin-right: 0.2rem; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--muted); + font-size: 1.3rem; + line-height: 1; + cursor: pointer; +} + +.stop-remove:hover, +.stop-remove:focus-visible { + background: var(--blue-100); + color: var(--closed); +} + +/* ---------- Linjen og numrene på kartet ---------- */ + +.map-route-casing, +.map-route-line { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +.map-route-casing { + stroke: var(--surface); + stroke-width: 8; +} + +.map-route-line { + stroke: var(--navy-900); + stroke-dasharray: 2 10; + stroke-width: 4; +} + +.map-pin-number { + fill: var(--surface); + font-size: 15px; + font-weight: 700; + pointer-events: none; +} + +.map-pin-selected .map-pin-number { + fill: var(--navy-900); +} + +.map-pin-ordered .map-pin-label { + opacity: 1; +} + +/* ---------- Vinduet for å lagre ---------- */ + +.save-route { + display: grid; + gap: 0.5rem; +} + +.save-route .stop-list { + max-height: 14rem; + margin-bottom: 0.5rem; + overflow-y: auto; +} + +.save-route label { + font-weight: 700; +} + +.save-route input { + width: 100%; + padding: 0.6rem 1rem; + border: 2px solid var(--navy-900); + border-radius: 999px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.save-route-count { + color: var(--muted); + text-align: right; +} + +.save-route-error { + margin: 0; + color: var(--closed); + font-weight: 600; +} + +.save-route-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.5rem; +} diff --git a/client/src/pages/RoutesPage.test.tsx b/client/src/pages/RoutesPage.test.tsx new file mode 100644 index 0000000..1d3c69c --- /dev/null +++ b/client/src/pages/RoutesPage.test.tsx @@ -0,0 +1,145 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makePlace } from '../test/places' +import { RoutesPage } from './RoutesPage' + +const mocks = vi.hoisted(() => ({ + saveRoute: vi.fn(), +})) + +const places = [ + makePlace(), + makePlace({ id: 'gloshaugen-bar', name: 'Gløshaugen Bar', type: 'Bar' }), + makePlace({ id: 'torget-vinbar', name: 'Torget Vinbar', type: 'Vinbar' }), +] + +vi.mock('../api/places', () => ({ + fetchAllPlaces: () => Promise.resolve(places), +})) + +vi.mock('../api/routes', () => ({ + fetchMyRoutes: () => + Promise.resolve([ + { + id: 'r1', + name: 'Elvekveld', + stopIds: ['nidelven-pub', 'torget-vinbar'], + }, + ]), + saveRoute: mocks.saveRoute, +})) + +// Kartet og målingen av ruten trenger nettleserfunksjoner som jsdom mangler. +vi.mock('../components/map/MapViewport', () => ({ + MapViewport: ({ places: pins }: { places: { id: string }[] }) => ( +

    {pins.length} merker på kartet

    + ), +})) + +vi.mock('../hooks/usePanelInset', () => ({ + usePanelInset: () => ({ + panelRef: { current: null }, + inset: { left: 0, bottom: 0 }, + }), +})) + +describe('RoutesPage', () => { + beforeEach(() => { + // jsdom åpner ikke dialoger selv, så innholdet ville vært skjult. + HTMLDialogElement.prototype.showModal = function showModal() { + this.setAttribute('open', '') + } + HTMLDialogElement.prototype.close = function close() { + this.removeAttribute('open') + } + mocks.saveRoute.mockImplementation((name: string, stopIds: string[]) => + Promise.resolve({ id: 'new', name, stopIds }), + ) + }) + + it('shows a route from start to end and goes back to the list', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: /Elvekveld/ })) + + const stops = screen.getByRole('list', { name: 'Stopp i Elvekveld' }) + expect( + within(stops) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual(['1Nidelven Pub', '2Torget Vinbar']) + expect(screen.getByText('2 merker på kartet')).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: 'Tilbake til mine ruter' }), + ) + expect( + screen.getByRole('button', { name: /Elvekveld/ }), + ).toBeInTheDocument() + }) + + it('shows information about a stop and returns to the route', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: /Elvekveld/ })) + await user.click(screen.getByRole('button', { name: 'Torget Vinbar' })) + + expect( + screen.getByRole('heading', { name: 'Torget Vinbar' }), + ).toBeInTheDocument() + expect(screen.getByText('Adresse')).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: 'Tilbake til Elvekveld' }), + ) + expect( + screen.getByRole('list', { name: 'Stopp i Elvekveld' }), + ).toBeInTheDocument() + }) + + it('builds a route stop by stop and saves it with a name', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Lag ny rute' })) + + const save = screen.getByRole('button', { name: 'Lagre rute' }) + expect(save).toBeDisabled() + + await user.type(screen.getByRole('searchbox'), 'nidelven') + await user.click(screen.getByRole('button', { name: /Nidelven Pub/ })) + expect(save).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'Neste stopp' })) + await user.click(screen.getByRole('button', { name: /Torget Vinbar/ })) + expect(save).toBeEnabled() + + await user.click(save) + await user.type(screen.getByLabelText('Navn på ruten'), 'Kveldstur') + await user.click(screen.getByRole('button', { name: 'Lagre' })) + + expect(mocks.saveRoute).toHaveBeenCalledWith('Kveldstur', [ + 'nidelven-pub', + 'torget-vinbar', + ]) + expect( + await screen.findByRole('button', { name: /Kveldstur/ }), + ).toBeInTheDocument() + }) + + it('lets the user remove a stop before saving', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Lag ny rute' })) + await user.click(screen.getByRole('button', { name: /Nidelven Pub/ })) + await user.click(screen.getByRole('button', { name: 'Fjern Nidelven Pub' })) + + expect( + screen.queryByRole('list', { name: 'Stopp i den nye ruten' }), + ).not.toBeInTheDocument() + }) +}) diff --git a/client/src/pages/RoutesPage.tsx b/client/src/pages/RoutesPage.tsx new file mode 100644 index 0000000..4115626 --- /dev/null +++ b/client/src/pages/RoutesPage.tsx @@ -0,0 +1,227 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { fetchAllPlaces } from '../api/places' +import { PlaceDetails } from '../components/map/PlaceDetails' +import { MapViewport } from '../components/map/MapViewport' +import { RouteBuilder } from '../components/routes/RouteBuilder' +import { RouteDetails } from '../components/routes/RouteDetails' +import { RouteList } from '../components/routes/RouteList' +import { SaveRouteDialog } from '../components/routes/SaveRouteDialog' +import { useFetch } from '../hooks/useFetch' +import { useMyRoutes } from '../hooks/useMyRoutes' +import { useNow } from '../hooks/useNow' +import { usePanelInset } from '../hooks/usePanelInset' +import type { Place } from '../types/place' +import './MapPage.css' +import './RoutesPage.css' + +type Mode = 'mine' | 'new' + +const modes: { mode: Mode; label: string }[] = [ + { mode: 'mine', label: 'Mine ruter' }, + { mode: 'new', label: 'Lag ny rute' }, +] + +const NO_PLACES: Place[] = [] + +export function RoutesPage() { + const now = useNow() + const { data, status } = useFetch(fetchAllPlaces) + const places = useMemo(() => data ?? NO_PLACES, [data]) + const placesById = useMemo( + () => new Map(places.map((place) => [place.id, place])), + [places], + ) + const { routes, status: routesStatus, addRoute } = useMyRoutes() + const { panelRef, inset } = usePanelInset() + + const [mode, setMode] = useState('mine') + const [openRouteId, setOpenRouteId] = useState(null) + const [focusedId, setFocusedId] = useState(null) + + const [draftIds, setDraftIds] = useState([]) + const [isAdding, setIsAdding] = useState(true) + const [query, setQuery] = useState('') + const [isSaveOpen, setIsSaveOpen] = useState(false) + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'error'>( + 'idle', + ) + + const lastOpenRouteId = useRef(null) + + // Setter fokus tilbake på ruten i listen når man går tilbake fra detaljene. + useEffect(() => { + if (openRouteId === null && lastOpenRouteId.current) { + document + .getElementById(`route-result-${lastOpenRouteId.current}`) + ?.focus() + } + lastOpenRouteId.current = openRouteId + }, [openRouteId]) + + const openRoute = routes.find(({ id }) => id === openRouteId) ?? null + const openStops = useMemo( + () => + openRoute + ? openRoute.stopIds.flatMap((id) => placesById.get(id) ?? []) + : NO_PLACES, + [openRoute, placesById], + ) + const draftStops = useMemo( + () => draftIds.flatMap((id) => placesById.get(id) ?? []), + [draftIds, placesById], + ) + + const isCreating = mode === 'new' + // Et stopp man har trykket på i en åpen rute vises med informasjon i boksen. + const focusedStop = + !isCreating && openRoute + ? (openStops.find(({ id }) => id === focusedId) ?? null) + : null + const routeStops = isCreating ? draftStops : openStops + const stopOrder = useMemo( + () => new Map(routeStops.map((place, index) => [place.id, index + 1])), + [routeStops], + ) + + const addStop = (id: string) => { + if (draftIds.includes(id)) return + setDraftIds((current) => [...current, id]) + setQuery('') + setIsAdding(false) + } + + const removeStop = (id: string) => { + setDraftIds((current) => current.filter((stopId) => stopId !== id)) + } + + const changeMode = (next: Mode) => { + setMode(next) + setOpenRouteId(null) + setFocusedId(null) + } + + const handleSave = async (name: string) => { + setSaveStatus('saving') + + try { + await addRoute(name, draftIds) + } catch { + setSaveStatus('error') + return + } + + setSaveStatus('idle') + setIsSaveOpen(false) + setDraftIds([]) + setIsAdding(true) + setQuery('') + changeMode('mine') + } + + return ( +
    + + + {status === 'error' && ( +

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

    + )} + + + + setIsSaveOpen(false)} + /> +
    + ) +} From ec74afcf6ace5d0aab3985305383b48de6106428 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 13 Oct 2026 14:44:21 +0200 Subject: [PATCH 4/4] fix: show place details for stops in the route draft Related to #18 --- client/src/pages/RoutesPage.test.tsx | 24 ++++++++++++++++++++++++ client/src/pages/RoutesPage.tsx | 22 ++++++++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/client/src/pages/RoutesPage.test.tsx b/client/src/pages/RoutesPage.test.tsx index 1d3c69c..9d8f3d2 100644 --- a/client/src/pages/RoutesPage.test.tsx +++ b/client/src/pages/RoutesPage.test.tsx @@ -130,6 +130,30 @@ describe('RoutesPage', () => { ).toBeInTheDocument() }) + it('shows information about a draft stop and returns to the draft', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Lag ny rute' })) + await user.click(screen.getByRole('button', { name: /Nidelven Pub/ })) + await user.click(screen.getByRole('button', { name: 'Nidelven Pub' })) + + expect( + screen.getByRole('heading', { name: 'Nidelven Pub' }), + ).toBeInTheDocument() + expect(screen.getByText('Adresse')).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: 'Tilbake til utkastet' }), + ) + expect( + screen.getByRole('list', { name: 'Stopp i den nye ruten' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Neste stopp' }), + ).toBeInTheDocument() + }) + it('lets the user remove a stop before saving', async () => { const user = userEvent.setup() render() diff --git a/client/src/pages/RoutesPage.tsx b/client/src/pages/RoutesPage.tsx index 4115626..2c2de67 100644 --- a/client/src/pages/RoutesPage.tsx +++ b/client/src/pages/RoutesPage.tsx @@ -72,12 +72,10 @@ export function RoutesPage() { ) const isCreating = mode === 'new' - // Et stopp man har trykket på i en åpen rute vises med informasjon i boksen. - const focusedStop = - !isCreating && openRoute - ? (openStops.find(({ id }) => id === focusedId) ?? null) - : null const routeStops = isCreating ? draftStops : openStops + // Et stopp man har trykket på, i en åpen rute eller i utkastet, vises med + // informasjon i boksen. + const focusedStop = routeStops.find(({ id }) => id === focusedId) ?? null const stopOrder = useMemo( () => new Map(routeStops.map((place, index) => [place.id, index + 1])), [routeStops], @@ -86,6 +84,7 @@ export function RoutesPage() { const addStop = (id: string) => { if (draftIds.includes(id)) return setDraftIds((current) => [...current, id]) + setFocusedId(null) setQuery('') setIsAdding(false) } @@ -125,7 +124,8 @@ export function RoutesPage() { className="map-panel" aria-label="Ruter" onKeyDown={(event) => { - // Escape går tilbake fra stedet til ruten, som pilen øverst i boksen. + // Escape går tilbake fra stedet til ruten eller utkastet, som pilen + // øverst i boksen. if (event.key === 'Escape' && focusedStop) setFocusedId(null) }} > @@ -133,7 +133,11 @@ export function RoutesPage() { setFocusedId(null)} /> ) : openRoute ? ( @@ -205,7 +209,9 @@ export function RoutesPage() {