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 ### 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. the rest of a space is, so that I can find the people near me.
Acceptance: Acceptance:
- The location field on the profile form searches places by name, leaves out - The location field on the profile form searches places by name, offers cities,
towns of under a thousand people, and publishes what was picked to kind 0. states and countries, and publishes what was picked to kind 0.
- Choosing a town publishes a geohash with it; choosing a state or a country - 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. 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 - 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. 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. // Transcription and speech both go here, against whichever key the user has saved.
const OPENROUTER_ORIGIN = "https://openrouter.ai" 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. // .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
const CHECKOUT_ORIGIN = "https://checkout.test" 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 = { export type GeocodedPlace = {
type: string
name: string name: string
admin1?: string state?: string
country?: string country?: string
feature_code: string
population?: number
latitude: number latitude: number
longitude: number longitude: number
} }
/** The place search, answering every term with the same places. */ /** The place search behind the profile's location field, answering every term with the same places. */
export const mockGeocoding = (context: BrowserContext, results: GeocodedPlace[]) => export const mockGeocoding = (context: BrowserContext, places: GeocodedPlace[]) =>
context.route(`${GEOCODING_ORIGIN}/**`, route => route.fulfill({json: {results}})) 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. */ /** 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) => 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(), [ await mockGeocoding(page.context(), [
{ {
type: "city",
name: "Portland", name: "Portland",
admin1: "Oregon", state: "Oregon",
country: "United States", country: "United States",
feature_code: "PPLA2",
population: 652503,
latitude: 45.52345, latitude: 45.52345,
longitude: -122.67621, longitude: -122.67621,
}, },
{ {
name: "Portlaw", type: "state",
admin1: "Munster", name: "Oregon",
country: "Ireland", country: "United States",
feature_code: "PPL", latitude: 43.93179,
population: 900, longitude: -120.76562,
latitude: 52.28833, },
longitude: -7.32056, {
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() 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. // A state is a place to be from; a street is not.
await expect(form.getByRole("button", {name: "Portlaw, Munster, Ireland"})).toHaveCount(0) 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: "Portland, Oregon, United States"}).click()
await form.getByRole("button", {name: "Save Changes"}).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) 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)) await page.goto(profilePath(users.alice.pubkey))
const secondForm = await openEditForm() 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 expect(secondForm.getByText("Portland, Oregon, United States")).toBeVisible()
await secondForm.getByRole("button", {name: "Clear location"}).click() await secondForm.getByRole("button", {name: "Clear location"}).click()
await secondForm.getByPlaceholder("Search for a town").fill("portl") await secondForm.getByPlaceholder("Search for a city").fill("oreg")
await secondForm.getByRole("button", {name: "Portland, Oregon, United States"}).click() await secondForm.getByRole("button", {name: "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.getByRole("button", {name: "Save Changes"}).click() await secondForm.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!") await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
const republished = getPublishedEvents(page.context(), PROFILE) 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") expect(republished[1].tags.map(tag => tag[0])).not.toContain("g")
}) })

View file

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

View file

@ -111,7 +111,7 @@
<InputLocation bind:location={values.profile.location} bind:geohash={values.geohash} /> <InputLocation bind:location={values.profile.location} bind:geohash={values.geohash} />
{/snippet} {/snippet}
{#snippet info()} {#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} {/snippet}
</Field> </Field>
{#if !isSignup} {#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 {tagSpec, tagValue} from "@welshman/util"
import type {ProfileReader} from "@welshman/domain" import type {ProfileReader} from "@welshman/domain"
import {encodeGeohash} from "@lib/geohash" import {encodeGeohash} from "@lib/geohash"
export type Place = { export type Place = {
city?: string type: "city" | "state" | "country"
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 = {
name: string name: string
admin1?: string state?: string
country?: string country?: string
feature_code: string
population?: number
latitude: number latitude: number
longitude: number longitude: number
} }
// Open-Meteo indexes GeoNames' populated places and countries, never a state on its own. type GeocodingFeature = {
export const searchPlaces = async (term: string) => { geometry: {coordinates: [number, number]}
const url = new URL("https://geocoding-api.open-meteo.com/v1/search") properties: {type: string; name: string; state?: string; country?: string}
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
} }
export const placeGranularities = (place: Place) => { const isPlaceType = (type: string): type is Place["type"] =>
const granularities: Granularity[] = [] type === "city" || type === "state" || type === "country"
if (place.city) { const toPlace = ({geometry, properties}: GeocodingFeature): Place | undefined => {
granularities.push("city") const {type, name, state, country} = properties
} const [longitude, latitude] = geometry.coordinates
if (place.region) { return isPlaceType(type) ? {type, name, state, country, latitude, longitude} : undefined
granularities.push("region")
}
granularities.push("country")
return granularities
} }
export const displayPlace = (place: Place, granularity: Granularity) => export const displayPlace = (place: Place) =>
removeUndefined([ removeUndefined([
granularity === "city" ? place.city : undefined, place.name,
granularity === "country" ? undefined : place.region, place.state,
place.country, place.type === "country" ? undefined : place.country,
]).join(", ") ]).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. // 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) => export const placeGeohash = (place: Place) =>
granularity === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined place.type === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
export const getProfileLocation = (profile?: ProfileReader): string | undefined => export const getProfileLocation = (profile?: ProfileReader): string | undefined =>
profile?.values.location profile?.values.location