Search states and countries for the profile location
This commit is contained in:
parent
eef6c19129
commit
c84077b86e
6 changed files with 95 additions and 152 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue