Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 19 additions & 3 deletions src/components/map/filter-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,13 +18,15 @@ import {
export interface PlaceFilters {
types: PlaceType[];
city: City | null;
query: string;
}

interface FilterPanelProps {
filters: PlaceFilters;
cities: City[];
onChange: (filters: PlaceFilters) => void;
resultCount: number;
typeCounts: Record<PlaceType, number>;
}

function toggle<T>(list: T[], value: T): T[] {
Expand All @@ -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 (
<div className="flex max-h-[70vh] w-full flex-col gap-3 overflow-y-auto">
<Input
type="search"
placeholder="Search places..."
value={filters.query}
onChange={(e) => onChange({ ...filters, query: e.target.value })}
className="h-8 text-sm"
aria-label="Search places by name or city"
/>

<div className="flex items-center justify-between">
<p className="text-sm font-semibold">
{resultCount} {resultCount === 1 ? "place" : "places"}
Expand All @@ -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
</Button>
Expand Down Expand Up @@ -97,7 +110,10 @@ export function FilterPanel({
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: PLACE_TYPE_COLORS[type] }}
/>
<span>{PLACE_TYPE_LABELS[type]}</span>
<span className="flex-1">{PLACE_TYPE_LABELS[type]}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{typeCounts[type]}
</span>
</label>
))}
</fieldset>
Expand Down
71 changes: 54 additions & 17 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
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";
Expand All @@ -32,25 +33,39 @@
}

export function PlacesMap({ places }: PlacesMapProps) {
const [filters, setFilters] = React.useState<PlaceFilters>({
types: [],
city: null,
const [filters, setFilters] = React.useState<PlaceFilters>(() => {
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<string | null>(() => {

Check warning on line 42 in src/components/map/places-map.tsx

View workflow job for this annotation

GitHub Actions / lint-typecheck

'setFocusId' is assigned a value but never used
if (typeof window === "undefined") return null;
return parseMapState(window.location.search).placeId ?? null;
});
const [focusId, setFocusId] = React.useState<string | null>(null);
const [panelOpen, setPanelOpen] = React.useState(false);
const [userLocation, setUserLocation] = React.useState<LatLng | null>(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;
Expand All @@ -63,10 +78,18 @@
}, [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<PlaceType, number>;
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(
Expand All @@ -86,11 +109,13 @@
.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 (
<div className="relative size-full overflow-hidden">
<MapErrorBoundary>
Expand Down Expand Up @@ -145,6 +170,7 @@
cities={cities}
onChange={setFilters}
resultCount={visible.length}
typeCounts={typeCounts}
/>

<Separator className="my-3" />
Expand All @@ -161,11 +187,22 @@
</Button>
</div>

{nearest.length > 0 && (
{byDistance.length > 0 && (
<div className="mt-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Nearest to you
</p>
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{sortByDistance
? `All ${byDistance.length} - nearest first`
: "Nearest to you"}
</p>
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setSortByDistance((s) => !s)}
>
{sortByDistance ? "Show fewer" : "Show all"}
</button>
</div>
<ul className="space-y-1.5">
{nearest.map((place) => (
<li
Expand Down
9 changes: 8 additions & 1 deletion src/lib/places.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,22 @@ 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;
}
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;
});
}
Expand Down
Loading