diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md index 0721827..18df2c9 100644 --- a/SELF-HOSTING.md +++ b/SELF-HOSTING.md @@ -35,6 +35,9 @@ Edit it: - `cities`: preferred display order for the city filter — a hint, not an exhaustive list (any city present in your data but missing here still shows, just sorted alphabetically after) - `places`: swap the sample imports for your own `data/places/*.json` files +- `competitions`: swap the `data/competitions/*.json` imports for your own, or set it to `[]` if + you don't want a competitions catalog (the competitions pages then show an empty list). The + record shape is in [`data/competitions/CONTRIBUTING.md`](data/competitions/CONTRIBUTING.md) The dataset itself follows the schema in [`data/CONTRIBUTING.md`](data/CONTRIBUTING.md): one JSON file per place type, one object per place, with `id`, `name`, `type`, `city`, `lat`, `lng`, diff --git a/src/components/docs/docs-index-grid.tsx b/src/components/docs/docs-index-grid.tsx index c1792a4..310911c 100644 --- a/src/components/docs/docs-index-grid.tsx +++ b/src/components/docs/docs-index-grid.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { docsPages, DOCS_GROUPS } from "@/lib/docs-nav"; +import { groupDocsByTopic } from "@/lib/docs-nav"; import { getDocMeta } from "@/lib/docs-meta"; import { Badge } from "@/components/ui/badge"; @@ -16,9 +16,7 @@ import { Badge } from "@/components/ui/badge"; export function DocsIndexGrid() { return (
- {DOCS_GROUPS.map((group) => { - const entries = docsPages.filter((entry) => entry.group === group); - if (entries.length === 0) return null; + {groupDocsByTopic().map(({ group, entries }) => { return (

{group}

diff --git a/src/components/docs/docs-index-list.tsx b/src/components/docs/docs-index-list.tsx index 2e7b04d..cf4fb8e 100644 --- a/src/components/docs/docs-index-list.tsx +++ b/src/components/docs/docs-index-list.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { docsPages, DOCS_GROUPS } from "@/lib/docs-nav"; +import { groupDocsByTopic } from "@/lib/docs-nav"; import { getDocMeta } from "@/lib/docs-meta"; import { Badge } from "@/components/ui/badge"; @@ -13,9 +13,7 @@ import { Badge } from "@/components/ui/badge"; export function DocsIndexList() { return (
- {DOCS_GROUPS.map((group) => { - const entries = docsPages.filter((entry) => entry.group === group); - if (entries.length === 0) return null; + {groupDocsByTopic().map(({ group, entries }) => { return (

{group}

diff --git a/src/components/layout/site-search.test.tsx b/src/components/layout/site-search.test.tsx new file mode 100644 index 0000000..96185b3 --- /dev/null +++ b/src/components/layout/site-search.test.tsx @@ -0,0 +1,223 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SiteSearchDialog } from "@/components/layout/site-search"; +import type { Competition, Place } from "@/lib/types"; + +const { push, places, competitions, docs } = vi.hoisted(() => { + const place = (n: number, extra: Record = {}) => ({ + id: `mum-library-0${n}`, + name: `Reading Room ${n}`, + type: "library", + city: "mumbai", + lat: 19.0, + lng: 72.8, + gmaps_link: "https://maps.google.com/?q=19,72.8", + added_by: "test", + ...extra, + }); + return { + push: vi.fn(), + // Seven matches for "reading", so the five-result cap and "See all" kick in. + places: [ + ...Array.from({ length: 7 }, (_, i) => place(i + 1)), + place(8, { + id: "pune-airport-01", + name: "Pune Airport", + type: "airport", + city: "pune", + }), + ], + competitions: [ + { + id: "reading-olympiad", + name: "Reading Olympiad", + organizer: "Literacy Foundation", + category: "humanities", + subjects: ["literature"], + }, + { + id: "math-cup", + name: "Math Cup", + organizer: "Maths Society", + category: "mathematics", + subjects: ["algebra"], + }, + ], + docs: [ + { + href: "/docs/faq", + title: "FAQ", + description: "Answers to common questions about reading the map.", + }, + ], + }; +}); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) })); +vi.mock("@/lib/places", () => ({ getPlaces: () => places as unknown as Place[] })); +vi.mock("@/lib/competitions", () => ({ + getCompetitions: () => competitions as unknown as Competition[], +})); +vi.mock("@/lib/docs-nav", () => ({ docsPages: docs })); + +beforeEach(() => push.mockReset()); +afterEach(cleanup); + +function open(onOpenChange = vi.fn()) { + render(); + return { + onOpenChange, + input: screen.getByRole("searchbox", { name: "Search StudyMap" }), + }; +} + +const type = (input: HTMLElement, value: string) => + fireEvent.change(input, { target: { value } }); + +describe("SiteSearchDialog", () => { + it("renders nothing when closed", () => { + render(); + expect(screen.queryByRole("searchbox")).toBeNull(); + }); + + it("focuses the search box and shows a hint before anything is typed", () => { + const { input } = open(); + expect(document.activeElement).toBe(input); + expect(screen.getByText("Search places, competitions, and docs.")).toBeTruthy(); + }); + + it("groups matches under Places, Competitions and Docs", () => { + const { input } = open(); + type(input, "reading"); + for (const heading of ["Places", "Competitions", "Docs"]) { + expect(screen.getByText(heading)).toBeTruthy(); + } + expect(screen.getByText("Reading Olympiad")).toBeTruthy(); + expect(screen.getByText("Literacy Foundation")).toBeTruthy(); + expect(screen.getByText("FAQ")).toBeTruthy(); + // A place shows its city and type under its name. + expect(screen.getAllByText("Mumbai · Library").length).toBeGreaterThan(0); + }); + + it("leaves out a group that has no matches", () => { + const { input } = open(); + type(input, "algebra"); + expect(screen.getByText("Competitions")).toBeTruthy(); + expect(screen.queryByText("Places")).toBeNull(); + expect(screen.queryByText("Docs")).toBeNull(); + }); + + it("says when nothing matches, quoting the query without its padding", () => { + const { input } = open(); + type(input, " zzzzz "); + expect(screen.getByText(/No results for/).textContent).toBe("No results for “zzzzz”"); + }); + + it("caps a group at five results and offers a See all link for places and competitions", () => { + const { input } = open(); + type(input, "reading"); + const links = screen.getAllByRole("link"); + // 5 places + 1 competition + 1 doc, plus the See all link for places. + expect( + links.filter((link) => link.getAttribute("href")?.startsWith("/map?place=")), + ).toHaveLength(5); + const seeAll = screen.getByRole("link", { name: "See all 7 results" }); + expect(seeAll.getAttribute("href")).toBe("/map?q=reading"); + }); + + it("encodes the query in the See all link", () => { + const { input } = open(); + type(input, "reading room"); + expect( + screen.getByRole("link", { name: "See all 7 results" }).getAttribute("href"), + ).toBe("/map?q=reading%20room"); + }); + + it("opens the matching place, competition and doc pages", () => { + const { input } = open(); + type(input, "reading"); + expect(screen.getByText("Reading Olympiad").closest("a")?.getAttribute("href")).toBe( + "/competitions/reading-olympiad", + ); + expect(screen.getByText("FAQ").closest("a")?.getAttribute("href")).toBe("/docs/faq"); + expect(screen.getByText("Reading Room 1").closest("a")?.getAttribute("href")).toBe( + "/map?place=mum-library-01", + ); + }); + + it("closes when a result is clicked", () => { + const { input, onOpenChange } = open(); + type(input, "reading"); + fireEvent.click(screen.getByText("Reading Olympiad")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + describe("keyboard", () => { + const activeRow = () => + screen + .getAllByRole("link") + .filter((link) => link.classList.contains("bg-muted")) + .map((link) => within(link).getAllByText(/./)[0]?.textContent); + + it("starts on the first result and moves with the arrow keys, wrapping at both ends", () => { + const { input } = open(); + type(input, "reading"); + expect(activeRow()).toEqual(["Reading Room 1"]); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(activeRow()).toEqual(["Reading Room 2"]); + fireEvent.keyDown(input, { key: "ArrowUp" }); + fireEvent.keyDown(input, { key: "ArrowUp" }); + // Up from the first row wraps to the last: the doc. + expect(activeRow()).toEqual(["FAQ"]); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(activeRow()).toEqual(["Reading Room 1"]); + }); + + it("Enter opens the active result and closes the dialog", () => { + const { input, onOpenChange } = open(); + type(input, "reading"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(push).toHaveBeenCalledWith("/map?place=mum-library-02"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("Enter does nothing with no results, and the arrow keys don't throw", () => { + const { input, onOpenChange } = open(); + type(input, "zzzzz"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(push).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("changing the query puts the highlight back on the first result", () => { + const { input } = open(); + type(input, "reading"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + type(input, "reading r"); + expect(activeRow()).toEqual(["Reading Room 1"]); + }); + + it("hovering a row makes it the active one", () => { + const { input } = open(); + type(input, "reading"); + fireEvent.mouseEnter( + screen.getByText("Reading Room 3").closest("a") as HTMLElement, + ); + expect(activeRow()).toEqual(["Reading Room 3"]); + }); + }); + + it("starts empty each time it is reopened", () => { + const { rerender } = render(); + type(screen.getByRole("searchbox"), "reading"); + expect(screen.getByText("Reading Olympiad")).toBeTruthy(); + rerender(); + rerender(); + expect((screen.getByRole("searchbox") as HTMLInputElement).value).toBe(""); + expect(screen.queryByText("Reading Olympiad")).toBeNull(); + }); +}); diff --git a/src/components/map/my-places-panel.test.tsx b/src/components/map/my-places-panel.test.tsx new file mode 100644 index 0000000..4beff4a --- /dev/null +++ b/src/components/map/my-places-panel.test.tsx @@ -0,0 +1,204 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MyPlacesPanel } from "@/components/map/my-places-panel"; +import type { UserHome, UserPlaceRow } from "@/lib/user-places"; + +afterEach(cleanup); + +function row(overrides: Partial = {}): UserPlaceRow { + return { + id: "p1", + user_id: "u1", + name: "Quiet Cafe", + type: "other_places", + city: "mumbai", + lat: 19.07, + lng: 72.87, + address: null, + note: null, + created_at: "2026-09-01T00:00:00Z", + ...overrides, + }; +} + +const home: UserHome = { + user_id: "u1", + lat: 19.1, + lng: 72.9, + label: "My flat", + updated_at: "2026-09-01T00:00:00Z", +}; + +function setup(props: Partial> = {}) { + const handlers = { + onQueryChange: vi.fn(), + onCityChange: vi.fn(), + onAddPlace: vi.fn(), + onEditPlace: vi.fn(), + onSelectPlace: vi.fn(), + onDeletePlace: vi.fn(), + onLocateHome: vi.fn(), + onEditHome: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("MyPlacesPanel", () => { + it("explains that saved places are private and lets you add one", () => { + const { onAddPlace } = setup(); + expect(screen.getByText(/Private to you/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + expect(onAddPlace).toHaveBeenCalledTimes(1); + }); + + describe("home location", () => { + it("offers to set one when there is none", () => { + const { onEditHome } = setup({ home: null }); + fireEvent.click(screen.getByRole("button", { name: "Set home location" })); + expect(onEditHome).toHaveBeenCalledTimes(1); + }); + + it("shows the saved one with shortcuts to find the nearest place and to edit it", () => { + const { onLocateHome, onEditHome } = setup({ home }); + expect(screen.getByText("My flat")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Nearest to home" })); + fireEvent.click(screen.getByRole("button", { name: "Edit home location" })); + expect(onLocateHome).toHaveBeenCalledTimes(1); + expect(onEditHome).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Set home location" })).toBeNull(); + }); + }); + + describe("list states", () => { + it("shows an empty state, with no search or city filter, when nothing is saved", () => { + setup({ savedPlaces: [] }); + expect(screen.getByText("No saved places yet.")).toBeTruthy(); + expect(screen.queryByRole("searchbox")).toBeNull(); + }); + + it("shows the error in place of the list", () => { + setup({ error: "Couldn't load your places." }); + expect(screen.getByText("Couldn't load your places.")).toBeTruthy(); + expect(screen.queryByText("Quiet Cafe")).toBeNull(); + expect(screen.queryByText("No saved places yet.")).toBeNull(); + }); + + it("lists each place with its type, city, address and note", () => { + setup({ + savedPlaces: [ + row({ address: "12 Hill Road", note: "Open late" }), + row({ id: "p2", name: "Study Nook", type: "library", city: "thane" }), + ], + }); + const first = screen.getByText("Quiet Cafe").closest("button") as HTMLElement; + expect(within(first).getByText("Other places")).toBeTruthy(); + expect(within(first).getByText("Mumbai")).toBeTruthy(); + expect(within(first).getByText("12 Hill Road")).toBeTruthy(); + expect(within(first).getByText("Open late")).toBeTruthy(); + const second = screen.getByText("Study Nook").closest("button") as HTMLElement; + expect(within(second).getByText("Library")).toBeTruthy(); + expect(within(second).getByText("Thane")).toBeTruthy(); + }); + + it("selecting a place calls onSelectPlace with it", () => { + const { onSelectPlace } = setup(); + fireEvent.click(screen.getByText("Study Nook")); + expect(onSelectPlace).toHaveBeenCalledWith(expect.objectContaining({ id: "p2" })); + }); + }); + + describe("search and city filter", () => { + it("reports typing to onQueryChange", () => { + const { onQueryChange } = setup(); + fireEvent.change( + screen.getByRole("searchbox", { name: "Search your saved places" }), + { + target: { value: "nook" }, + }, + ); + expect(onQueryChange).toHaveBeenCalledWith("nook"); + }); + + it("filters by name, ignoring case and padding", () => { + setup({ query: " NOOK " }); + expect(screen.getByText("Study Nook")).toBeTruthy(); + expect(screen.queryByText("Quiet Cafe")).toBeNull(); + }); + + it("filters by city", () => { + setup({ city: "thane" }); + expect(screen.getByText("Study Nook")).toBeTruthy(); + expect(screen.queryByText("Quiet Cafe")).toBeNull(); + }); + + it("combines the name and city filters", () => { + setup({ query: "quiet", city: "thane" }); + expect(screen.getByText("No saved places match this search.")).toBeTruthy(); + }); + + it("says when the search matches nothing, and still offers the search box", () => { + setup({ query: "zzz" }); + expect(screen.getByText("No saved places match this search.")).toBeTruthy(); + expect(screen.getByRole("searchbox")).toBeTruthy(); + }); + + it("hides the city filter when there is only one city", () => { + setup({ cities: ["mumbai"] }); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("shows the city filter when there are several cities", () => { + setup(); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + }); + + describe("edit and delete", () => { + it("editing a place calls onEditPlace without selecting it", () => { + const { onEditPlace, onSelectPlace } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Edit Quiet Cafe" })); + expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + expect(onSelectPlace).not.toHaveBeenCalled(); + }); + + it("asks for confirmation before deleting, and Cancel backs out", () => { + const { onDeletePlace, onSelectPlace } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Delete Quiet Cafe" })); + expect(onDeletePlace).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onDeletePlace).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Delete Quiet Cafe" })).toBeTruthy(); + expect(onSelectPlace).not.toHaveBeenCalled(); + }); + + it("confirming deletes that place only", () => { + const { onDeletePlace } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Delete Study Nook" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(onDeletePlace).toHaveBeenCalledTimes(1); + expect(onDeletePlace).toHaveBeenCalledWith("p2"); + // The other place's controls are untouched. + expect(screen.getByRole("button", { name: "Delete Quiet Cafe" })).toBeTruthy(); + }); + + it("has no delete button when deleting isn't wired up", () => { + setup({ onDeletePlace: undefined }); + expect(screen.queryByRole("button", { name: /^Delete / })).toBeNull(); + expect(screen.getByRole("button", { name: "Edit Quiet Cafe" })).toBeTruthy(); + }); + }); +}); diff --git a/src/components/map/near-me-button.tsx b/src/components/map/near-me-button.tsx index f9aae7a..186fa9d 100644 --- a/src/components/map/near-me-button.tsx +++ b/src/components/map/near-me-button.tsx @@ -1,9 +1,9 @@ "use client"; -import * as React from "react"; import { LocateFixed } from "lucide-react"; import type { LatLng } from "@/lib/geo"; +import { useNearMe } from "@/components/map/use-near-me"; import { Button } from "@/components/ui/button"; interface NearMeButtonProps { @@ -12,34 +12,7 @@ interface NearMeButtonProps { } export function NearMeButton({ onLocated, className }: NearMeButtonProps) { - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); - - function locate() { - if (!navigator.geolocation) { - setError("Geolocation is not available in this browser."); - return; - } - setBusy(true); - setError(null); - navigator.geolocation.getCurrentPosition( - (pos) => { - setBusy(false); - onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude }); - }, - (err) => { - setBusy(false); - setError( - err.code === err.PERMISSION_DENIED - ? "Allow location access in your browser settings." - : err.code === err.TIMEOUT - ? "Location request timed out. Try again." - : "Your location could not be determined. Try again.", - ); - }, - { enableHighAccuracy: true, timeout: 10000 }, - ); - } + const { busy, error, locate } = useNearMe(onLocated); return (
diff --git a/src/components/map/near-me-fab.tsx b/src/components/map/near-me-fab.tsx index 3dc9969..76cb4d2 100644 --- a/src/components/map/near-me-fab.tsx +++ b/src/components/map/near-me-fab.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { LocateFixed, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { useNearMe } from "@/components/map/use-near-me"; import type { LatLng } from "@/lib/geo"; import { cn } from "@/lib/utils"; @@ -14,37 +15,12 @@ interface NearMeFabProps { /** Round floating "near me" button for the mobile map, sized for thumb reach. */ export function NearMeFab({ onLocated, className }: NearMeFabProps) { - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); + const { busy, error, locate } = useNearMe(onLocated); - function locate() { - if (!navigator.geolocation) { - const message = "Geolocation is not available in this browser."; - setError(message); - toast.error(message); - return; - } - setBusy(true); - setError(null); - navigator.geolocation.getCurrentPosition( - (pos) => { - setBusy(false); - onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude }); - }, - (err) => { - setBusy(false); - const message = - err.code === err.PERMISSION_DENIED - ? "Allow location access in your browser settings." - : err.code === err.TIMEOUT - ? "Location request timed out. Try again." - : "Your location could not be determined. Try again."; - setError(message); - toast.error(message); - }, - { enableHighAccuracy: true, timeout: 10000 }, - ); - } + // The floating button has no room for an inline message, so it also toasts. + React.useEffect(() => { + if (error) toast.error(error); + }, [error]); return ( <> diff --git a/src/components/map/use-near-me.test.tsx b/src/components/map/use-near-me.test.tsx new file mode 100644 index 0000000..9d3c508 --- /dev/null +++ b/src/components/map/use-near-me.test.tsx @@ -0,0 +1,198 @@ +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { NearMeButton } from "@/components/map/near-me-button"; +import { NearMeFab } from "@/components/map/near-me-fab"; +import { + GEOLOCATION_UNAVAILABLE_MESSAGE, + geolocationErrorMessage, + useNearMe, +} from "@/components/map/use-near-me"; + +const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: toastError } })); + +type Success = (position: GeolocationPosition) => void; +type Failure = (error: GeolocationPositionError) => void; + +/** A stand-in for navigator.geolocation that lets each test answer the request. */ +function mockGeolocation() { + const calls: { success: Success; failure: Failure; options?: PositionOptions }[] = []; + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { + getCurrentPosition: ( + success: Success, + failure: Failure, + options?: PositionOptions, + ) => calls.push({ success, failure, options }), + }, + }); + return calls; +} + +const position = (lat: number, lng: number) => + ({ coords: { latitude: lat, longitude: lng } }) as GeolocationPosition; + +const positionError = (code: number) => + ({ + code, + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + }) as GeolocationPositionError; + +beforeEach(() => toastError.mockReset()); + +afterEach(() => { + cleanup(); + Reflect.deleteProperty(navigator, "geolocation"); +}); + +describe("geolocationErrorMessage", () => { + it("tells people to allow access when permission is denied", () => { + expect(geolocationErrorMessage(positionError(1))).toBe( + "Allow location access in your browser settings.", + ); + }); + + it("says when the request timed out", () => { + expect(geolocationErrorMessage(positionError(3))).toBe( + "Location request timed out. Try again.", + ); + }); + + it("falls back to a general message for anything else", () => { + expect(geolocationErrorMessage(positionError(2))).toBe( + "Your location could not be determined. Try again.", + ); + expect(geolocationErrorMessage(positionError(99))).toBe( + "Your location could not be determined. Try again.", + ); + }); +}); + +describe("useNearMe", () => { + it("starts idle", () => { + mockGeolocation(); + const { result } = renderHook(() => useNearMe(vi.fn())); + expect(result.current.busy).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("asks for a high-accuracy position with a 10 second timeout and is busy until it answers", () => { + const calls = mockGeolocation(); + const { result } = renderHook(() => useNearMe(vi.fn())); + act(() => result.current.locate()); + expect(calls).toHaveLength(1); + expect(calls[0]!.options).toEqual({ enableHighAccuracy: true, timeout: 10000 }); + expect(result.current.busy).toBe(true); + }); + + it("passes the position on as lat and lng and stops being busy", () => { + const calls = mockGeolocation(); + const onLocated = vi.fn(); + const { result } = renderHook(() => useNearMe(onLocated)); + act(() => result.current.locate()); + act(() => calls[0]!.success(position(19.07, 72.87))); + expect(onLocated).toHaveBeenCalledWith({ lat: 19.07, lng: 72.87 }); + expect(result.current.busy).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("turns a failure into a message and stops being busy", () => { + const calls = mockGeolocation(); + const onLocated = vi.fn(); + const { result } = renderHook(() => useNearMe(onLocated)); + act(() => result.current.locate()); + act(() => calls[0]!.failure(positionError(1))); + expect(onLocated).not.toHaveBeenCalled(); + expect(result.current.busy).toBe(false); + expect(result.current.error).toBe("Allow location access in your browser settings."); + }); + + it("clears the old error when it tries again", () => { + const calls = mockGeolocation(); + const { result } = renderHook(() => useNearMe(vi.fn())); + act(() => result.current.locate()); + act(() => calls[0]!.failure(positionError(3))); + expect(result.current.error).toBe("Location request timed out. Try again."); + act(() => result.current.locate()); + expect(result.current.error).toBeNull(); + expect(result.current.busy).toBe(true); + }); + + it("reports a browser with no geolocation without asking for anything", () => { + const { result } = renderHook(() => useNearMe(vi.fn())); + act(() => result.current.locate()); + expect(result.current.error).toBe(GEOLOCATION_UNAVAILABLE_MESSAGE); + expect(result.current.busy).toBe(false); + }); +}); + +describe("the two Near me triggers share the hook", () => { + it("NearMeButton shows Locating..., then the message inline", () => { + const calls = mockGeolocation(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Near me" })); + expect(screen.getByRole("button", { name: "Locating..." })).toHaveProperty( + "disabled", + true, + ); + act(() => calls[0]!.failure(positionError(3))); + expect(screen.getByRole("alert").textContent).toBe( + "Location request timed out. Try again.", + ); + expect(screen.getByRole("button", { name: "Near me" })).toHaveProperty( + "disabled", + false, + ); + }); + + it("NearMeFab announces the message to screen readers and toasts it", () => { + const calls = mockGeolocation(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Find places near me" })); + act(() => calls[0]!.failure(positionError(1))); + const message = "Allow location access in your browser settings."; + expect(screen.getByRole("alert").textContent).toBe(message); + expect(screen.getByRole("alert").className).toContain("sr-only"); + expect(toastError).toHaveBeenCalledWith(message); + }); + + it("NearMeFab toasts again when the same error happens a second time", () => { + const calls = mockGeolocation(); + render(); + const button = screen.getByRole("button", { name: "Find places near me" }); + fireEvent.click(button); + act(() => calls[0]!.failure(positionError(3))); + fireEvent.click(button); + act(() => calls[1]!.failure(positionError(3))); + expect(toastError).toHaveBeenCalledTimes(2); + }); + + it("both pass the located position to their callback", () => { + const calls = mockGeolocation(); + const first = vi.fn(); + const second = vi.fn(); + render( + <> + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Near me" })); + fireEvent.click(screen.getByRole("button", { name: "Find places near me" })); + act(() => calls[0]!.success(position(1, 2))); + act(() => calls[1]!.success(position(3, 4))); + expect(first).toHaveBeenCalledWith({ lat: 1, lng: 2 }); + expect(second).toHaveBeenCalledWith({ lat: 3, lng: 4 }); + }); +}); diff --git a/src/components/map/use-near-me.ts b/src/components/map/use-near-me.ts new file mode 100644 index 0000000..9dfdadf --- /dev/null +++ b/src/components/map/use-near-me.ts @@ -0,0 +1,56 @@ +"use client"; + +import * as React from "react"; + +import type { LatLng } from "@/lib/geo"; + +/** The message shown when the browser has no Geolocation API at all. */ +export const GEOLOCATION_UNAVAILABLE_MESSAGE = + "Geolocation is not available in this browser."; + +/** Maps a `GeolocationPositionError` to the message shown to the visitor. */ +export function geolocationErrorMessage( + error: Pick, +): string { + if (error.code === error.PERMISSION_DENIED) { + return "Allow location access in your browser settings."; + } + if (error.code === error.TIMEOUT) { + return "Location request timed out. Try again."; + } + return "Your location could not be determined. Try again."; +} + +/** + * The geolocation logic behind both "Near me" triggers (the desktop button and + * the mobile floating button): asks the browser for the position once, tracks + * `busy` while it waits, and turns a failure into a readable `error`. A + * component calls `locate()` from its own click handler and decides how to show + * `busy` and `error`. + */ +export function useNearMe(onLocated: (location: LatLng) => void) { + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const locate = React.useCallback(() => { + if (!navigator.geolocation) { + setError(GEOLOCATION_UNAVAILABLE_MESSAGE); + return; + } + setBusy(true); + setError(null); + navigator.geolocation.getCurrentPosition( + (pos) => { + setBusy(false); + onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude }); + }, + (err) => { + setBusy(false); + setError(geolocationErrorMessage(err)); + }, + { enableHighAccuracy: true, timeout: 10000 }, + ); + }, [onLocated]); + + return { busy, error, locate }; +} diff --git a/src/lib/competitions-config.test.ts b/src/lib/competitions-config.test.ts new file mode 100644 index 0000000..b95238e --- /dev/null +++ b/src/lib/competitions-config.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getCategories, getCompetitions } from "@/lib/competitions"; +import studyMapConfig from "../../studymap.config"; +import example from "../../studymap.config.example"; +import type { Competition } from "@/lib/types"; + +const custom = vi.hoisted(() => ({ competitions: [] as unknown[] })); + +vi.mock("../../studymap.config", async (importOriginal) => { + const original = await importOriginal(); + return { + default: { + ...original.default, + get competitions() { + return custom.competitions.length + ? custom.competitions + : original.default.competitions; + }, + }, + }; +}); + +function competition(id: string, category: Competition["category"]): Competition { + return { + id, + name: id, + organizer: "Test Org", + organizer_url: "https://example.com", + category, + subjects: [], + description: "A test competition.", + format: "online", + age_min: 13, + age_max: 18, + participation: "individual", + region: "international", + fee: { amount: 0, currency: "USD" }, + prize: "A prize", + official_url: "https://example.com", + cycle_year: 2026, + dates: [], + added_by: "test", + }; +} + +describe("competitions come from studymap.config.ts", () => { + it("getCompetitions() returns the config's competitions, as getPlaces() returns its places", () => { + expect(getCompetitions()).toBe(studyMapConfig.competitions); + expect(getCompetitions().length).toBeGreaterThan(0); + }); + + it("a fork that sets its own competitions gets exactly those", () => { + custom.competitions = [competition("only-one", "coding")]; + try { + expect(getCompetitions().map((item) => item.id)).toEqual(["only-one"]); + expect(getCategories()).toEqual(["coding"]); + } finally { + custom.competitions = []; + } + }); + + it("the example config has an empty competitions list, so it type-checks as a StudyMapConfig", () => { + expect(example.competitions).toEqual([]); + }); +}); diff --git a/src/lib/competitions.ts b/src/lib/competitions.ts index a281578..59f1506 100644 --- a/src/lib/competitions.ts +++ b/src/lib/competitions.ts @@ -33,43 +33,11 @@ export function formatCompetitionDate(iso: string): string { return `${MONTH_NAMES[month - 1]} ${day}, ${year}`; } -import artsDesign from "../../data/competitions/arts_design.json"; -import business from "../../data/competitions/business.json"; -import coding from "../../data/competitions/coding.json"; -import creativeWriting from "../../data/competitions/creative_writing.json"; -import debateMun from "../../data/competitions/debate_mun.json"; -import essayWriting from "../../data/competitions/essay_writing.json"; -import filmVideo from "../../data/competitions/film_video.json"; -import finance from "../../data/competitions/finance.json"; -import history from "../../data/competitions/history.json"; -import humanities from "../../data/competitions/humanities.json"; -import interdisciplinary from "../../data/competitions/interdisciplinary.json"; -import mathematics from "../../data/competitions/mathematics.json"; -import research from "../../data/competitions/research.json"; -import scholarship from "../../data/competitions/scholarship.json"; -import stem from "../../data/competitions/stem.json"; +import studyMapConfig from "../../studymap.config"; -const ALL_COMPETITIONS: Competition[] = [ - ...(artsDesign as Competition[]), - ...(business as Competition[]), - ...(coding as Competition[]), - ...(creativeWriting as Competition[]), - ...(debateMun as Competition[]), - ...(essayWriting as Competition[]), - ...(filmVideo as Competition[]), - ...(finance as Competition[]), - ...(history as Competition[]), - ...(humanities as Competition[]), - ...(interdisciplinary as Competition[]), - ...(mathematics as Competition[]), - ...(research as Competition[]), - ...(scholarship as Competition[]), - ...(stem as Competition[]), -]; - -/** Every competition record, sourced from `data/competitions/.json`. */ +/** Every competition record, sourced from `studymap.config.ts` (by default `data/competitions/.json`). */ export function getCompetitions(): Competition[] { - return ALL_COMPETITIONS; + return studyMapConfig.competitions; } export interface CompetitionFilters { diff --git a/src/lib/docs-nav.test.ts b/src/lib/docs-nav.test.ts new file mode 100644 index 0000000..10c090a --- /dev/null +++ b/src/lib/docs-nav.test.ts @@ -0,0 +1,64 @@ +import { FileText } from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { + DOCS_GROUPS, + docsPages, + groupDocsByTopic, + type DocsNavEntry, +} from "@/lib/docs-nav"; + +const entry = (href: string, group?: DocsNavEntry["group"]): DocsNavEntry => ({ + href, + title: href, + description: "", + icon: FileText, + group, +}); + +describe("groupDocsByTopic", () => { + it("returns the groups in DOCS_GROUPS order, each with its own pages", () => { + const groups = groupDocsByTopic(); + expect(groups.map(({ group }) => group)).toEqual( + DOCS_GROUPS.filter((group) => docsPages.some((page) => page.group === group)), + ); + for (const { group, entries } of groups) { + expect(entries.length).toBeGreaterThan(0); + expect(entries.every((page) => page.group === group)).toBe(true); + } + }); + + it("places every docs page in exactly one group", () => { + const grouped = groupDocsByTopic().flatMap(({ entries }) => entries); + expect(grouped).toHaveLength(docsPages.length); + expect(new Set(grouped.map((page) => page.href)).size).toBe(docsPages.length); + }); + + it("leaves a group out when it has no pages", () => { + const groups = groupDocsByTopic([ + entry("/docs/a", "Guides"), + entry("/docs/b", "Developers"), + ]); + expect(groups.map(({ group }) => group)).toEqual(["Guides", "Developers"]); + }); + + it("keeps the order of pages within a group", () => { + const groups = groupDocsByTopic([ + entry("/docs/second", "Guides"), + entry("/docs/other", "Developers"), + entry("/docs/first", "Guides"), + ]); + expect(groups[0]!.entries.map((page) => page.href)).toEqual([ + "/docs/second", + "/docs/first", + ]); + }); + + it("drops a page that has no group", () => { + expect(groupDocsByTopic([entry("/docs/loose")])).toEqual([]); + }); + + it("returns nothing for no pages", () => { + expect(groupDocsByTopic([])).toEqual([]); + }); +}); diff --git a/src/lib/docs-nav.ts b/src/lib/docs-nav.ts index 428da86..bbbbbcb 100644 --- a/src/lib/docs-nav.ts +++ b/src/lib/docs-nav.ts @@ -233,6 +233,20 @@ export const docsNav: DocsNavEntry[] = [ /** Same entries, index page excluded — the order sidebar links, groups, and the pager all walk. */ export const docsPages = docsNav.filter((entry) => entry.href !== "/docs"); +/** + * The docs pages grouped by topic, in `DOCS_GROUPS` order, leaving out any + * group with no pages. Both /docs index views (grid and list) render from + * this, so they always agree on which groups show and in what order. + */ +export function groupDocsByTopic( + pages: DocsNavEntry[] = docsPages, +): { group: DocsGroup; entries: DocsNavEntry[] }[] { + return DOCS_GROUPS.map((group) => ({ + group, + entries: pages.filter((entry) => entry.group === group), + })).filter(({ entries }) => entries.length > 0); +} + export function getDocsNavEntry(pathname: string) { return docsNav.find((entry) => entry.href === pathname); } diff --git a/studymap.config.example.ts b/studymap.config.example.ts index fc60714..49381f3 100644 --- a/studymap.config.example.ts +++ b/studymap.config.example.ts @@ -7,6 +7,8 @@ * with your own data, or start adding real files under data/places/ * per CONTRIBUTING.md and import those instead. * 3. Set center / defaultZoom / cities to your region. + * 4. Optional: list your own competitions under `competitions` (see + * data/competitions/CONTRIBUTING.md), or leave it empty. */ import type { City, Place } from "@/lib/types"; import type { StudyMapConfig } from "./studymap.config"; @@ -21,6 +23,9 @@ const studyMapConfig: StudyMapConfig = { defaultZoom: 12, cities, places: [...(library as Place[]), ...(bookShop as Place[])], + // No sample competitions ship with the example. The competitions pages + // show an empty catalog until you add some. + competitions: [], }; export default studyMapConfig; diff --git a/studymap.config.ts b/studymap.config.ts index 8e99c1e..44e3b67 100644 --- a/studymap.config.ts +++ b/studymap.config.ts @@ -3,10 +3,11 @@ * * Forking StudyMap for a different city or dataset? This is the one file to * change. Swap the imports below for your own `data/places/*.json`, and - * adjust `center` / `defaultZoom` / `cities` to your region. No other file - * needs to change; `src/lib/places.ts` reads from here. + * adjust `center` / `defaultZoom` / `cities` to your region, and do the same + * for the competitions below (or leave `competitions` empty). No other file + * needs to change; `src/lib/places.ts` and `src/lib/competitions.ts` read from here. */ -import type { City, Place } from "@/lib/types"; +import type { City, Competition, Place } from "@/lib/types"; import airport from "./data/places/airport.json"; import library from "./data/places/library.json"; @@ -15,6 +16,22 @@ import satCentre from "./data/places/sat_centre.json"; import foreignLangExamCentre from "./data/places/foreign_lang_exam_centre.json"; import govOffices from "./data/places/gov_offices.json"; +import artsDesignCompetitions from "./data/competitions/arts_design.json"; +import businessCompetitions from "./data/competitions/business.json"; +import codingCompetitions from "./data/competitions/coding.json"; +import creativeWritingCompetitions from "./data/competitions/creative_writing.json"; +import debateMunCompetitions from "./data/competitions/debate_mun.json"; +import essayWritingCompetitions from "./data/competitions/essay_writing.json"; +import filmVideoCompetitions from "./data/competitions/film_video.json"; +import financeCompetitions from "./data/competitions/finance.json"; +import historyCompetitions from "./data/competitions/history.json"; +import humanitiesCompetitions from "./data/competitions/humanities.json"; +import interdisciplinaryCompetitions from "./data/competitions/interdisciplinary.json"; +import mathematicsCompetitions from "./data/competitions/mathematics.json"; +import researchCompetitions from "./data/competitions/research.json"; +import scholarshipCompetitions from "./data/competitions/scholarship.json"; +import stemCompetitions from "./data/competitions/stem.json"; + export interface StudyMapConfig { /** Initial map center, as [lat, lng]. */ center: [number, number]; @@ -29,6 +46,12 @@ export interface StudyMapConfig { cities: City[]; /** Every place pin StudyMap renders, merged from the data sources above. */ places: Place[]; + /** + * Every competition StudyMap lists, merged from the data sources above + * (`data/competitions/.json`). Use an empty array if your + * fork doesn't have a competitions catalog. + */ + competitions: Competition[]; } const studyMapConfig: StudyMapConfig = { @@ -43,6 +66,23 @@ const studyMapConfig: StudyMapConfig = { ...(foreignLangExamCentre as Place[]), ...(govOffices as Place[]), ], + competitions: [ + ...(artsDesignCompetitions as Competition[]), + ...(businessCompetitions as Competition[]), + ...(codingCompetitions as Competition[]), + ...(creativeWritingCompetitions as Competition[]), + ...(debateMunCompetitions as Competition[]), + ...(essayWritingCompetitions as Competition[]), + ...(filmVideoCompetitions as Competition[]), + ...(financeCompetitions as Competition[]), + ...(historyCompetitions as Competition[]), + ...(humanitiesCompetitions as Competition[]), + ...(interdisciplinaryCompetitions as Competition[]), + ...(mathematicsCompetitions as Competition[]), + ...(researchCompetitions as Competition[]), + ...(scholarshipCompetitions as Competition[]), + ...(stemCompetitions as Competition[]), + ], }; export default studyMapConfig;