Search states and countries for the profile location

This commit is contained in:
Coracle-Bot 2026-09-24 20:26:36 +00:00
parent eef6c19129
commit c84077b86e
6 changed files with 95 additions and 152 deletions

View file

@ -1560,14 +1560,14 @@ Acceptance:
### US-131 — Say where you are and find members on a map
As a member, I want to give a town, state or country on my profile and see where
As a member, I want to give a city, state or country on my profile and see where
the rest of a space is, so that I can find the people near me.
Acceptance:
- The location field on the profile form searches places by name, leaves out
towns of under a thousand people, and publishes what was picked to kind 0.
- Choosing a town publishes a geohash with it; choosing a state or a country
- The location field on the profile form searches places by name, offers cities,
states and countries, and publishes what was picked to kind 0.
- Choosing a city publishes a geohash with it; choosing a state or a country
publishes the name alone, so nothing finer than the answer given goes out.
- The directory offers a map whenever a member has a geohash, pins each of them
with their avatar, and opens a pin's profile when it is clicked.

View file

@ -19,9 +19,6 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Transcription and speech both go here, against whichever key the user has saved.
const OPENROUTER_ORIGIN = "https://openrouter.ai"
// The place search behind the profile's location field, in src/app/location.ts.
const GEOCODING_ORIGIN = "https://geocoding-api.open-meteo.com"
// .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
const CHECKOUT_ORIGIN = "https://checkout.test"
@ -618,20 +615,28 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
},
)
// One row of Open-Meteo's answer. `feature_code` is PCLI for a country and PPL* for a town.
// One row of Photon's answer. Anything but a city, state or country is left out of the results.
export type GeocodedPlace = {
type: string
name: string
admin1?: string
state?: string
country?: string
feature_code: string
population?: number
latitude: number
longitude: number
}
/** The place search, answering every term with the same places. */
export const mockGeocoding = (context: BrowserContext, results: GeocodedPlace[]) =>
context.route(`${GEOCODING_ORIGIN}/**`, route => route.fulfill({json: {results}}))
/** The place search behind the profile's location field, answering every term with the same places. */
export const mockGeocoding = (context: BrowserContext, places: GeocodedPlace[]) =>
context.route("https://photon.komoot.io/**", route =>
route.fulfill({
json: {
features: places.map(({type, name, state, country, latitude, longitude}) => ({
geometry: {coordinates: [longitude, latitude]},
properties: {type, name, state, country},
})),
},
}),
)
/** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */
export const mockImages = (context: BrowserContext) =>

View file

@ -652,22 +652,27 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
await mockGeocoding(page.context(), [
{
type: "city",
name: "Portland",
admin1: "Oregon",
state: "Oregon",
country: "United States",
feature_code: "PPLA2",
population: 652503,
latitude: 45.52345,
longitude: -122.67621,
},
{
name: "Portlaw",
admin1: "Munster",
country: "Ireland",
feature_code: "PPL",
population: 900,
latitude: 52.28833,
longitude: -7.32056,
type: "state",
name: "Oregon",
country: "United States",
latitude: 43.93179,
longitude: -120.76562,
},
{
type: "street",
name: "Portland Avenue",
state: "Oregon",
country: "United States",
latitude: 45.51,
longitude: -122.65,
},
])
@ -679,10 +684,13 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
const form = await openEditForm()
await form.getByPlaceholder("Search for a town").fill("portl")
await form.getByPlaceholder("Search for a city").fill("portl")
// A town of under a thousand people is not somewhere to be from.
await expect(form.getByRole("button", {name: "Portlaw, Munster, Ireland"})).toHaveCount(0)
// A state is a place to be from; a street is not.
await expect(form.getByRole("button", {name: "Oregon, United States"})).toBeVisible()
await expect(
form.getByRole("button", {name: "Portland Avenue, Oregon, United States"}),
).toHaveCount(0)
await form.getByRole("button", {name: "Portland, Oregon, United States"}).click()
await form.getByRole("button", {name: "Save Changes"}).click()
@ -725,7 +733,7 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
await expect(pins).toHaveCount(0)
// A country is not a pin, so choosing one takes alice off the map.
// A state is not a pin, so choosing one takes alice off the map.
await page.goto(profilePath(users.alice.pubkey))
const secondForm = await openEditForm()
@ -733,18 +741,14 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
await expect(secondForm.getByText("Portland, Oregon, United States")).toBeVisible()
await secondForm.getByRole("button", {name: "Clear location"}).click()
await secondForm.getByPlaceholder("Search for a town").fill("portl")
await secondForm.getByRole("button", {name: "Portland, Oregon, United States"}).click()
await secondForm.getByLabel("How much of your location to show").selectOption({label: "Country"})
await expect(secondForm.getByText("United States", {exact: true})).toBeVisible()
await secondForm.getByPlaceholder("Search for a city").fill("oreg")
await secondForm.getByRole("button", {name: "Oregon, United States"}).click()
await secondForm.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
const republished = getPublishedEvents(page.context(), PROFILE)
expect(JSON.parse(republished[1].content).location).toBe("United States")
expect(JSON.parse(republished[1].content).location).toBe("Oregon, United States")
expect(republished[1].tags.map(tag => tag[0])).not.toContain("g")
})

