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
51 changes: 51 additions & 0 deletions .github/ISSUE_TEMPLATE/verify-place.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Verify a place
description: Re-check an existing place on the map (address, entrance, open status). A smaller ask than adding a new place.
title: "[verify] "
labels: ["verification"]
body:
- type: markdown
attributes:
value: |-
Thanks for re-checking a place. Picking a place whose listing is stale
(older than the expiry window: 12 months, 6 months for exam centres)
is most valuable. When the place checks out, the maintainer adds the
`verified` block to its record and it earns a badge on the map.
- type: input
id: place_id
attributes:
label: Place id
description: From the dataset file (data/places/<type>.json) or the map share link (`/map?place=...`).
placeholder: mum-library-07
validations:
required: true
- type: input
id: checked_on
attributes:
label: Date you checked it (YYYY-MM-DD)
placeholder: "2026-08-14"
validations:
required: true
- type: textarea
id: findings
attributes:
label: What you found
description: Is the address/entrance still correct? Is it still open and operating? Any change (name, address, closed) means the record should be updated instead — say so here.
placeholder: Address matches Google Maps, entrance unchanged, still open as of today.
validations:
required: true
- type: input
id: github_handle
attributes:
label: Your GitHub handle
placeholder: your-handle
validations:
required: true
- type: checkboxes
id: acknowledgements
attributes:
label: Quality gate
options:
- label: I physically visited or checked this place on Google Maps this month.
required: true
- label: The listing's address and entrance match what is on the ground.
required: true
25 changes: 25 additions & 0 deletions data/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ below is also machine-enforced by [`places.schema.json`](places.schema.json), wh
- `address`: optional, short, human-readable
- `gmaps_link`: Google Maps share link (Share -> Copy link)
- Do not add rating, review count, or verified date to the JSON. Those go in the PR.
- `verified` is the one exception (#126): a maintainer may add it after a
contributor re-checks a place. See [Verification](#verification) below.

## Verification

Places can carry an optional `verified` block recording that a contributor
re-checked the place:

```json
"verified": {
"by": "your-github-handle",
"on": "2026-08-14"
}
```

- `by` is the verifier's GitHub username; `on` is the ISO date (YYYY-MM-DD)
they checked it.
- A verification stays valid for **12 months** — or **6 months** for exam
centres (`sat_centre` / `foreign_lang_exam_centre`), where a stale address
is worse than no address. After the window, the place should be verified
again or the block removed.
- Verified places show a small badge on the map and in lists; unverified
places look exactly as before.
- `npm run validate` fails on a malformed `verified` block (missing or empty
`by`, a non-date or impossible `on`, or extra keys).

## Quality gate (must pass before merge)

Expand Down
18 changes: 18 additions & 0 deletions data/places.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@
"minLength": 1,
"description": "GitHub username of the contributor"
},
"verified": {
"type": "object",
"description": "Optional proof a contributor re-checked this place. Verified places show a badge. Expiry: 12 months, or 6 months for exam centres (sat_centre / foreign_lang_exam_centre); see data/CONTRIBUTING.md.",
"additionalProperties": false,
"required": ["by", "on"],
"properties": {
"by": {
"type": "string",
"minLength": 1,
"description": "GitHub username of the verifier"
},
"on": {
"type": "string",
"format": "date",
"description": "ISO date (YYYY-MM-DD) the place was last verified"
}
}
},
"exam": {
"type": "string",
"description": "Optional. Only for sat_centre / foreign_lang_exam_centre, e.g. \"SAT\", \"IELTS\", \"Goethe-Zertifikat (A1-C2)\""
Expand Down
31 changes: 31 additions & 0 deletions scripts/validate-places.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,37 @@ for (const file of files) {
}
}

// Optional `verified` object: must carry a non-empty verifier handle and a
// real YYYY-MM-DD date, and nothing else (see #126).
if (r.verified !== undefined) {
if (r.verified === null || typeof r.verified !== "object" || Array.isArray(r.verified)) {
err(loc, `verified must be an object with "by" and "on", got ${JSON.stringify(r.verified)}`);
} else {
const v = r.verified;
for (const key of Object.keys(v)) {
if (key !== "by" && key !== "on") {
err(loc, `verified has unknown field "${key}" — allowed: by, on`);
}
}
if (typeof v.by !== "string" || v.by.trim() === "") {
err(loc, `verified.by must be a non-empty GitHub username`);
}
if (typeof v.on !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v.on)) {
err(loc, `verified.on must be an ISO date (YYYY-MM-DD), got "${v.on}"`);
} else {
const d = new Date(`${v.on}T00:00:00Z`);
const valid = !Number.isNaN(d.getTime()) &&
d.toISOString().slice(0, 10) === v.on;
if (!valid) {
err(loc, `verified.on "${v.on}" is not a real calendar date`);
}
}
if (typeof v.by === "string" && v.by.includes("—")) {
err(loc, `verified.by contains an em dash (—) — use a plain hyphen instead`);
}
}
}

fileErrors += totalErrors - before;
}

Expand Down
45 changes: 43 additions & 2 deletions src/components/map/results-list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { ResultsList } from "@/components/map/results-list";

afterEach(cleanup);

