diff --git a/client/src/App.tsx b/client/src/App.tsx index 1b135d9..188d8f8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,11 +6,13 @@ import { useRoute } from './hooks/useRoute' import { ChallengesPage } from './pages/ChallengesPage' import { HomePage } from './pages/HomePage' import { MapPage } from './pages/MapPage' +import { RoutesPage } from './pages/RoutesPage' import { UserPage } from './pages/UserPage' const pages = { home: HomePage, map: MapPage, + routes: RoutesPage, user: UserPage, challenges: ChallengesPage, } 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/components/MainNav.tsx b/client/src/components/MainNav.tsx index e2acfb4..3fd46ba 100644 --- a/client/src/components/MainNav.tsx +++ b/client/src/components/MainNav.tsx @@ -3,6 +3,7 @@ import { CHALLENGES_HREF, HOME_HREF, MAP_HREF, + ROUTES_HREF, USER_HREF, useRoute, } from '../hooks/useRoute' @@ -27,7 +28,7 @@ function Icon({ children }: { children: ReactNode }) { ) } -/** Lenkene i hovednavigasjonen. «Ruter» har ingen side ennå. */ +/** Lenkene i hovednavigasjonen. */ const links: { label: string icon: ReactNode @@ -48,6 +49,8 @@ const links: { }, { label: 'Ruter', + href: ROUTES_HREF, + route: 'routes', // Ruten mellom to punkter icon: ( diff --git a/client/src/components/PageFooter.tsx b/client/src/components/PageFooter.tsx index a7d32a5..a02681c 100644 --- a/client/src/components/PageFooter.tsx +++ b/client/src/components/PageFooter.tsx @@ -10,6 +10,10 @@ const quotes: Record = { text: 'Man cannot discover new oceans unless he has the courage to lose sight of the shore.', author: 'André Gide', }, + routes: { + text: 'If one does not know to which port one is sailing, no wind is favorable.', + author: 'Seneca', + }, user: { text: 'A sailor is an artist whose medium is the wind.', author: 'Webb Chiles', diff --git a/client/src/components/PageHeader.tsx b/client/src/components/PageHeader.tsx index 07e420f..961a0d6 100644 --- a/client/src/components/PageHeader.tsx +++ b/client/src/components/PageHeader.tsx @@ -26,6 +26,12 @@ const content: Record = { helpTitle: 'Slik bruker du kartet', help: 'Kartet viser utestedene i Trondheim som små skattemerker. Dra i kartet for å flytte deg, og bruk rulling, knappene nederst til høyre eller to fingre for å zoome. Søk etter et sted i feltet til venstre, så vises bare de stedene på kartet. Trykk på et sted i listen eller et merke på kartet for å se informasjon om stedet i ruten. Trykk på pilen øverst i ruten for å gå tilbake til søket. Foreløpig er dette et stilisert prøvekart, ikke et ekte kart.', }, + routes: { + title: 'Ruter', + subtitle: 'Planlegg kvelden stopp for stopp', + helpTitle: 'Slik bruker du rutene', + help: 'Her samler du stedene du vil innom, i den rekkefølgen du vil besøke dem. Under «Mine ruter» ser du rutene du har laget. Trykk på en rute for å se den på kartet, og trykk på pilen øverst i ruten for å gå tilbake til alle rutene. Under «Lag ny rute» søker du opp stedet du vil starte på, eller trykker på et merke på kartet. Trykk «Neste stopp» for å legge til flere stopp, og «Lagre rute» når du er fornøyd. Da får du gi ruten et navn, og den dukker opp under «Mine ruter».', + }, user: { title: 'Profil', subtitle: 'Din logg over kvelder til sjøs', diff --git a/client/src/components/__snapshots__/SiteHeader.test.tsx.snap b/client/src/components/__snapshots__/SiteHeader.test.tsx.snap index c9bbca5..0948434 100644 --- a/client/src/components/__snapshots__/SiteHeader.test.tsx.snap +++ b/client/src/components/__snapshots__/SiteHeader.test.tsx.snap @@ -125,7 +125,7 @@ exports[`SiteHeader > matches the shared header structure 1`] = `
  • 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/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/hooks/usePanelInset.ts b/client/src/hooks/usePanelInset.ts new file mode 100644 index 0000000..31afca1 --- /dev/null +++ b/client/src/hooks/usePanelInset.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react' +import type { Inset } from '../components/map/MapViewport' + +/** Måler ruten ved siden av kartet, så kartet vet hvor mye av det som er dekket. */ +export function usePanelInset() { + const panelRef = useRef(null) + const [inset, setInset] = useState({ left: 0, bottom: 0 }) + + useEffect(() => { + const panel = panelRef.current + if (!panel) return + + const update = () => { + const rect = panel.getBoundingClientRect() + const isDesktop = window.matchMedia('(min-width: 56rem)').matches + const next = isDesktop + ? { left: rect.right, bottom: 0 } + : { left: 0, bottom: window.innerHeight - rect.top } + + setInset((previous) => + previous.left === next.left && previous.bottom === next.bottom + ? previous + : next, + ) + } + update() + + const observer = new ResizeObserver(update) + observer.observe(panel) + window.addEventListener('resize', update) + return () => { + observer.disconnect() + window.removeEventListener('resize', update) + } + }, []) + + return { panelRef, inset } +} 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/MapPage.tsx b/client/src/pages/MapPage.tsx index 6366a14..d15d5bd 100644 --- a/client/src/pages/MapPage.tsx +++ b/client/src/pages/MapPage.tsx @@ -2,17 +2,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { fetchAllPlaces } from '../api/places' import { MapPanel } from '../components/map/MapPanel' import { MapViewport } from '../components/map/MapViewport' -import type { Inset } from '../components/map/MapViewport' import { useFetch } from '../hooks/useFetch' import { useNow } from '../hooks/useNow' +import { usePanelInset } from '../hooks/usePanelInset' import type { Place } from '../types/place' +import { matchesQuery } from '../utils/placeSearch' import './MapPage.css' -const matches = (place: Place, query: string) => - [place.name, place.type, place.area, ...place.tags].some((text) => - text.toLocaleLowerCase('nb-NO').includes(query), - ) - export function MapPage() { const now = useNow() // Kartet viser alle steder. Når backenden er klar, byttes dette ut med en @@ -22,40 +18,10 @@ export function MapPage() { const [query, setQuery] = useState('') const [selectedId, setSelectedId] = useState(null) const [hoveredId, setHoveredId] = useState(null) - const [inset, setInset] = useState({ left: 0, bottom: 0 }) - const panelRef = useRef(null) + const { panelRef, inset } = usePanelInset() const lastSelectedId = useRef(null) - // Måler ruten, så kartet vet hvor mye av det som er dekket. - useEffect(() => { - const panel = panelRef.current - if (!panel) return - - const update = () => { - const rect = panel.getBoundingClientRect() - const isDesktop = window.matchMedia('(min-width: 56rem)').matches - const next = isDesktop - ? { left: rect.right, bottom: 0 } - : { left: 0, bottom: window.innerHeight - rect.top } - - setInset((previous) => - previous.left === next.left && previous.bottom === next.bottom - ? previous - : next, - ) - } - update() - - const observer = new ResizeObserver(update) - observer.observe(panel) - window.addEventListener('resize', update) - return () => { - observer.disconnect() - window.removeEventListener('resize', update) - } - }, []) - // Setter fokus tilbake på stedet i listen når man går tilbake fra detaljene. useEffect(() => { if (selectedId === null && lastSelectedId.current) { @@ -64,10 +30,10 @@ export function MapPage() { lastSelectedId.current = selectedId }, [selectedId]) - const results = useMemo(() => { - const needle = query.trim().toLocaleLowerCase('nb-NO') - return needle ? places.filter((place) => matches(place, needle)) : places - }, [places, query]) + const results = useMemo( + () => places.filter((place) => matchesQuery(place, query)), + [places, query], + ) const selected = places.find(({ id }) => id === selectedId) ?? null 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..9d8f3d2 --- /dev/null +++ b/client/src/pages/RoutesPage.test.tsx @@ -0,0 +1,169 @@ +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('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() + + 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..2c2de67 --- /dev/null +++ b/client/src/pages/RoutesPage.tsx @@ -0,0 +1,233 @@ +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' + 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], + ) + + const addStop = (id: string) => { + if (draftIds.includes(id)) return + setDraftIds((current) => [...current, id]) + setFocusedId(null) + 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)} + /> +
    + ) +} 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 diff --git a/client/src/utils/placeSearch.ts b/client/src/utils/placeSearch.ts new file mode 100644 index 0000000..a8eba42 --- /dev/null +++ b/client/src/utils/placeSearch.ts @@ -0,0 +1,13 @@ +import type { Place } from '../types/place' + +/** Sant når navn, type, område eller en egenskap inneholder søketeksten. */ +export const matchesQuery = (place: Place, query: string) => { + 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), + ) + ) +}