diff --git a/.github/ISSUE_TEMPLATE/verify-place.yml b/.github/ISSUE_TEMPLATE/verify-place.yml new file mode 100644 index 0000000..d26fe36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/verify-place.yml @@ -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/.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 diff --git a/data/CONTRIBUTING.md b/data/CONTRIBUTING.md index a4d5fc3..d7e3585 100644 --- a/data/CONTRIBUTING.md +++ b/data/CONTRIBUTING.md @@ -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) diff --git a/data/places.schema.json b/data/places.schema.json index c217859..122b0a7 100644 --- a/data/places.schema.json +++ b/data/places.schema.json @@ -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)\"" diff --git a/scripts/validate-places.mjs b/scripts/validate-places.mjs index 33e04bd..f3389fb 100644 --- a/scripts/validate-places.mjs +++ b/scripts/validate-places.mjs @@ -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; } diff --git a/src/components/map/results-list.test.tsx b/src/components/map/results-list.test.tsx index dba5b56..e408ca7 100644 --- a/src/components/map/results-list.test.tsx +++ b/src/components/map/results-list.test.tsx @@ -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(); @@ -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( + , + ); + expect(screen.getByText("Verified")).toBeTruthy(); + }); + + it("shows no badge for unverified places", () => { + render( + , + ); + const row = screen.getByText("City Library").closest("li"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).queryByText("Verified")).toBeNull(); + }); +}); diff --git a/src/components/map/results-list.tsx b/src/components/map/results-list.tsx index 057946e..4e4481f 100644 --- a/src/components/map/results-list.tsx +++ b/src/components/map/results-list.tsx @@ -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; @@ -94,8 +95,11 @@ export function ResultsList({ className="min-w-0 flex-1 text-left" title={`${place.name} (${PLACE_TYPE_LABELS[place.type]})`} > - - {place.name} + + + {place.name} + + {PLACE_TYPE_LABELS[place.type]} diff --git a/src/components/pins/pin-popup.tsx b/src/components/pins/pin-popup.tsx index b093e36..9e710c5 100644 --- a/src/components/pins/pin-popup.tsx +++ b/src/components/pins/pin-popup.tsx @@ -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; @@ -47,6 +48,7 @@ export function PinPopup({ place }: PinPopupProps) { {PLACE_TYPE_LABELS[place.type]} {humanizeCity(place.city)} + diff --git a/src/components/pins/verified-badge.tsx b/src/components/pins/verified-badge.tsx new file mode 100644 index 0000000..3b65f09 --- /dev/null +++ b/src/components/pins/verified-badge.tsx @@ -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 ( + + + Verified + {place.verified.on && ( + + {formatVerifiedOn(place.verified.on)} + + )} + + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index dc8e495..f9fe90f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; @@ -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. */