describe("ResultsList empty state", () => {
it("describes active filters and offers reset and contribution actions", () => {
const onReset = vi.fn();
Expand Down Expand Up @@ -31,3 +33,42 @@ describe("ResultsList empty state", () => {
expect(onSuggest).toHaveBeenCalledOnce();
});
});

describe("ResultsList verified badge", () => {
const place = {
id: "mum-library-07",
name: "City Library",
type: "library" as const,
city: "mumbai",
lat: 19.0176,
lng: 72.8562,
gmaps_link: "https://maps.google.com/?q=19.0176,72.8562",
added_by: "someone",
};

it("shows a badge for verified places", () => {
render(
<ResultsList
header="All places"
rows={[{ place: { ...place, verified: { by: "verifier", on: "2026-08-01" } } }]}
emptyState={{ activeFilters: [], onReset: vi.fn(), onSuggest: vi.fn() }}
onSelect={vi.fn()}
/>,
);
expect(screen.getByText("Verified")).toBeTruthy();
});

it("shows no badge for unverified places", () => {
render(
<ResultsList
header="All places"
rows={[{ place }]}
emptyState={{ activeFilters: [], onReset: vi.fn(), onSuggest: vi.fn() }}
onSelect={vi.fn()}
/>,
);
const row = screen.getByText("City Library").closest("li");
expect(row).not.toBeNull();
expect(within(row as HTMLElement).queryByText("Verified")).toBeNull();
});
});
8 changes: 6 additions & 2 deletions src/components/map/results-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PLACE_TYPE_COLORS } from "@/lib/map";
import { directionsUrl } from "@/lib/map";
import { formatDistance } from "@/lib/geo";
import { Button } from "@/components/ui/button";
import { VerifiedBadge } from "@/components/pins/verified-badge";

export interface ResultRow {
place: Place;
Expand Down Expand Up @@ -94,8 +95,11 @@ export function ResultsList({
className="min-w-0 flex-1 text-left"
title={`${place.name} (${PLACE_TYPE_LABELS[place.type]})`}
>
<span className="block truncate text-sm text-foreground">
{place.name}
<span className="flex items-center gap-1.5">
<span className="block truncate text-sm text-foreground">
{place.name}
</span>
<VerifiedBadge place={place} />
</span>
<span className="block truncate text-xs text-muted-foreground">
{PLACE_TYPE_LABELS[place.type]}
Expand Down
2 changes: 2 additions & 0 deletions src/components/pins/pin-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Place } from "@/lib/types";
import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types";
import { directionsUrl, PLACE_TYPE_COLORS } from "@/lib/map";
import { buildShareUrl } from "@/lib/share";
import { VerifiedBadge } from "@/components/pins/verified-badge";

interface PinPopupProps {
place: Place;
Expand Down Expand Up @@ -47,6 +48,7 @@ export function PinPopup({ place }: PinPopupProps) {
<span className="text-muted-foreground">{PLACE_TYPE_LABELS[place.type]}</span>
</div>
<span className="text-muted-foreground">{humanizeCity(place.city)}</span>
<VerifiedBadge place={place} />
</div>
</div>

Expand Down
31 changes: 31 additions & 0 deletions src/components/pins/verified-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BadgeCheck } from "lucide-react";

import type { Place } from "@/lib/types";

/** Human-readable form of the verification date, e.g. "Jun 2026". */
export function formatVerifiedOn(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
return new Date(iso + "T00:00:00").toLocaleDateString("en-IN", {
month: "short",
year: "numeric",
});
}

/** Subtle badge for places a contributor re-checked. Absent for unverified places. */
export function VerifiedBadge({ place }: { place: Place }) {
if (!place.verified) return null;
return (
<span
title={`Verified by ${place.verified.by} · ${place.verified.on}`}
className="inline-flex items-center gap-1 rounded-full border border-success/25 bg-success/10 px-2 py-0.5 text-[10px] font-medium text-success"
>
<BadgeCheck className="size-3" aria-hidden />
Verified
{place.verified.on && (
<span className="font-normal text-success/70">
{formatVerifiedOn(place.verified.on)}
</span>
)}
</span>
);
}
15 changes: 13 additions & 2 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ export function humanizeCity(city: City): string {
.join(" ");
}

/** Who verified a place and when, for the optional `verified` badge. */
export interface Verified {
/** GitHub username of the verifier. */
by: string;
/** ISO date (YYYY-MM-DD) the place was last verified. */
on: string;
}

/**
* A public place pin. This is the entire committed record shape.
* Proof of quality (source citation, Google Maps rating and review count,
* verified date) lives in the contribution PR, not in this dataset.
* Proof of quality (source citation, Google Maps rating and review count)
* lives in the contribution PR, not in this dataset; the `verified` field is
* the one exception (#126).
*/
export interface Place {
id: string;
Expand All @@ -49,6 +58,8 @@ export interface Place {
address?: string;
gmaps_link: string;
added_by: string;
/** Optional proof a contributor re-checked this place; verified places show a badge. */
verified?: Verified;
/** Exam this place is a centre for, e.g. "SAT", "Goethe-Zertifikat (A1-C2)". */
exam?: string;
/** ISO date the centre's exam/address validity should be reconfirmed by. */
Expand Down
Loading