From 0f3e08cce6b2e45a715836b20b2e7f238b651ec1 Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Mon, 29 Jun 2026 12:05:47 +0530 Subject: [PATCH 1/6] docs(contributing): add local development section with worked example --- CONTRIBUTING.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cfc26e..172160d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,90 @@ Thanks for helping students find their way around. The most useful contribution - **Fix incorrect data** (wrong coordinates, outdated name, broken link) - **Fix code or docs** +## Local development + +### 1. Clone and install + +```bash +git clone https://github.com/StudentSuite/StudyMap.git +cd StudyMap +npm ci +``` + +### 2. Set up environment variables + +Copy the example file and fill in your values: + +```bash +cp .env.example .env.local +``` + +The only required variables are the Supabase credentials (for auth). If you are only adding place data, you can leave them blank - the map still loads without auth. + +### 3. Start the dev server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). The map is at [http://localhost:3000/map](http://localhost:3000/map). + +### Worked example: adding a library in Thane + +#### Step 1 - verify the place meets the quality gate + +Search the place on Google Maps. Confirm: + +- Rating is 4.0 or higher +- It has 50 or more reviews +- It is real and currently operating + +Note down the rating, review count, and date - these go in your PR, not the JSON. + +#### Step 2 - get the coordinates + +Click the place on Google Maps. The URL contains the coordinates, e.g. `@19.2183,72.9781`. Alternatively, right-click the entrance pin and copy the lat/lng. + +#### Step 3 - find the next available ID + +```bash +node -e "const d = require('./data/places/library.json'); const ids = d.map(e=>e.id).filter(id=>id.startsWith('thn')); console.log(ids.sort().at(-1));" +``` + +If the last Thane library ID is `thn-library-03`, use `thn-library-04`. + +#### Step 4 - add the entry + +Open `data/places/library.json` and append before the closing `]`: + +```json +{ + "id": "thn-library-04", + "name": "Thane Municipal Library, Naupada", + "type": "library", + "city": "thane", + "lat": 19.2183, + "lng": 72.9781, + "address": "Naupada, Thane West", + "gmaps_link": "https://maps.google.com/?q=19.2183,72.9781", + "added_by": "your-github-handle" +} +``` + +#### Step 5 - validate + +```bash +node -e "const d = require('./data/places/library.json'); console.log('Total:', d.length); const ids = d.map(e=>e.id); console.log('Dups:', ids.filter((id,i)=>ids.indexOf(id)!==i).length||'none');" +``` + +#### Step 6 - confirm the pin appears + +The dev server hot-reloads. Refresh [http://localhost:3000/map](http://localhost:3000/map) and check that the new pin appears at the right location. + +#### Step 7 - open a PR + +Commit with `feat(data): add Thane Municipal Library, Naupada` and open a pull request. Include the Google Maps rating, review count, and the date you verified the place in the PR description. + ## Quality gate for places Public places live in the repo as JSON and must be trustworthy. Before a place is merged it must clear this gate, with proof shown in the PR: From 4bbb58730b85771c049b925d5b4712c436dfc75a Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Mon, 29 Jun 2026 12:08:26 +0530 Subject: [PATCH 2/6] feat(map): add per-category counts to the type legend --- src/components/map/filter-panel.tsx | 7 ++++++- src/components/map/places-map.tsx | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/map/filter-panel.tsx b/src/components/map/filter-panel.tsx index f30293b..05be7b8 100644 --- a/src/components/map/filter-panel.tsx +++ b/src/components/map/filter-panel.tsx @@ -24,6 +24,7 @@ interface FilterPanelProps { cities: City[]; onChange: (filters: PlaceFilters) => void; resultCount: number; + typeCounts: Record; } function toggle(list: T[], value: T): T[] { @@ -37,6 +38,7 @@ export function FilterPanel({ cities, onChange, resultCount, + typeCounts, }: FilterPanelProps) { const allEmpty = filters.types.length === 0 && !filters.city; @@ -97,7 +99,10 @@ export function FilterPanel({ className="size-3 shrink-0 rounded-full" style={{ backgroundColor: PLACE_TYPE_COLORS[type] }} /> - {PLACE_TYPE_LABELS[type]} + {PLACE_TYPE_LABELS[type]} + + {typeCounts[type]} + ))} diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index 764d9c3..eacdeec 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -5,7 +5,8 @@ import dynamic from "next/dynamic"; import { Share2, SlidersHorizontal, X } from "lucide-react"; import { toast } from "sonner"; -import type { Place } from "@/lib/types"; +import type { Place, PlaceType } from "@/lib/types"; +import { PLACE_TYPES } from "@/lib/types"; import { cityBounds, filterPlaces, getCities } from "@/lib/places"; import { placesByDistance, formatDistance, type LatLng } from "@/lib/geo"; import { PLACE_TYPE_LABELS } from "@/lib/types"; @@ -67,6 +68,14 @@ export function PlacesMap({ places }: PlacesMapProps) { [places, filters], ); + const typeCounts = React.useMemo(() => { + const counts = Object.fromEntries( + PLACE_TYPES.map((t) => [t, 0]), + ) as Record; + for (const place of visible) counts[place.type]++; + return counts; + }, [visible]); + // Fly the map to the selected city's bounding box, regardless of type filters, // so picking a city always shows the whole city rather than just the visible types. const focusBounds = React.useMemo( @@ -145,6 +154,7 @@ export function PlacesMap({ places }: PlacesMapProps) { cities={cities} onChange={setFilters} resultCount={visible.length} + typeCounts={typeCounts} /> From 7885335c848e5aea79c35f35152025cbe87bf5bf Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Mon, 29 Jun 2026 12:10:21 +0530 Subject: [PATCH 3/6] feat(map): add nearest-first sort to the distance results list --- src/components/map/places-map.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index eacdeec..cf935d4 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -40,6 +40,7 @@ export function PlacesMap({ places }: PlacesMapProps) { const [focusId, setFocusId] = React.useState(null); const [panelOpen, setPanelOpen] = React.useState(false); const [userLocation, setUserLocation] = React.useState(null); + const [sortByDistance, setSortByDistance] = React.useState(false); const hydrated = React.useRef(false); const cities = React.useMemo(() => getCities(places), [places]); @@ -95,11 +96,13 @@ export function PlacesMap({ places }: PlacesMapProps) { .catch(() => toast.error("Could not copy link")); } - const nearest = React.useMemo(() => { + const byDistance = React.useMemo(() => { if (!userLocation) return []; - return placesByDistance(visible, userLocation).slice(0, 5); + return placesByDistance(visible, userLocation); }, [visible, userLocation]); + const nearest = sortByDistance ? byDistance : byDistance.slice(0, 5); + return (
@@ -171,11 +174,22 @@ export function PlacesMap({ places }: PlacesMapProps) {
- {nearest.length > 0 && ( + {byDistance.length > 0 && (
-

- Nearest to you -

+
+

+ {sortByDistance + ? `All ${byDistance.length} - nearest first` + : "Nearest to you"} +

+ +
    {nearest.map((place) => (
  • Date: Mon, 29 Jun 2026 12:12:35 +0530 Subject: [PATCH 4/6] feat(map): add debounced text search to filter places by name/city --- src/components/map/filter-panel.tsx | 15 +++++++++++++-- src/components/map/places-map.tsx | 18 +++++++++++++++--- src/lib/places.ts | 9 ++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/map/filter-panel.tsx b/src/components/map/filter-panel.tsx index 05be7b8..85945ba 100644 --- a/src/components/map/filter-panel.tsx +++ b/src/components/map/filter-panel.tsx @@ -5,6 +5,7 @@ import type { City, PlaceType } from "@/lib/types"; import { PLACE_TYPE_COLORS } from "@/lib/map"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Select, @@ -17,6 +18,7 @@ import { export interface PlaceFilters { types: PlaceType[]; city: City | null; + query: string; } interface FilterPanelProps { @@ -40,10 +42,19 @@ export function FilterPanel({ resultCount, typeCounts, }: FilterPanelProps) { - const allEmpty = filters.types.length === 0 && !filters.city; + const allEmpty = filters.types.length === 0 && !filters.city && !filters.query; return (
    + onChange({ ...filters, query: e.target.value })} + className="h-8 text-sm" + aria-label="Search places by name or city" + /> +

    {resultCount} {resultCount === 1 ? "place" : "places"} @@ -53,7 +64,7 @@ export function FilterPanel({ variant="ghost" size="sm" className="h-7 px-2 text-xs" - onClick={() => onChange({ types: [], city: null })} + onClick={() => onChange({ types: [], city: null, query: "" })} > Reset diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index cf935d4..55b7c87 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -36,7 +36,9 @@ export function PlacesMap({ places }: PlacesMapProps) { const [filters, setFilters] = React.useState({ types: [], city: null, + query: "", }); + const [debouncedQuery, setDebouncedQuery] = React.useState(""); const [focusId, setFocusId] = React.useState(null); const [panelOpen, setPanelOpen] = React.useState(false); const [userLocation, setUserLocation] = React.useState(null); @@ -48,11 +50,21 @@ export function PlacesMap({ places }: PlacesMapProps) { // Restore filters and the focused pin from the URL on first load. React.useEffect(() => { const state = parseMapState(window.location.search); - setFilters({ types: state.types, city: state.city }); + setFilters({ types: state.types, city: state.city, query: "" }); setFocusId(state.placeId); hydrated.current = true; }, []); + // Debounce the search query so filtering doesn't run on every keystroke. + React.useEffect(() => { + if (!filters.query) { + setDebouncedQuery(""); + return; + } + const timer = setTimeout(() => setDebouncedQuery(filters.query), 250); + return () => clearTimeout(timer); + }, [filters.query]); + // Mirror filter and focus state back into the URL so it stays shareable. React.useEffect(() => { if (!hydrated.current) return; @@ -65,8 +77,8 @@ export function PlacesMap({ places }: PlacesMapProps) { }, [filters, focusId]); const visible = React.useMemo( - () => filterPlaces(places, filters), - [places, filters], + () => filterPlaces(places, { ...filters, query: debouncedQuery }), + [places, filters.types, filters.city, debouncedQuery], ); const typeCounts = React.useMemo(() => { diff --git a/src/lib/places.ts b/src/lib/places.ts index 2a237c2..f7b5b13 100644 --- a/src/lib/places.ts +++ b/src/lib/places.ts @@ -27,8 +27,9 @@ export function getPlaces(): Place[] { export function filterPlaces( places: Place[], - opts: { types?: PlaceType[]; city?: City | null }, + opts: { types?: PlaceType[]; city?: City | null; query?: string }, ): Place[] { + const q = opts.query?.trim().toLowerCase() ?? ""; return places.filter((place) => { if (opts.types && opts.types.length > 0 && !opts.types.includes(place.type)) { return false; @@ -36,6 +37,12 @@ export function filterPlaces( if (opts.city && place.city !== opts.city) { return false; } + if (q) { + const cityNorm = place.city.replace(/_/g, " "); + if (!place.name.toLowerCase().includes(q) && !cityNorm.includes(q)) { + return false; + } + } return true; }); } From 2dc47b1ad7d1995a23a24a14294b8016458ce49f Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Mon, 29 Jun 2026 12:14:23 +0530 Subject: [PATCH 5/6] ci: add lint + typecheck workflow that gates PRs --- .github/workflows/ci.yml | 2 -- .github/workflows/lint.yml | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1756b81..26b2370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,4 @@ jobs: node-version: 20 cache: npm - run: npm ci - - run: npm run lint - continue-on-error: true - run: npm run build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9e23027 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,15 @@ +name: Lint & Typecheck +on: + pull_request: +jobs: + lint-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npx tsc --noEmit From d8025e39db35a9852095d16374fe8524e659490a Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Mon, 29 Jun 2026 12:15:39 +0530 Subject: [PATCH 6/6] fix(map): use lazy useState initialisers to avoid double render on hydration --- src/components/map/places-map.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index 55b7c87..e6c60a3 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -33,13 +33,16 @@ interface PlacesMapProps { } export function PlacesMap({ places }: PlacesMapProps) { - const [filters, setFilters] = React.useState({ - types: [], - city: null, - query: "", + const [filters, setFilters] = React.useState(() => { + if (typeof window === "undefined") return { types: [], city: null, query: "" }; + const state = parseMapState(window.location.search); + return { types: state.types, city: state.city, query: "" }; }); const [debouncedQuery, setDebouncedQuery] = React.useState(""); - const [focusId, setFocusId] = React.useState(null); + const [focusId, setFocusId] = React.useState(() => { + if (typeof window === "undefined") return null; + return parseMapState(window.location.search).placeId ?? null; + }); const [panelOpen, setPanelOpen] = React.useState(false); const [userLocation, setUserLocation] = React.useState(null); const [sortByDistance, setSortByDistance] = React.useState(false); @@ -47,11 +50,9 @@ export function PlacesMap({ places }: PlacesMapProps) { const cities = React.useMemo(() => getCities(places), [places]); - // Restore filters and the focused pin from the URL on first load. + // Mark hydrated after first paint so the URL-sync effect below doesn't + // overwrite the URL before state has settled. React.useEffect(() => { - const state = parseMapState(window.location.search); - setFilters({ types: state.types, city: state.city, query: "" }); - setFocusId(state.placeId); hydrated.current = true; }, []);