StudyMap is a Next.js 16 (App Router) app. The public map, calendar, and place data need no
database and no auth - all place data ships as JSON, imported via studymap.config.ts at the
repo root. Signing in (Supabase Auth) is optional and only gates two private features: saved
custom places and personal calendar events, both backed by RLS-scoped Supabase tables under
supabase/migrations/. This document is a map for new contributors.
studymap/
├── .github/
│ ├── DISCUSSION_TEMPLATE/
│ │ └── request-a-city.yml # discussion template: request coverage for a new city
│ ├── ISSUE_TEMPLATE/
│ │ ├── add-place.yml # issue form: suggest a new place
│ │ ├── bug_report.yml # issue form: file a bug
│ │ ├── feature_request.yml # issue form: request a feature
│ │ └── config.yml # disables blank issues, adds contact links
│ ├── workflows/ # CI: lint+typecheck, build+validate-data
│ └── pull_request_template.md
├── data/
│ ├── CONTRIBUTING.md # data-specific contribution rules
│ ├── places/ # 9 JSON files, one per place type
│ └── places.sample/ # minimal placeholder dataset for forks (see SELF-HOSTING.md)
├── public/
│ ├── brand/ # OG image (og.svg, og-preview.html)
│ ├── icons/ # PWA icons: 192px + 512px, normal + maskable
│ ├── logo-dark.svg
│ ├── logo-light.svg
│ ├── logo-mark.svg
│ ├── favicon.svg
│ ├── manifest.webmanifest # PWA manifest
│ └── sw.js # service worker (offline support)
├── src/
│ ├── app/ # Next.js App Router pages and layouts
│ ├── components/ # React components, grouped by feature
│ └── lib/ # pure TypeScript utilities (no JSX)
├── supabase/
│ └── migrations/ # SQL for the two optional, sign-in-gated tables (user_places,
│ # user_home, user_events), run once by hand - see SELF-HOSTING.md
├── ARCHITECTURE.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE
├── README.md
├── SECURITY.md
├── SELF-HOSTING.md
├── components.json # shadcn/ui component config
├── eslint.config.mjs
├── next.config.js
├── package.json
├── postcss.config.mjs
├── studymap.config.ts # region + dataset config; the file a fork edits to retarget StudyMap
├── studymap.config.example.ts # starter version of the above, pointing at data/places.sample/
├── tsconfig.json
└── vitest.config.ts
One JSON file per place category (imported by studymap.config.ts at the repo root, not
directly by src/lib/places.ts - see Data flow below). Each file is a flat array of
place objects. This is the only place place data lives — nothing is duplicated elsewhere.
The record shape, valid type keys, and per-field rules are documented once in
data/CONTRIBUTING.md and enforced by
data/places.schema.json via npm run validate -
that pair is the source of truth, not this file. The PlaceType union and
Place interface in src/lib/types.ts mirror the same schema.
Next.js App Router. Each folder is a route segment.
| Route | File | Purpose |
|---|---|---|
/ |
page.tsx |
Homepage: hero, feature grid, CTA |
/map |
map/page.tsx |
Full interactive map |
/calendar |
calendar/page.tsx |
Exam calendar (SAT, IB, IGCSE) |
/docs |
docs/page.tsx |
Docs index linking to guides |
/docs/contributing |
How to add a place via PR | |
/docs/exam-centres |
How to use the map for exam centres | |
/docs/github-student-pack |
GitHub Student Pack guide | |
/contribute |
contribute/page.tsx |
Contribution hub (add place / bug / question) |
/about |
about/page.tsx |
Principles, stats, maintainers |
/legal/* |
Privacy, terms, disclaimer | |
/offline |
offline/page.tsx |
PWA offline fallback |
/login |
login/page.tsx |
Optional sign-in (Google OAuth, email) |
/auth/callback |
auth/callback/route.ts |
OAuth code exchange (dynamic, not static-export-safe) |
layout.tsx (root) wraps every page with Navbar, Footer, and the theme provider.
Components are grouped by feature, not by type.
components/
├── home/
│ ├── hero.tsx # landing hero with particle field
│ ├── hero-particles.tsx # canvas particle animation
│ └── map-preview.tsx # static map thumbnail on homepage
├── map/
│ ├── places-map.tsx # top-level map container: filtering, URL state, geolocation,
│ │ # auth + saved-places/home state, merges the private pin layer
│ ├── map-view.tsx # Leaflet map (clustering, tiles, scroll guard, user dot)
│ ├── map-panel.tsx # search, category chips, city select, results list, my-places slot
│ ├── my-places-panel.tsx # signed-in-only saved-places section: own search/city filter, CRUD
│ ├── user-place-dialog.tsx # add/edit/delete a saved place
│ ├── user-home-dialog.tsx # set/edit/remove the saved home location
│ ├── suggest-place-dialog.tsx # public "suggest a place" form -> pre-filled GitHub issue link
│ ├── category-chips.tsx # type filter chips with counts
│ ├── results-list.tsx # scrollable place list, optionally distance-sorted
│ ├── map-sheet.tsx # mobile bottom sheet (vaul) wrapping MapPanel
│ ├── near-me-button.tsx # desktop geolocation trigger
│ ├── near-me-fab.tsx # mobile floating geolocation trigger
│ ├── map-error-boundary.tsx
│ └── filters.ts # `PlaceFilters` type
├── calendar/
│ └── personal-event-dialog.tsx # add/edit/delete a signed-in user's calendar event
├── pins/
│ └── pin-popup.tsx # popup on marker click (directions, share, added_by)
├── layout/
│ ├── navbar.tsx # nav links + sign-in/sign-out
│ ├── footer.tsx
│ ├── page-container.tsx # max-width wrapper used by most pages
│ ├── theme-provider.tsx
│ └── theme-toggle.tsx
├── legal/
│ └── legal-page.tsx # shared shell for privacy/terms/disclaimer pages
├── ui/ # shadcn/ui primitives (Badge, Button, Card, Checkbox,
│ # Dialog, DropdownMenu, Input, Label, Particles,
│ # Select, Separator, Sheet, Sonner, Switch, Table, Tabs)
├── analytics.tsx # Umami analytics script (privacy-friendly, cookieless)
├── mdx-content.tsx # MDX renderer used by docs pages
└── pwa-register.tsx # service worker registration for offline / PWA support
src/app/calendar/CalendarView.tsx (a page-level component, not under components/) renders the
public exam calendar and, when signed in, the personal-events overlay.
Pure TypeScript modules. No JSX, no React imports. Each file has a single responsibility.
| File | What it does |
|---|---|
types.ts |
Place, PlaceType, City types; label maps |
places.ts |
Reads places from studymap.config.ts; exports getPlaces() and filterPlaces() |
geo.ts |
Haversine distance, placesByDistance(), formatDistance() |
map.ts |
PLACE_TYPE_COLORS (color-blind-safe palette); directionsUrl() |
share.ts |
URL state encode/decode for shareable filtered map links |
site.ts |
Site name, tagline, repo URL, nav link list |
exam-dates.ts |
EXAM_EVENTS array with SAT/IB/IGCSE session data |
fonts.ts |
next/font setup (Inter, Space Grotesk, JetBrains Mono) |
utils.ts |
cn() — clsx + tailwind-merge helper; isMissingTableError() — detects an unrun Supabase migration |
user-places.ts |
CRUD for saved places + home location (user_places, user_home tables) |
user-events.ts |
CRUD for personal calendar events (user_events table) |
supabase/client.ts, supabase/server.ts |
Browser and server Supabase clients (@supabase/ssr) |
types.ts, places.ts, geo.ts, map.ts, share.ts, exam-dates.ts have no Supabase dependency
and no side effects, they're the easiest targets for new unit tests (see *.test.ts next to
geo.ts, share.ts, and places.ts, run via npm run test:unit).
studymap.config.ts imports data/places/*.json, exports the merged Place[]
│
▼
src/lib/places.ts getPlaces() reads the config's Place[]
│ filterPlaces() narrows by type and/or city
▼
src/components/map/places-map.tsx
│ reads URL params via share.ts → parseMapState()
│ calls filterPlaces() on every filter change
│ if signed in: fetches saved places/home (user-places.ts) and merges
│ them into the Place[] passed down, on top of the public filters
│
├──▶ src/components/map/map-panel.tsx (search, chips, city select, my-places-panel)
├──▶ src/components/map/near-me-button.tsx (geolocation → placesByDistance())
│
▼
src/components/map/map-view.tsx
│ clusters overlapping pins (Supercluster), coloured by PLACE_TYPE_COLORS
│ on marker click: opens PinPopup
│
▼
src/components/pins/pin-popup.tsx
directionsUrl(lat, lng) ← src/lib/map.ts
buildShareUrl(state) ← src/lib/share.ts
The public map makes no network requests at runtime beyond map tiles. Place JSON is bundled
at build time via static import statements in studymap.config.ts. Signing in adds real runtime
requests: Supabase Auth, plus user-places.ts/user-events.ts calls for the two private features.
The single most central component. It owns four independent pieces of state and merges them into one Place[] for the map:
| State | Source | Notes |
|---|---|---|
Filters (types, city, query) |
useState, seeded from the URL via parseMapState() |
query is debounced (250 ms typing, 0 ms clearing) into debouncedQuery before filterPlaces() runs |
Focus (focusId) |
useState, seeded from the URL |
Set when a result row or pin is selected; collapses the mobile sheet |
Geolocation (userLocation, sortByDistance) |
useState, set by the near-me button/FAB |
Drives placesByDistance() for "nearest to you" sorting |
Auth (user, savedPlaces, home) |
Supabase onAuthStateChange listener |
Cleared synchronously during render (not in an effect) whenever user.id changes, so there's no stale-data flash between accounts |
Filters and focus are mirrored back into the URL on every change (mapStateToSearch()), which is what makes map links shareable. Filters and geolocation each run through filterPlaces()/placesByDistance() independently; they don't affect each other.
Merge order for what actually reaches the map: filterPlaces(places, filters) → visible, then visible is concatenated with savedPlaces (mapped through userPlaceToPlace()) → mapPlaces. Saved places always render regardless of the public filters — they're a private layer on top, not filtered by type/city/query. If a signed-in user's saved-places fetch fails because the Supabase table doesn't exist yet (self-hosted, migrations not run), isMissingTableError() catches it and shows a specific "table isn't set up, see SELF-HOSTING.md" message instead of a generic error.
Desktop and mobile share the same panelProps object (filters, results, my-places) — the only difference is which shell renders it: a fixed sidebar (<aside>) on lg: and up, or the MapSheet bottom drawer below that, toggled by sheetOpen/snap.
The single aggregation point for place data. Every component that needs places calls getPlaces() here — nothing imports the JSON directly.
getPlaces(): Place[]
filterPlaces(places, { types?, cities? }): Place[]
getCities(places, preferredOrder?): City[]Region and dataset settings (initial map center, default zoom, coordinate bounds, the
city display order, and which data/places/*.json files get loaded) live in
studymap.config.ts at the repo root, the one file a fork edits to retarget StudyMap.
All distance math lives here. Used by places-map.tsx to find the five nearest places when the user enables geolocation.
haversineKm(a: LatLng, b: LatLng): number
placesByDistance(places, origin): PlaceWithDistance[]
formatDistance(km): string // "450 m" | "3.2 km"Visual and navigation constants for the map. Keeping colours here (not inside the component) means the filter panel swatches and the Leaflet markers stay in sync automatically.
PLACE_TYPE_COLORS: Record<PlaceType, string> // one hex per type
directionsUrl(lat, lng): string // Google Maps deep-linkEncodes the active filter state (selected types, cities, focused place ID) into a query string so filtered views are copy-pasteable.
parseMapState(search: string): MapShareState
mapStateToSearch(state): string
buildShareUrl(state): string- Pick the right file in
data/places/<type>.json. - Append a record following the shape in
src/lib/types.ts. - Run
npm run devand open/mapto verify the pin appears in the right spot. - Open a pull request — see
CONTRIBUTING.mdfor the quality gate.
No code changes are needed to add a place. getPlaces() picks up the new entry automatically because it does a static import of the whole file.