+ Go to the{" "} + + Calendar + {" "} + page. Use the arrows on either side of the month name to move between + months. Each board has its own dot color, shown in the legend above the + grid: blue for SAT, purple for IB, green for IGCSE. +
++ Days with an exam window are highlighted and show a dot for each board + in session that day. The list below the legend shows every session that + month with its exam dates, results date, and a link back to the + official source (College Board, IBO, or Cambridge International). +
++ A session tagged Provisional means + the board has published an expected window that isn't officially + confirmed yet. Results dates marked{" "} + (expected) are estimates based on + board guidance, not a confirmed release date. +
++ Always confirm exam and result dates with your school or test centre + before making travel or study plans - boards occasionally revise + published timetables. +
++ Signed-in users can add personal events - application deadlines, + coaching sessions, reminders - that show up on the same calendar with a + pink marker, under "Your events" for the current month. Tap{" "} + Add event above the exam list to + create one, or the pencil icon on an existing event to edit it. +
++ Personal events are private to your account - they never appear on + anyone else's calendar. +
++ If a session is missing, a date has changed, or a provisional window has + since been confirmed, please{" "} + + open a GitHub issue + {" "} + with a link to the official board source. +
+ elements. */
+function renderItem(text: string): ReactNode {
+ const parts = text.split(/(`[^`]+`)/g).filter(Boolean);
+ return parts.map((part, i) =>
+ part.startsWith("`") && part.endsWith("`") ? (
+
+ {part.slice(1, -1)}
+
+ ) : (
+ {part}
+ ),
+ );
+}
+
+export default function ChangelogPage() {
+ const releases = getChangelog();
+
+ return (
+ <>
+
+ Every notable change to StudyMap, generated from{" "}
+
+ CHANGELOG.md
+ {" "}
+ so this page never drifts from the real history.
+ >
+ }
+ />
+
+
+ {releases.map((release) => (
+
+
+
+ v{release.version}
+
+ {release.date}
+
+
+
+ Changes in version {release.version}
+
+
+
+ {release.sections.map((section) => (
+
+
+ {section.heading}
+
+
+ {section.items.map((item, i) => (
+ - {renderItem(item)}
+ ))}
+
+
+ ))}
+
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/app/docs/data-format/page.tsx b/src/app/docs/data-format/page.tsx
new file mode 100644
index 0000000..8ea6ed1
--- /dev/null
+++ b/src/app/docs/data-format/page.tsx
@@ -0,0 +1,199 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { PLACE_TYPES, PLACE_TYPE_LABELS } from "@/lib/types";
+import placesSchema from "../../../../data/places.schema.json";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CodeBlock } from "@/components/docs/code-block";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "Place Data Format",
+ description:
+ "The JSON schema for data/places/*.json: every field, the id-prefix convention, and the 6 place types.",
+};
+
+const EXAMPLE = `{
+ "id": "mum-library-07",
+ "name": "City Library, Dadar branch",
+ "type": "library",
+ "city": "mumbai",
+ "lat": 19.0176,
+ "lng": 72.8562,
+ "address": "Gate 2, Gokhale Road, Dadar West",
+ "gmaps_link": "https://maps.app.goo.gl/xxxx",
+ "added_by": "your-github-handle"
+}`;
+
+const REQUIRED = new Set(placesSchema.required);
+
+/** Falls back to a synthesized note for fields the schema doesn't annotate with prose. */
+function fieldDescription(name: string, field: Record): string {
+ if (typeof field.description === "string") return field.description;
+ if (typeof field.minimum === "number" && typeof field.maximum === "number") {
+ return `Range: ${field.minimum} to ${field.maximum}.`;
+ }
+ if (name === "gmaps_link") {
+ return "A Google Maps link (maps.google.com, maps.app.goo.gl, or a full google.com/maps URL).";
+ }
+ return "";
+}
+
+export default function DataFormatPage() {
+ const fields = Object.entries(placesSchema.properties);
+
+ return (
+ <>
+
+ The JSON schema behind every entry in{" "}
+
+ data/places/<type>.json
+
+ . For the GitHub issue/PR process itself, see{" "}
+
+ Contributing Places
+ {" "}
+ instead - this page is only about the data shape.
+ >
+ }
+ />
+
+
+
+
+ The 6 place types
+ One JSON file per type, under data/places/
+
+
+
+
+
+
+ File
+ Type key
+ Label
+
+
+
+ {PLACE_TYPES.map((type) => (
+
+ {type}.json
+ {type}
+ {PLACE_TYPE_LABELS[type]}
+
+ ))}
+
+
+
+
+
+
+
+
+ Fields
+
+ Generated from data/places.schema.json, the file{" "}
+
+ scripts/validate-places.mjs
+ {" "}
+ validates every record against
+
+
+
+ {fields.map(([name, field]) => (
+
+ {name}
+
+ {(field as { type: string }).type}
+
+
+ {REQUIRED.has(name) ? "required" : "optional"}
+
+ {fieldDescription(name, field as Record) && (
+
+ {fieldDescription(name, field as Record)}
+
+ )}
+
+ ))}
+
+ Nothing outside this list is allowed on a committed record - proof of
+ quality (source, rating, review count, verified date) goes in the pull
+ request only.
+
+
+
+
+
+
+ Example record
+
+
+
+
+
+
+
+
+ ID prefix convention
+ <city-prefix>-<type>-<number>
+
+
+
+ The prefix is a short slug for the city (e.g.{" "}
+ mum,{" "}
+ thane,{" "}
+ jkt),
+ and the number increments from the highest one already used for that
+ prefix in the same file - never reuse an ID.
+
+
+ A handful of older bulk-imported entries don't follow this exact
+ format (see{" "}
+
+ Data Sources & Provenance
+
+ ), but every new place should.
+
+
+
+
+
+
+
+ If a place type ever needs a new field, add it to{" "}
+
+ data/places.schema.json
+ {" "}
+ first. This page and{" "}
+
+ scripts/validate-places.mjs
+ {" "}
+ read that file directly, so they update automatically - only{" "}
+
+ data/CONTRIBUTING.md
+ {" "}
+ needs a manual edit to match.
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/data-sources/page.tsx b/src/app/docs/data-sources/page.tsx
new file mode 100644
index 0000000..96406df
--- /dev/null
+++ b/src/app/docs/data-sources/page.tsx
@@ -0,0 +1,176 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { site } from "@/lib/site";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "Data Sources & Provenance",
+ description:
+ "Where StudyMap's place and exam-centre data comes from, how it's verified, licensing, and known accuracy caveats.",
+};
+
+export default function DataSourcesPage() {
+ return (
+ <>
+
+ Where the map's data comes from and how it's verified. For the
+ legal side (liability, use-at-your-own-risk), see the{" "}
+
+ Disclaimer
+ {" "}
+ page instead - this one is about provenance, not privacy or liability.
+ >
+ }
+ />
+
+
+
+
+ How places get added
+ Community-sourced, via GitHub
+
+
+
+ Every place in data/{" "}
+ was added by a contributor through a GitHub issue or pull request, not by
+ StudyMap staff independently researching locations. Each record carries an{" "}
+ added_by{" "}
+ field crediting the GitHub username that submitted it.
+
+
+ Contributors are asked to include, in the pull request itself (never in the
+ committed data): a source or citation, a Google Maps rating of 4.0 or
+ higher, 50 or more Google Maps reviews, and the date they verified the
+ place. See{" "}
+
+ Contributing Places
+ {" "}
+ for the full process.
+
+
+
+
+
+
+ Bulk-imported SAT centres
+ A different path from hand-added places
+
+
+
+ A large share of sat_centre.json{" "}
+ came from a bulk import rather than individual contributor submissions -
+ identifiable by numeric, CEEB-style id{" "}
+ values instead of the usual city-type-number{" "}
+ format. These entries did not go through the same per-place rating/review
+ gate as hand-added places.
+
+
+ Coordinates on these entries are accurate, but a few had formatting quirks
+ from the import (inconsistent city casing, a placeholder-style{" "}
+ gmaps_link{" "}
+ on some records) that have been progressively cleaned up. None of this
+ affects in-app navigation, since Directions is always computed live from
+ the stored coordinates, never from the stored link.
+
+
+
+
+
+
+ Verification is honor-system, not automated
+ What the validator actually checks
+
+
+
+ scripts/validate-places.mjs{" "}
+ (run on every pull request) checks structure - required fields, valid
+ types, coordinate bounds, unique IDs, well-formed links. It cannot verify
+ that a place is real, still open, or actually has the rating and review
+ count claimed in the PR description. That part relies on reviewer judgment
+ and contributor honesty.
+
+
+
+
+
+
+ Exam calendar dates
+ Sourced from the official boards
+
+
+
+ SAT, IB, and Cambridge IGCSE session and result dates are sourced directly
+ from College Board, IBO, and Cambridge International, with a link back to
+ the source on every entry. See{" "}
+
+ Using the Exam Calendar
+ {" "}
+ for how provisional dates are marked.
+
+
+
+
+
+
+ Licensing
+ MIT, code and data alike
+
+
+
+ The entire repository, including everything in{" "}
+ data/, is
+ MIT licensed. See{" "}
+
+ LICENSE
+ {" "}
+ on GitHub.
+
+
+
+
+
+
+
+ Open an issue at{" "}
+
+ github.com/StudentSuite/StudyMap/issues
+ {" "}
+ with what changed. See the{" "}
+
+ Disclaimer
+ {" "}
+ for why you should always verify details before visiting.
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/faq/page.tsx b/src/app/docs/faq/page.tsx
new file mode 100644
index 0000000..bd7f9be
--- /dev/null
+++ b/src/app/docs/faq/page.tsx
@@ -0,0 +1,106 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { site } from "@/lib/site";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "FAQ",
+ description:
+ "Common questions about StudyMap: data accuracy, reporting wrong info, why a place isn't listed, and accounts.",
+};
+
+export default function FaqPage() {
+ return (
+ <>
+
+
+
+
+
+ Every place was added by a contributor through GitHub, with a required
+ 4.0+ Google Maps rating and 50+ reviews at the time of submission - but
+ that proof lives in the pull request, not as an automated check, so it's
+ honor-system rather than machine-verified. Places can also go stale
+ (closed, moved, hours changed) after being added. See{" "}
+
+ Data Sources & Provenance
+ {" "}
+ for the full picture.
+
+
+
+
+
+ Open an issue at{" "}
+
+ github.com/StudentSuite/StudyMap/issues
+ {" "}
+ describing what changed. No GitHub account handy? Use the{" "}
+ Suggest a place button on the{" "}
+
+ map
+ {" "}
+ instead - it opens a pre-filled issue for you.
+
+
+
+
+
+ Either nobody has submitted it yet, or it didn't clear the quality
+ gate: a 4.0+ Google Maps rating, 50+ reviews, and confirmation it's
+ real and currently operating. Add it yourself via the{" "}
+ Suggest a place button on the map, or
+ see{" "}
+
+ Contributing Places
+ {" "}
+ to open a pull request directly.
+
+
+
+
+
+ No. The map, search, filters, and exam calendar all work fully without
+ signing in. An account is only needed for two optional, private features:
+ saving your own places and a home location, and adding personal events to
+ the calendar - neither of which are visible to anyone else.
+
+
+
+
+
+ Go to{" "}
+
+ Sign in
+ {" "}
+ and use either Google or email and password. There's nothing else to
+ configure.
+
+
+
+
+
+ Adding a place (via GitHub PR or issue) puts it on the public map for every
+ visitor, after the quality gate above. Saving a place (via the map's
+ "My places" panel, signed in) is private to your account only and
+ never appears on anyone else's map - it's a personal bookmark, not
+ a public contribution.
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/install/page.tsx b/src/app/docs/install/page.tsx
new file mode 100644
index 0000000..ca90a15
--- /dev/null
+++ b/src/app/docs/install/page.tsx
@@ -0,0 +1,138 @@
+import type { Metadata } from "next";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "Install & Offline Usage",
+ description:
+ "Install StudyMap as an app and use it offline: what's cached, how the service worker works, and how to force a fresh load.",
+};
+
+export default function InstallPage() {
+ return (
+ <>
+
+
+
+
+
+ Add to Home Screen
+ Uses your browser's built-in install feature, no app store
+
+
+
+ -
+ Android (Chrome): tap the ⋮ menu
+ and choose Install app (or{" "}
+ Add to Home screen), or tap the
+ install icon in the address bar if it appears.
+
+ -
+ iPhone/iPad (Safari): tap the{" "}
+ Share button, then{" "}
+ Add to Home Screen.
+
+ -
+ Desktop (Chrome/Edge): click the
+ install icon in the address bar, or open the browser menu and choose{" "}
+ Install StudyMap....
+
+
+
+ StudyMap doesn't show its own install button - this is entirely your
+ browser's native feature, driven by the app's manifest and
+ service worker.
+
+
+
+
+
+
+ What works offline
+ Whatever you've already opened, plus the map itself
+
+
+
+ - The app shell (home, map, and other pages you've visited before).
+ -
+ Map tiles you've already panned/zoomed to - cached as you browse, up
+ to 300 tiles, so the whole world isn't hoarded on your device.
+
+ -
+ Any page you haven't visited yet falls back to an{" "}
+ Offline screen instead of a browser
+ error.
+
+
+
+ Signing in, saving a new place, or loading a city you've never opened
+ still needs a connection - only what's already cached works fully
+ offline.
+
+
+
+
+
+
+ How the caching works
+ A versioned service worker (public/sw.js)
+
+
+
+ Two caches, each tagged with a version: an app cache (page shell, build
+ assets, icons) and a tile cache (map tiles from MapTiler). Page loads try
+ the network first and fall back to the cache when offline; static assets
+ and tiles are cache-first since they don't change under the same URL.
+
+
+ Every deploy bumps the version, which deletes old caches once the new
+ service worker activates - normally a reload is enough to pick up a new
+ release.
+
+
+
+
+
+
+ Try these in order:
+
+ -
+ Hard refresh: Cmd+Shift+R on Mac,
+ Ctrl+Shift+R or Ctrl+F5 on Windows/Linux.
+
+ -
+ Clear site data: in your browser's
+ dev tools (Application tab in Chrome/Edge, Storage in Firefox), clear
+ Service Workers, Cache Storage, and Storage for the site, then reload.
+
+ -
+ Reinstall the PWA: if you installed it
+ to your home screen, remove it and reinstall from the site.
+
+
+
+ If none of these help, the deploy itself likely hasn't shipped yet - check
+ the deployment status before assuming it's a caching issue.
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/map-controls/page.tsx b/src/app/docs/map-controls/page.tsx
new file mode 100644
index 0000000..202fae7
--- /dev/null
+++ b/src/app/docs/map-controls/page.tsx
@@ -0,0 +1,165 @@
+import type { Metadata } from "next";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "Map Controls",
+ description:
+ "Every way to zoom, pan, search, and filter the StudyMap map, including keyboard equivalents.",
+};
+
+export default function MapControlsPage() {
+ return (
+ <>
+
+
+
+
+
+ Zoom
+ Mouse, touch, and keyboard
+
+
+
+ -
+ Scroll wheel: plain scrolling moves
+ the page, not the map (so the map never traps your scroll). Hold{" "}
+ Ctrl (
+ Cmd on Mac) and scroll to zoom -
+ a brief hint appears if you scroll over the map without it held.
+
+ -
+ + / - buttons: the zoom control in
+ the map's corner works with a click or tap, no modifier needed.
+
+ -
+ Double-click: zooms in one level,
+ centered on the click.
+
+ -
+ Pinch: on touch devices, pinch to
+ zoom in or out.
+
+ -
+ Keyboard: click or tab the map to
+ focus it, then press + or{" "}
+ = to zoom in, and{" "}
+ - to zoom out.
+
+
+
+
+
+
+
+ Pan
+ Move around the map
+
+
+
+ -
+ Drag: click and drag (or swipe on
+ touch) to move the map.
+
+ -
+ Keyboard: with the map focused, the
+ arrow keys pan up, down, left, and right.
+
+ -
+ Near me: the locate button flies the
+ map to your current location (with permission).
+
+
+
+
+
+
+
+ Clusters
+ Pie-chart circles group nearby places
+
+
+
+ Where pins are close together, they group into a circle showing a count
+ and a color split by place type. Click or tap a cluster to zoom into it;
+ it keeps splitting into smaller clusters and eventually individual pins
+ as you zoom in.
+
+
+ Clicking an individual pin opens a popup with its name, type, and
+ Directions / Copy Link actions. Press{" "}
+ Escape, or click elsewhere on the
+ map, to close it.
+
+
+
+
+
+
+ Search & filter
+ Narrow down what's shown
+
+
+
+ -
+ Search box: matches place name or
+ city as you type.
+
+ -
+ Category chips: tap one or more to
+ show only those place types; tap again to remove it.
+
+ -
+ City select: narrows results to one
+ city at a time.
+
+ -
+ Results list: click a result to fly
+ the map to that pin.
+
+ -
+ Share: copies a link that reopens
+ the map with your current filters and selection applied.
+
+
+
+
+
+
+
+
+ Cluster markers don't yet announce their contents to screen readers, and
+ the Ctrl+scroll zoom hint has no keyboard-specific messaging. Both are tracked
+ in{" "}
+
+ issue #99
+
+ . This page will be updated with the keyboard equivalents once that ships.
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx
index b8e451c..5f6654b 100644
--- a/src/app/docs/page.tsx
+++ b/src/app/docs/page.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { BookOpen, Gift, GitPullRequest, GraduationCap, MapPin, Puzzle } from "lucide-react";
+import { BookOpen, CalendarDays, CircleHelp, Database, Download, FileJson, Gift, GitPullRequest, GraduationCap, History, Layers, MapPin, MousePointerClick, Puzzle, Server, Wrench } from "lucide-react";
import { PageContainer } from "@/components/layout/page-container";
import { DocsPageHeader } from "@/components/docs/docs-page-header";
@@ -27,6 +27,22 @@ const DOCS = [
icon: MapPin,
iconClassName: "text-marker-sat-centre",
},
+ {
+ href: "/docs/map-controls",
+ title: "Map Controls",
+ description:
+ "Every way to zoom, pan, search, and filter the map, including keyboard equivalents.",
+ icon: MousePointerClick,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/calendar",
+ title: "Using the Exam Calendar",
+ description:
+ "Read SAT, IB, and Cambridge IGCSE exam windows and result dates, and add your own personal events.",
+ icon: CalendarDays,
+ iconClassName: "text-primary",
+ },
{
href: "/docs/contributing",
title: "Contributing Places",
@@ -35,6 +51,38 @@ const DOCS = [
icon: GitPullRequest,
iconClassName: "text-primary",
},
+ {
+ href: "/docs/data-format",
+ title: "Place Data Format",
+ description:
+ "The JSON schema for data/places/*.json: every field, the id-prefix convention, and the 6 place types.",
+ icon: FileJson,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/data-sources",
+ title: "Data Sources & Provenance",
+ description:
+ "Where the place and exam-centre data comes from, how it's verified, licensing, and known accuracy caveats.",
+ icon: Database,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/architecture",
+ title: "Architecture",
+ description:
+ "A map of the codebase for new contributors: folder layout, data flow, and key modules.",
+ icon: Layers,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/self-hosting",
+ title: "Self-Hosting Guide",
+ description:
+ "Run StudyMap for your own city: fork, configure your region and dataset, and deploy.",
+ icon: Server,
+ iconClassName: "text-primary",
+ },
{
href: "/docs/awesome-student-resources",
title: "Awesome Student Resources",
@@ -59,6 +107,37 @@ const DOCS = [
icon: Puzzle,
iconClassName: "text-primary",
},
+ {
+ href: "/docs/faq",
+ title: "FAQ",
+ description:
+ "Common questions about StudyMap: data accuracy, reporting wrong info, why a place isn't listed, and accounts.",
+ icon: CircleHelp,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/install",
+ title: "Install & Offline Usage",
+ description:
+ "Install StudyMap as an app and use it offline: what's cached and how to force a fresh load.",
+ icon: Download,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/troubleshooting",
+ title: "Troubleshooting",
+ description:
+ "Common problems when running StudyMap locally or on a fork, and what actually causes them.",
+ icon: Wrench,
+ iconClassName: "text-primary",
+ },
+ {
+ href: "/docs/changelog",
+ title: "Changelog",
+ description: "Every notable change to StudyMap, release by release.",
+ icon: History,
+ iconClassName: "text-primary",
+ },
];
export default function DocsIndexPage() {
diff --git a/src/app/docs/self-hosting/page.tsx b/src/app/docs/self-hosting/page.tsx
new file mode 100644
index 0000000..14f1aa8
--- /dev/null
+++ b/src/app/docs/self-hosting/page.tsx
@@ -0,0 +1,248 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { StepCard } from "@/components/docs/step-card";
+import { CalloutCard } from "@/components/docs/callout-card";
+import { CodeBlock } from "@/components/docs/code-block";
+
+export const metadata: Metadata = {
+ title: "Self-Hosting Guide",
+ description:
+ "Run StudyMap for your own city: fork, configure your region and dataset, and deploy. No coding required beyond editing one config file.",
+};
+
+const CLONE = `git clone https://github.com//.git
+cd
+npm install`;
+
+const CONFIG_COPY = `cp studymap.config.example.ts studymap.config.ts`;
+
+const VALIDATE = `npm run validate`;
+
+const ENV_COPY = `cp .env.example .env.local`;
+
+const DEV = `npm run dev`;
+
+const DEPLOY = `npx vercel`;
+
+const UPSTREAM = `git remote add upstream https://github.com/StudentSuite/StudyMap.git
+git fetch upstream
+git merge upstream/main`;
+
+export default function SelfHostingPage() {
+ return (
+ <>
+
+
+
+
+
+ Click "Use this template" at
+ the top of{" "}
+
+ StudentSuite/StudyMap
+ {" "}
+ to create your own copy, then clone it.
+
+
+
+
+
+
+ Everything region- and data-specific lives in{" "}
+ studymap.config.ts{" "}
+ at the repo root.
+
+
+ Edit it:
+
+ -
+
center:{" "}
+ [lat, lng]{" "}
+ for the initial map view
+
+ -
+
defaultZoom:
+ initial zoom level (11-13 works well for a metro area)
+
+ -
+
bounds: rough
+ coordinate box around your region, used for data validation and map fitting
+
+ -
+
cities:
+ display order for the city filter (anything in your data but missing here still
+ shows, just sorted alphabetically after)
+
+ -
+
places: swap
+ the sample imports for your own data/places/*.json{" "}
+ files
+
+
+
+ The dataset itself follows the schema on{" "}
+
+ Place Data Format
+
+ . data/places.sample/{" "}
+ has two minimal example entries if you want to start from a clean skeleton instead of
+ the dataset that ships with the template.
+
+ Validate as you go:
+
+
+
+
+
+
+ -
+
+ NEXT_PUBLIC_MAPTILER_KEY
+ {" "}
+ is required for the map basemap. Free tier, no credit card, at{" "}
+
+ cloud.maptiler.com
+
+ .
+
+ -
+ The Supabase variables are optional. Leave them blank and the map, filters, and
+ calendar all work; you just won't get sign-in or the two private features in
+ step 5.
+
+
+
+
+
+
+
+ Open{" "}
+
+ localhost:3000/map
+ {" "}
+ and confirm your places show up in the right spot.
+
+
+
+
+
+ These three features (sign-in, saved custom places, personal calendar events) need a
+ Supabase project. Skip this whole step if you only want the public map and calendar.
+
+
+ -
+ Create a free project at{" "}
+
+ supabase.com
+
+ .
+
+ -
+ Copy its URL and anon key into{" "}
+
.env.local{" "}
+ (NEXT_PUBLIC_SUPABASE_URL,{" "}
+ NEXT_PUBLIC_SUPABASE_ANON_KEY).
+
+ -
+ In the Supabase SQL editor, run every file in{" "}
+
supabase/migrations/,
+ in filename order. Each one creates its tables with row-level security already
+ scoped to auth.uid(),
+ so users can only ever read or write their own rows.
+
+ -
+ Enable whichever auth providers you want (email, Google, etc.) under
+ Authentication > Providers.
+
+
+
+
+
+ Deploy like any standard Next.js app, for example on Vercel:
+
+
+ or import the repo at{" "}
+
+ vercel.com/new
+ {" "}
+ and set the same environment variables from step 3 in the project settings.
+
+
+
+ Static export (output: "export")
+ is not supported.
+ {" "}
+ Sign-in needs a live server: the OAuth callback route exchanges a code for a session
+ server-side, and middleware refreshes that session on every request. Both are
+ incompatible with a static build. Deploy to a normal server/edge runtime instead -
+ that's the default for Vercel and most other Next.js hosts.
+
+
+
+
+
+ To pull in upstream fixes, add the original repo as a remote and merge from it:
+
+
+ Your studymap.config.ts,{" "}
+ .env.local, and{" "}
+ data/places/*.json{" "}
+ are yours - upstream changes to shared code (map, calendar, components) merge in
+ without touching them, unless you've also edited those files.
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/troubleshooting/page.tsx b/src/app/docs/troubleshooting/page.tsx
new file mode 100644
index 0000000..a0daf85
--- /dev/null
+++ b/src/app/docs/troubleshooting/page.tsx
@@ -0,0 +1,163 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { site } from "@/lib/site";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PageContainer } from "@/components/layout/page-container";
+import { DocsPageHeader } from "@/components/docs/docs-page-header";
+import { CalloutCard } from "@/components/docs/callout-card";
+
+export const metadata: Metadata = {
+ title: "Troubleshooting",
+ description:
+ "Common problems when running StudyMap locally or on a fork, and what actually causes them.",
+};
+
+export default function TroubleshootingPage() {
+ return (
+ <>
+
+
+
+
+
+ Map doesn't load, or the basemap is blank/grey
+ Almost always a missing or invalid MapTiler key
+
+
+
+ The map tiles come from MapTiler, and unlike the Supabase variables, this
+ one is required - the basemap will not render without it.
+
+
+ Get a free key at{" "}
+
+ cloud.maptiler.com
+
+ , add it to .env.local as{" "}
+ NEXT_PUBLIC_MAPTILER_KEY,
+ and restart the dev server - env vars are only read on boot.
+
+
+
+
+
+
+ Signed in, but saved places or the calendar show an error
+ The Supabase tables for the app's own data aren't set up yet
+
+
+
+ This means the Supabase tables exist for auth but not for the app's own
+ data (saved places, home location, personal calendar events). Code already
+ catches this: isMissingTableError(){" "}
+ checks for Postgres error PGRST205{" "}
+ ("table not found in schema cache") and shows a message pointing here
+ instead of a generic failure.
+
+
+ Fix: in the Supabase SQL editor, run every file in{" "}
+ supabase/migrations/, in
+ filename order. See{" "}
+
+ SELF-HOSTING.md
+ {" "}
+ for the full self-host setup.
+
+
+ If you don't need auth at all, leave the Supabase env vars blank. The map,
+ search, filters, and calendar all work without them - you just lose sign-in,
+ saved places, and personal events.
+
+
+
+
+
+
+ "Near me" doesn't work
+ Geolocation issues, and why the pin sometimes looks off
+
+
+
+ -
+ No prompt appeared, or it's silently doing
+ nothing: the browser doesn't support the Geolocation API, or the
+ page isn't served over HTTPS (required by most browsers outside{" "}
+
localhost).
+
+ -
+ "Could not read your location":{" "}
+ location permission was denied, or the device couldn't get a fix. Check
+ the browser's site permissions and try again.
+
+ -
+ Location seems wrong: some browsers fall
+ back to IP-based location when GPS/Wi-Fi positioning is unavailable, which
+ can be off by kilometers, especially on desktop.
+
+
+
+
+
+
+
+ The app looks out of date after a deploy
+ Usually a PWA caching issue
+
+
+
+ See{" "}
+
+ Install & Offline Usage
+ {" "}
+ for what gets cached and how to force a fresh load.
+
+
+
+
+
+
+
+ Open an issue at{" "}
+
+ github.com/StudentSuite/StudyMap/issues
+ {" "}
+ or email{" "}
+
+ studentsuite3@gmail.com
+
+ .
+
+
+
+ >
+ );
+}
diff --git a/src/components/mdx-content.tsx b/src/components/mdx-content.tsx
index decd2fb..5266ffd 100644
--- a/src/components/mdx-content.tsx
+++ b/src/components/mdx-content.tsx
@@ -74,4 +74,38 @@ export const mdxComponents = {
hr: ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
),
+ // Fenced code blocks land as ``, with or without a language
+ // className depending on whether the fence declares one (` ``` ` vs
+ // ` ```ts `) - resetting the inline `code` pill styling via a `pre >
+ // code` selector here works either way, instead of trying to detect
+ // "is this inline or block" from the code element alone.
+ pre: ({ className, ...props }: ComponentPropsWithoutRef<"pre">) => (
+ code]:rounded-none [&>code]:bg-transparent [&>code]:p-0 [&>code]:text-xs",
+ className,
+ )}
+ {...props}
+ />
+ ),
+ table: ({ className, ...props }: ComponentPropsWithoutRef<"table">) => (
+
+
+
+ ),
+ thead: ({ className, ...props }: ComponentPropsWithoutRef<"thead">) => (
+
+ ),
+ tr: ({ className, ...props }: ComponentPropsWithoutRef<"tr">) => (
+
+ ),
+ th: ({ className, ...props }: ComponentPropsWithoutRef<"th">) => (
+
+ ),
+ td: ({ className, ...props }: ComponentPropsWithoutRef<"td">) => (
+
+ ),
};
diff --git a/src/lib/changelog.test.ts b/src/lib/changelog.test.ts
new file mode 100644
index 0000000..0009e8a
--- /dev/null
+++ b/src/lib/changelog.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+
+import { parseChangelog } from "@/lib/changelog";
+
+const CHANGELOG = `# Changelog
+
+All notable changes to StudyMap are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [2.1.0] - 2026-07-03
+
+### Added
+
+- New feature one.
+- New feature two with \`inline code\`.
+
+### Fixed
+
+- A bug (#42).
+
+## [2.0.0] - 2026-07-01
+
+### Removed
+
+- Old thing.
+`;
+
+describe("parseChangelog", () => {
+ it("parses releases in document order with version and date", () => {
+ const releases = parseChangelog(CHANGELOG);
+
+ expect(releases).toHaveLength(2);
+ expect(releases[0]).toMatchObject({ version: "2.1.0", date: "2026-07-03" });
+ expect(releases[1]).toMatchObject({ version: "2.0.0", date: "2026-07-01" });
+ });
+
+ it("groups items under their nearest ### section within a release", () => {
+ const [latest] = parseChangelog(CHANGELOG);
+
+ expect(latest.sections.map((s) => s.heading)).toEqual(["Added", "Fixed"]);
+ expect(latest.sections[0].items).toEqual([
+ "New feature one.",
+ "New feature two with `inline code`.",
+ ]);
+ expect(latest.sections[1].items).toEqual(["A bug (#42)."]);
+ });
+
+ it("ignores the intro paragraph before the first ## [ heading", () => {
+ expect(parseChangelog("Just some prose.\n\nNo headings here.")).toEqual([]);
+ });
+});
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
new file mode 100644
index 0000000..59edcbf
--- /dev/null
+++ b/src/lib/changelog.ts
@@ -0,0 +1,59 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+
+export interface ChangelogSection {
+ heading: string;
+ items: string[];
+}
+
+export interface ChangelogRelease {
+ version: string;
+ date: string;
+ sections: ChangelogSection[];
+}
+
+const VERSION_PATTERN = /^## \[(.+?)\] - (.+)$/;
+const SECTION_PATTERN = /^### (.+)$/;
+const ITEM_PATTERN = /^- (.+)$/;
+
+/**
+ * Parses CHANGELOG.md's Keep-a-Changelog-formatted body (`## [x.y.z] - date`,
+ * `### Category`, `- item`) into structured releases. Content past the intro
+ * paragraph and before the first `## [` heading is ignored.
+ */
+export function parseChangelog(markdown: string): ChangelogRelease[] {
+ const releases: ChangelogRelease[] = [];
+ let currentRelease: ChangelogRelease | null = null;
+ let currentSection: ChangelogSection | null = null;
+
+ for (const line of markdown.split("\n")) {
+ const versionMatch = VERSION_PATTERN.exec(line);
+ if (versionMatch) {
+ currentRelease = { version: versionMatch[1], date: versionMatch[2], sections: [] };
+ releases.push(currentRelease);
+ currentSection = null;
+ continue;
+ }
+ if (!currentRelease) continue;
+
+ const sectionMatch = SECTION_PATTERN.exec(line);
+ if (sectionMatch) {
+ currentSection = { heading: sectionMatch[1], items: [] };
+ currentRelease.sections.push(currentSection);
+ continue;
+ }
+
+ const itemMatch = ITEM_PATTERN.exec(line);
+ if (itemMatch && currentSection) {
+ currentSection.items.push(itemMatch[1]);
+ }
+ }
+
+ return releases;
+}
+
+/** Reads and parses CHANGELOG.md from the repo root. Server-only (uses `fs`). */
+export function getChangelog(): ChangelogRelease[] {
+ const raw = readFileSync(join(process.cwd(), "CHANGELOG.md"), "utf8");
+ return parseChangelog(raw);
+}