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 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: diff --git a/src/components/map/filter-panel.tsx b/src/components/map/filter-panel.tsx index f30293b..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 { @@ -24,6 +26,7 @@ interface FilterPanelProps { cities: City[]; onChange: (filters: PlaceFilters) => void; resultCount: number; + typeCounts: Record; } function toggle(list: T[], value: T): T[] { @@ -37,11 +40,21 @@ export function FilterPanel({ cities, onChange, 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"} @@ -51,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 @@ -97,7 +110,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..e6c60a3 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"; @@ -32,25 +33,39 @@ interface PlacesMapProps { } export function PlacesMap({ places }: PlacesMapProps) { - const [filters, setFilters] = React.useState({ - types: [], - city: null, + 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(() => { + if (typeof window === "undefined") return null; + return parseMapState(window.location.search).placeId ?? null; }); - 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]); - // 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 }); - 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; @@ -63,10 +78,18 @@ 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(() => { + 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( @@ -86,11 +109,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 (

@@ -145,6 +170,7 @@ export function PlacesMap({ places }: PlacesMapProps) { cities={cities} onChange={setFilters} resultCount={visible.length} + typeCounts={typeCounts} /> @@ -161,11 +187,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) => (
  • { 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; }); }