View file

@ -1,19 +1,12 @@
<script lang="ts">
import {debounce} from "throttle-debounce"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import {
displayPlace,
granularityLabels,
placeGeohash,
placeGranularities,
searchPlaces,
} from "@app/location"
import type {Granularity, Place} from "@app/location"
import {displayPlace, placeGeohash, searchPlaces} from "@app/location"
import type {Place} from "@app/location"
type Props = {
location?: string
@ -22,13 +15,6 @@
let {location = $bindable(), geohash = $bindable()}: Props = $props()
const fullName = (candidate: Place) => displayPlace(candidate, placeGranularities(candidate)[0])
const apply = (picked: Place) => {
location = displayPlace(picked, granularity)
geohash = placeGeohash(picked, granularity)
}
const search = debounce(400, async (value: string) => {
try {
const places = await searchPlaces(value)
@ -59,21 +45,13 @@
}
const pick = (picked: Place) => {
place = picked
granularity = placeGranularities(picked)[0]
location = displayPlace(picked)
geohash = placeGeohash(picked)
term = ""
results = []
apply(picked)
}
const changeGranularity = () => {
if (place) {
apply(place)
}
}
const clear = () => {
place = undefined
location = undefined
geohash = undefined
}
@ -82,8 +60,6 @@
let results: Place[] = $state([])
let loading = $state(false)
let failed = $state(false)
let place: Place | undefined = $state()
let granularity: Granularity = $state("city")
let list: HTMLElement | undefined = $state()
// The field sits deep enough in the form that results can open below the fold.
@ -95,50 +71,37 @@
</script>
{#if location}
<div class="flex flex-col gap-2">
<div class="input flex w-full items-center gap-2">
<Icon icon={MapPoint} />
<span class="min-w-0 grow truncate">{location}</span>
<Button
class="button button-ghost button-xs button-square"
aria-label="Clear location"
onclick={clear}>
<Icon size={4} icon={CloseCircle} />
</Button>
</div>
{#if place}
<select
class="select input"
aria-label="How much of your location to show"
bind:value={granularity}
onchange={changeGranularity}>
{#each placeGranularities(place) as option (option)}
<option value={option}>{granularityLabels[option]}</option>
{/each}
</select>
{/if}
<div class="input flex w-full items-center gap-2">
<Icon icon={MapPoint} />
<span class="min-w-0 grow truncate">{location}</span>
<Button
class="button button-ghost button-xs button-square"
aria-label="Clear location"
onclick={clear}>
<Icon size={4} icon={CloseCircle} />
</Button>
</div>
{:else}
<div class="flex flex-col gap-2">
<label class="input flex w-full items-center gap-2">
<Icon icon={Magnifier} />
<Icon icon={MapPoint} />
<input
bind:value={term}
oninput={onInput}
class="min-w-0 grow"
type="text"
placeholder="Search for a town, city, or country..." />
placeholder="Search for a city, state, or country..." />
<Spinner {loading} />
</label>
{#if failed}
<p class="text-sm text-error">Location search is unavailable right now.</p>
{:else if results.length > 0}
<ul bind:this={list} class="menu bg-surface max-h-60 gap-1 overflow-auto rounded-2xl p-2">
{#each results as result (result.latitude + "," + result.longitude)}
{#each results as result (displayPlace(result))}
<li>
<Button class="w-full text-left" onclick={() => pick(result)}>
<Icon size={4} icon={MapPoint} />
{fullName(result)}
{displayPlace(result)}
</Button>
</li>
{/each}

View file

@ -111,7 +111,7 @@
<InputLocation bind:location={values.profile.location} bind:geohash={values.geohash} />
{/snippet}
{#snippet info()}
A town, state or country. Only a town puts you on a space's member map.
A city, state or country. Only a city puts you on a space's member map.
{/snippet}
</Field>
{#if !isSignup}

View file

@ -1,86 +1,57 @@
import {removeUndefined} from "@welshman/lib"
import {removeUndefined, uniqBy} from "@welshman/lib"
import {tagSpec, tagValue} from "@welshman/util"
import type {ProfileReader} from "@welshman/domain"
import {encodeGeohash} from "@lib/geohash"
export type Place = {
city?: string
region?: string
country: string
latitude: number
longitude: number
}
export type Granularity = "city" | "region" | "country"
export const granularityLabels: Record<Granularity, string> = {
city: "City",
region: "State or region",
country: "Country",
}
type GeocodingResult = {
type: "city" | "state" | "country"
name: string
admin1?: string
state?: string
country?: string
feature_code: string
population?: number
latitude: number
longitude: number
}
// Open-Meteo indexes GeoNames' populated places and countries, never a state on its own.
export const searchPlaces = async (term: string) => {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search")
url.searchParams.set("name", term)
url.searchParams.set("count", "20")
url.searchParams.set("language", "en")
url.searchParams.set("format", "json")
const response = await fetch(url)
const {results = []}: {results?: GeocodingResult[]} = await response.json()
const places: Place[] = []
for (const result of results) {
const {name, admin1, country, feature_code, population, latitude, longitude} = result
if (feature_code === "PCLI") {
places.push({country: name, latitude, longitude})
} else if (country && population && population >= 1000) {
places.push({city: name, region: admin1, country, latitude, longitude})
}
}
return places
type GeocodingFeature = {
geometry: {coordinates: [number, number]}
properties: {type: string; name: string; state?: string; country?: string}
}
export const placeGranularities = (place: Place) => {
const granularities: Granularity[] = []
const isPlaceType = (type: string): type is Place["type"] =>
type === "city" || type === "state" || type === "country"
if (place.city) {
granularities.push("city")
}
const toPlace = ({geometry, properties}: GeocodingFeature): Place | undefined => {
const {type, name, state, country} = properties
const [longitude, latitude] = geometry.coordinates
if (place.region) {
granularities.push("region")
}
granularities.push("country")
return granularities
return isPlaceType(type) ? {type, name, state, country, latitude, longitude} : undefined
}
export const displayPlace = (place: Place, granularity: Granularity) =>
export const displayPlace = (place: Place) =>
removeUndefined([
granularity === "city" ? place.city : undefined,
granularity === "country" ? undefined : place.region,
place.country,
place.name,
place.state,
place.type === "country" ? undefined : place.country,
]).join(", ")
// Photon indexes OpenStreetMap for autocomplete; osm_tag=place keeps streets and buildings out.
export const searchPlaces = async (term: string) => {
const url = new URL("https://photon.komoot.io/api/")
url.searchParams.set("q", term)
url.searchParams.set("limit", "15")
url.searchParams.set("lang", "en")
url.searchParams.set("osm_tag", "place")
const response = await fetch(url)
const {features = []}: {features?: GeocodingFeature[]} = await response.json()
return uniqBy(displayPlace, removeUndefined(features.map(toPlace)))
}
// A pin is finer than a state or a country asked to be, so only a city publishes one.
export const placeGeohash = (place: Place, granularity: Granularity) =>
granularity === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
export const placeGeohash = (place: Place) =>
place.type === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
export const getProfileLocation = (profile?: ProfileReader): string | undefined =>
profile?.values.location