feat: scale request discovery by map viewport

This commit is contained in:
SimpleTest 2026-07-22 16:37:34 +03:00
parent 3dfdf7a360
commit 6f22657731
18 changed files with 2202 additions and 227 deletions

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ who_need_help-*.tar
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
/e2e/node_modules/ /e2e/node_modules/
/e2e/output/
/.tools/ /.tools/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -45,8 +45,11 @@ local Codex CLI authenticated with their ChatGPT subscription.
only the linked evidence to audited moderators. only the linked evidence to audited moderators.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel - Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths. and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix - PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side
PubSub/Presence, and optional consent-driven live location sharing. map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence,
and optional consent-driven live location sharing. The browser loads only
the chosen map area; exact points, privacy areas, and coordinate-free
requests retain their distinct disclosure rules.
- Handover code plus both-party confirmation before verified completion. - Handover code plus both-party confirmation before verified completion.
- Double-blind reviews, public trust summaries, and a helper leaderboard that - Double-blind reviews, public trust summaries, and a helper leaderboard that
prioritizes unique location-supported and handover-verified counterparts prioritizes unique location-supported and handover-verified counterparts

View File

@ -158,6 +158,184 @@ html {
var(--color-base-200); var(--color-base-200);
} }
.request-discovery-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1rem;
border: 1px solid var(--color-base-300);
border-radius: 1rem;
background: var(--color-base-100);
}
.request-view-switcher {
display: inline-flex;
flex: none;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 0.85rem;
background: var(--color-base-200);
}
.request-view-switcher .btn {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.request-view-switcher .btn-active {
color: var(--color-base-content);
background: var(--color-base-100);
box-shadow: 0 1px 5px color-mix(in oklab, var(--color-base-content) 12%, transparent);
}
.request-discovery-content {
display: grid;
gap: 1.25rem;
margin-top: 1rem;
}
.request-discovery[data-view-mode="split"] .request-discovery-content {
grid-template-columns: minmax(20rem, 0.78fr) minmax(0, 1.22fr);
}
.request-discovery[data-view-mode="list"][data-has-viewport="true"] .request-discovery-map-panel,
.request-discovery[data-view-mode="map"] .request-results-list {
display: none;
}
.request-results-list {
min-width: 0;
}
.request-result-card {
transition:
border-color 150ms ease,
box-shadow 150ms ease,
transform 150ms ease;
}
.request-result-card:hover,
.request-result-card.is-selected {
border-color: var(--color-success);
box-shadow: 0 10px 28px color-mix(in oklab, var(--color-success) 14%, transparent);
transform: translateY(-1px);
}
.request-discovery-map-panel {
min-width: 0;
}
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
position: sticky;
top: 1rem;
align-self: start;
}
.request-discovery-map-shell {
position: relative;
}
.request-discovery-map {
min-height: min(68vh, 46rem);
}
.request-discovery[data-view-mode="map"] .request-discovery-map {
min-height: min(74vh, 52rem);
}
.request-map-search-button {
position: absolute;
z-index: 4;
top: 1rem;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
.request-map-cluster {
display: grid;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.35rem;
place-items: center;
color: white;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
border: 3px solid white;
border-radius: 9999px;
background: #16725b;
box-shadow: 0 3px 12px rgb(0 0 0 / 24%);
cursor: pointer;
}
.request-map-cluster.is-urgent {
background: #b63b21;
}
.request-map-cluster:hover,
.request-map-cluster:focus-visible {
transform: scale(1.08);
}
.maplibregl-marker.is-selected svg {
filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white);
transform: scale(1.12);
}
.request-map-popup {
display: grid;
min-width: 11rem;
gap: 0.35rem;
}
.request-map-popup small {
color: #555;
}
.request-map-popup a {
margin-top: 0.25rem;
color: #0a6b55;
font-weight: 700;
text-decoration: underline;
}
@media (max-width: 767px) {
.request-discovery-toolbar {
align-items: stretch;
flex-direction: column;
}
.request-view-switcher {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
}
.request-view-switcher .btn {
width: 100%;
}
.request-view-split {
display: none;
}
.request-discovery[data-view-mode="split"] .request-discovery-content {
display: block;
}
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
display: none;
}
.request-discovery-map,
.request-discovery[data-view-mode="map"] .request-discovery-map {
min-height: max(30rem, calc(100svh - 12rem));
}
}
.home-demo-map { .home-demo-map {
min-height: 32rem; min-height: 32rem;
border-radius: 2rem; border-radius: 2rem;

View File

@ -17,7 +17,7 @@ const defaultStyle = {
const markerPoints = element => { const markerPoints = element => {
try { try {
const points = JSON.parse(element.dataset.markers || "[]") const points = JSON.parse(element.dataset.items || element.dataset.markers || "[]")
if (!Array.isArray(points)) return [] if (!Array.isArray(points)) return []
@ -31,7 +31,8 @@ const markerPoints = element => {
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []}) const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => { const areaPolygon = point => {
const {longitude, latitude, radius_meters: radiusMeters} = point
const earthRadiusMeters = 6371008.8 const earthRadiusMeters = 6371008.8
const angularDistance = radiusMeters / earthRadiusMeters const angularDistance = radiusMeters / earthRadiusMeters
const latitudeRadians = latitude * Math.PI / 180 const latitudeRadians = latitude * Math.PI / 180
@ -54,7 +55,13 @@ const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
return { return {
type: "Feature", type: "Feature",
properties: {}, id: point.id,
properties: {
id: point.id,
title: point.title || "",
location: point.location || "",
type: point.type || "request"
},
geometry: {type: "Polygon", coordinates: [coordinates]} geometry: {type: "Polygon", coordinates: [coordinates]}
} }
} }
@ -66,6 +73,55 @@ const areaFeatureCollection = points => ({
.map(areaPolygon) .map(areaPolygon)
}) })
const clusterFeatureCollection = points => ({
type: "FeatureCollection",
features: points
.filter(point => point.type === "cluster")
.map(point => ({
type: "Feature",
id: point.id,
properties: {
id: point.id,
count: point.count,
urgent_count: point.urgent_count || 0
},
geometry: {type: "Point", coordinates: [point.longitude, point.latitude]}
}))
})
const mapViewport = map => {
const bounds = map.getBounds()
const container = map.getContainer()
if (container.clientWidth < 160 || container.clientHeight < 160) return null
return {
west: Math.max(-180, Math.min(180, bounds.getWest())),
south: Math.max(-85.05112878, Math.min(85.05112878, bounds.getSouth())),
east: Math.max(-180, Math.min(180, bounds.getEast())),
north: Math.max(-85.05112878, Math.min(85.05112878, bounds.getNorth())),
zoom: map.getZoom(),
width: Math.round(container.clientWidth),
height: Math.round(container.clientHeight)
}
}
const savedViewport = element => {
try {
const viewport = JSON.parse(element.dataset.viewport || "")
if (!viewport || typeof viewport !== "object") return null
const west = Number(viewport.west)
const south = Number(viewport.south)
const east = Number(viewport.east)
const north = Number(viewport.north)
if (![west, south, east, north].every(Number.isFinite)) return null
return {west, south, east, north}
} catch (_error) {
return null
}
}
const extendBoundsWithFeature = (bounds, feature) => { const extendBoundsWithFeature = (bounds, feature) => {
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate)) feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
} }
@ -85,10 +141,20 @@ const createAidMap = element => {
const state = { const state = {
element, element,
map: null, map: null,
active: true,
viewportFrame: null,
resizeFrame: null,
resizeObserver: null,
markers: [], markers: [],
markerElements: new Map(),
initializedViewport: false,
areaSourceId: `${element.id || "aid-map"}-areas`, areaSourceId: `${element.id || "aid-map"}-areas`,
areaFillId: `${element.id || "aid-map"}-area-fill`, areaFillId: `${element.id || "aid-map"}-area-fill`,
areaLineId: `${element.id || "aid-map"}-area-line` areaLineId: `${element.id || "aid-map"}-area-line`,
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
clusterSourceId: `${element.id || "aid-map"}-clusters`,
clusterCircleId: `${element.id || "aid-map"}-cluster-circle`,
clusterCountId: `${element.id || "aid-map"}-cluster-count`
} }
state.markLoading = () => { state.markLoading = () => {
@ -154,21 +220,92 @@ const createAidMap = element => {
state.element.replaceChildren(fallback) state.element.replaceChildren(fallback)
} }
state.popupContent = point => {
const content = document.createElement("div")
content.className = "request-map-popup"
const title = document.createElement("strong")
title.textContent = point.title || ""
content.append(title)
const location = document.createElement("small")
location.textContent = point.location || ""
content.append(location)
if (point.id) {
const link = document.createElement("a")
link.href = `/requests/${encodeURIComponent(point.id)}`
link.textContent = state.element.dataset.requestLabel || "Open request"
content.append(link)
}
return content
}
state.highlightRequest = id => {
state.markerElements.forEach((element, requestId) => {
element.classList.toggle("is-selected", requestId === id)
})
if (state.map?.getLayer(state.areaSelectedId)) {
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
}
}
state.selectRequest = id => {
state.highlightRequest(id)
window.dispatchEvent(new CustomEvent("wnh:request-selected", {detail: {id}}))
}
state.resize = () => {
if (!state.active || !state.map) return
const {width, height} = state.element.getBoundingClientRect()
if (width < 160 || height < 160) return
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeFrame = window.requestAnimationFrame(() => {
state.resizeFrame = null
if (!state.active || !state.map) return
const current = state.element.getBoundingClientRect()
if (current.width >= 160 && current.height >= 160) state.map.resize()
})
}
state.applySavedViewport = () => {
if (!state.active || !state.map) return
const viewport = savedViewport(state.element)
const serialized = viewport ? JSON.stringify(viewport) : ""
if (!viewport || state.appliedViewport === serialized) return
state.appliedViewport = serialized
state.map.fitBounds(
[[viewport.west, viewport.south], [viewport.east, viewport.north]],
{padding: 0, duration: 0}
)
}
state.renderMarkers = () => { state.renderMarkers = () => {
if (!state.map) return if (!state.map) return
state.markers.forEach(marker => marker.remove()) state.markers.forEach(marker => marker.remove())
state.markers = [] state.markers = []
state.markerElements.clear()
const points = markerPoints(state.element) const points = markerPoints(state.element)
const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters)) const exactPoints = points.filter(
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
)
const areaFeatures = areaFeatureCollection(points) const areaFeatures = areaFeatureCollection(points)
const clusterFeatures = clusterFeatureCollection(points)
if (state.map.isStyleLoaded()) { if (state.map.isStyleLoaded()) {
const source = state.map.getSource(state.areaSourceId) const areaSource = state.map.getSource(state.areaSourceId)
if (source) { if (areaSource) {
source.setData(areaFeatures) areaSource.setData(areaFeatures)
} else { } else {
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures}) state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
state.map.addLayer({ state.map.addLayer({
@ -187,28 +324,75 @@ const createAidMap = element => {
"line-dasharray": [2, 2] "line-dasharray": [2, 2]
} }
}) })
state.map.addLayer({
id: state.areaSelectedId,
type: "line",
source: state.areaSourceId,
filter: ["==", ["get", "id"], ""],
paint: {"line-color": "#d6573b", "line-width": 5}
})
state.map.on("mouseenter", state.areaFillId, () => {
state.map.getCanvas().style.cursor = "pointer"
})
state.map.on("mouseleave", state.areaFillId, () => {
state.map.getCanvas().style.cursor = ""
})
state.map.on("click", state.areaFillId, event => {
const properties = event.features?.[0]?.properties
if (!properties?.id) return
state.selectRequest(properties.id)
new maplibregl.Popup({offset: 12})
.setLngLat(event.lngLat)
.setDOMContent(state.popupContent(properties))
.addTo(state.map)
})
} }
} }
exactPoints.forEach(point => { exactPoints.forEach(point => {
const popupContent = document.createElement("div") const popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point))
const title = document.createElement("strong")
title.textContent = point.title || ""
popupContent.append(title, document.createElement("br"))
popupContent.append(document.createTextNode(point.location || ""))
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"}) const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
.setLngLat([point.longitude, point.latitude]) .setLngLat([point.longitude, point.latitude])
.setPopup(popup) .setPopup(popup)
.addTo(state.map) .addTo(state.map)
const markerElement = marker.getElement()
markerElement.dataset.requestId = point.id
markerElement.addEventListener("click", () => state.selectRequest(point.id))
state.markerElements.set(point.id, markerElement)
state.markers.push(marker) state.markers.push(marker)
}) })
if (points.length > 0) { points.filter(point => point.type === "cluster").forEach(point => {
const element = document.createElement("button")
element.type = "button"
element.className = "request-map-cluster"
element.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
element.textContent = String(point.count)
element.setAttribute("aria-label", `${point.count} requests`)
element.addEventListener("click", event => {
event.stopPropagation()
state.map.easeTo({
center: [point.longitude, point.latitude],
zoom: Math.min(state.map.getZoom() + 2, 20)
})
})
const marker = new maplibregl.Marker({element})
.setLngLat([point.longitude, point.latitude])
.addTo(state.map)
state.markers.push(marker)
})
if (points.length > 0 && state.element.dataset.autoFit !== "false") {
const bounds = new maplibregl.LngLatBounds() const bounds = new maplibregl.LngLatBounds()
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude])) exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature)) areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates))
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0}) state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
} }
} }
@ -229,6 +413,9 @@ const createAidMap = element => {
center: [30.5234, 50.4501], center: [30.5234, 50.4501],
zoom: 5, zoom: 5,
attributionControl: true, attributionControl: true,
renderWorldCopies: false,
maxBounds: [[-180, -85.05112878], [180, 85.05112878]],
trackResize: false,
dragRotate: !demoMap, dragRotate: !demoMap,
pitchWithRotate: !demoMap, pitchWithRotate: !demoMap,
scrollZoom: !demoMap scrollZoom: !demoMap
@ -244,22 +431,71 @@ const createAidMap = element => {
new maplibregl.NavigationControl({showCompass: !demoMap}), new maplibregl.NavigationControl({showCompass: !demoMap}),
demoMap ? "bottom-right" : "top-right" demoMap ? "bottom-right" : "top-right"
) )
state.onLoad = () => {
state.markReady() if (state.element.dataset.discoveryMap === "true" && navigator.geolocation) {
state.renderMarkers() state.map.addControl(
new maplibregl.GeolocateControl({
positionOptions: {enableHighAccuracy: false},
trackUserLocation: false,
showUserLocation: false,
fitBoundsOptions: {maxZoom: 13}
}),
"top-right"
)
} }
state.onLoad = () => {
if (!state.active || !state.map) return
state.markReady()
state.applySavedViewport()
state.renderMarkers()
state.viewportFrame = window.requestAnimationFrame(() => {
state.viewportFrame = null
if (!state.active || !state.map) return
state.initializedViewport = true
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
const viewport = mapViewport(state.map)
if (viewport) state.onInitializeViewport?.(viewport)
}
})
}
state.onMoveEnd = () => {
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
const viewport = mapViewport(state.map)
if (viewport) state.onViewportChange?.(viewport)
}
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
state.map.on("dataloading", state.markLoading) state.map.on("dataloading", state.markLoading)
state.map.on("load", state.onLoad) state.map.on("load", state.onLoad)
state.map.on("idle", state.markReady) state.map.on("idle", state.markReady)
state.map.on("moveend", state.onMoveEnd)
window.addEventListener("wnh:request-highlight", state.onRequestHighlight)
if ("ResizeObserver" in window) {
state.resizeObserver = new ResizeObserver(state.resize)
state.resizeObserver.observe(state.element)
}
state.renderMarkers() state.renderMarkers()
} }
state.destroy = () => { state.destroy = () => {
state.active = false
if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame)
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeObserver?.disconnect()
state.map?.off("dataloading", state.markLoading) state.map?.off("dataloading", state.markLoading)
state.map?.off("load", state.onLoad) state.map?.off("load", state.onLoad)
state.map?.off("idle", state.markReady) state.map?.off("idle", state.markReady)
state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.map?.remove() state.map?.remove()
state.map = null
} }
return state return state
@ -557,13 +793,105 @@ export const Hooks = {
AidMap: { AidMap: {
mounted() { mounted() {
this.aidMap = createAidMap(this.el) this.aidMap = createAidMap(this.el)
this.el._aidMap = this.aidMap
this.aidMap.onInitializeViewport = viewport =>
this.pushEvent("initialize-map-area", viewport)
this.aidMap.onViewportChange = viewport =>
this.pushEvent("map-viewport-change", viewport)
this.aidMap.mount() this.aidMap.mount()
}, },
updated() { updated() {
this.aidMap.applySavedViewport()
this.aidMap.renderMarkers() this.aidMap.renderMarkers()
this.aidMap.resize()
}, },
destroyed() { destroyed() {
this.aidMap.destroy() this.aidMap.destroy()
delete this.el._aidMap
}
},
RequestDiscovery: {
mounted() {
this.storageKey = "wnh:request-discovery-view"
this.allowedViews = ["list", "split", "map"]
this.resizeMap = () => {
document.querySelector("#request-map")?._aidMap?.resize()
}
this.persistView = () => {
const view = this.el.dataset.viewMode
if (!this.allowedViews.includes(view)) return
try {
window.localStorage.setItem(this.storageKey, view)
} catch (_error) {
// The URL still keeps the selected mode when storage is unavailable.
}
}
this.onPointerOver = event => {
const card = event.target.closest("[data-discovery-card]")
if (!card || !this.el.contains(card)) return
window.dispatchEvent(
new CustomEvent("wnh:request-highlight", {detail: {id: card.dataset.requestId}})
)
}
this.onPointerOut = event => {
const card = event.target.closest("[data-discovery-card]")
if (!card || card.contains(event.relatedTarget)) return
window.dispatchEvent(new CustomEvent("wnh:request-highlight", {detail: {id: null}}))
}
this.onRequestSelected = event => {
this.el.querySelectorAll("[data-discovery-card]").forEach(card => {
card.classList.toggle("is-selected", card.dataset.requestId === event.detail?.id)
})
const selected = this.el.querySelector(
`[data-discovery-card][data-request-id="${CSS.escape(String(event.detail?.id || ""))}"]`
)
if (selected && this.el.dataset.viewMode !== "map") {
selected.scrollIntoView({block: "nearest", behavior: "smooth"})
}
}
this.el.addEventListener("pointerover", this.onPointerOver)
this.el.addEventListener("pointerout", this.onPointerOut)
window.addEventListener("wnh:request-selected", this.onRequestSelected)
let savedView = null
try {
savedView = window.localStorage.getItem(this.storageKey)
} catch (_error) {
savedView = null
}
if (
this.el.dataset.viewExplicit !== "true" &&
this.allowedViews.includes(savedView) &&
savedView !== this.el.dataset.viewMode
) {
this.pushEvent("restore-view-mode", {view: savedView})
} else {
this.persistView()
}
this.resizeMap()
},
updated() {
this.persistView()
this.resizeMap()
},
destroyed() {
this.el.removeEventListener("pointerover", this.onPointerOver)
this.el.removeEventListener("pointerout", this.onPointerOut)
window.removeEventListener("wnh:request-selected", this.onRequestSelected)
} }
}, },
@ -1055,7 +1383,6 @@ function supportsMapCanvas() {
if (!context) return false if (!context) return false
context.getExtension("WEBGL_lose_context")?.loseContext()
return true return true
} catch (_error) { } catch (_error) {
return false return false

View File

@ -119,6 +119,16 @@ Production operators must use a tile service whose policy and capacity fit the
traffic; the public OpenStreetMap tile service is best-effort and has a usage traffic; the public OpenStreetMap tile service is best-effort and has a usage
policy, not an application backend. policy, not an application backend.
Request discovery is bounded by the validated map viewport sent by the
browser. PostGIS filters open requests against that area and groups visible
coordinates into Web Mercator grid cells sized from the geographic bounds and
rendered map dimensions. Dense cells are returned as aggregate clusters;
single cells preserve a request marker or privacy-radius area. Coordinate-free
requests are excluded from the map and appear in the list only when the user
supplies a matching public area/city search. LiveView subscribes to bounded
regional PubSub tiles plus the signed-in user's topic instead of the global
request stream.
## Data durability and retention ## Data durability and retention
- Requests, activities, membership decisions, messages, reviews, reports, and - Requests, activities, membership decisions, messages, reviews, reports, and

View File

@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import {
captureBrowserFailures,
gotoLiveView,
projectEmail,
projectText,
registerAndConfirm,
selectOptionContaining,
setRequestLocation,
waitForMapReady,
} from "./helpers";
async function createMedicineRequest(
page: Parameters<typeof gotoLiveView>[0],
title: string,
location: { label: string; latitude: string; longitude: string },
) {
await gotoLiveView(page, "/requests/new");
await selectOptionContaining(page, "Category", "Medicine pickup");
await page.getByLabel("Medicine pickup status").selectOption("reserved");
await page.getByLabel("Short title").fill(title);
await page
.getByLabel("What help do you need?")
.fill("Please collect a legal medicine order that is already reserved.");
await page.getByLabel("Urgency").selectOption("now");
await page.getByRole("button", { name: "In 3 hours" }).click();
await setRequestLocation(page, {
...location,
mode: "exact_public",
});
await page.locator("#request-form input[type=checkbox]").check();
await page.getByRole("button", { name: "Publish request" }).click();
await expect(page.getByRole("heading", { name: title })).toBeVisible();
}
test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({
browser,
request,
}, testInfo) => {
const attemptScope = `${testInfo.project.name}-${Date.now()}-${process.pid}`;
const requester = await registerAndConfirm(
browser,
request,
projectEmail(`discovery-requester-${attemptScope}`, testInfo.project.name),
"Discovery requester",
);
const viewer = await registerAndConfirm(
browser,
request,
projectEmail(`discovery-viewer-${attemptScope}`, testInfo.project.name),
"Discovery viewer",
);
const assertRequesterClean = captureBrowserFailures(requester.page);
const assertViewerClean = captureBrowserFailures(viewer.page);
const denseTitles = [1, 2, 3].map((index) =>
projectText(`Kyiv viewport request ${index}`, attemptScope),
);
const berlinTitle = projectText("Berlin outside viewport", attemptScope);
const kyivArea = `Kyiv discovery ${attemptScope}`;
for (const title of denseTitles) {
await createMedicineRequest(requester.page, title, {
label: kyivArea,
latitude: "50.4501",
longitude: "30.5234",
});
}
await createMedicineRequest(requester.page, berlinTitle, {
label: "Berlin discovery area",
latitude: "52.5200",
longitude: "13.4050",
});
await gotoLiveView(
viewer.page,
`/requests?west=30.2&south=50.2&east=30.8&north=50.7&zoom=10&width=1280&height=720&view=split&area=${encodeURIComponent(kyivArea)}`,
);
await waitForMapReady(viewer.page);
for (const title of denseTitles) {
await expect(viewer.page.getByRole("heading", { name: title })).toBeVisible();
}
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
await expect(viewer.page.getByText("3 requests in this map area")).toBeVisible();
const mapItems = JSON.parse((await viewer.page.locator("#request-map").getAttribute("data-items"))!);
expect(mapItems).toContainEqual(expect.objectContaining({ type: "cluster", count: 3 }));
if ((await viewer.page.locator("#request-map canvas").count()) > 0) {
await expect(viewer.page.locator(".request-map-cluster", { hasText: "3" })).toBeVisible();
} else {
await expect(viewer.page.locator("#request-map")).toContainText("The map is unavailable");
}
await viewer.page.getByRole("button", { name: "Map", exact: true }).click();
await expect(viewer.page.locator("#request-map")).toBeVisible();
await expect(viewer.page.locator("#request-results-list")).toBeHidden();
await viewer.page.getByRole("button", { name: "List", exact: true }).click();
await expect(viewer.page.locator("#request-results-list")).toBeVisible();
await expect(viewer.page.locator("#request-map")).toBeHidden();
await viewer.page.reload();
await expect(viewer.page.getByRole("button", { name: "List", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
assertRequesterClean();
assertViewerClean();
await requester.context.close();
await viewer.context.close();
});

View File

@ -3,10 +3,11 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@shortdoc "Measures keyset pagination plans on an isolated large data set" @shortdoc "Measures cursor and spatial discovery plans on an isolated large data set"
@moduledoc """ @moduledoc """
Seeds and measures the isolated database used by `scripts/db-scale-benchmark.sh`. Seeds and measures cursor pagination plus viewport-scoped map discovery in the
isolated database used by `scripts/db-scale-benchmark.sh`.
The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output. The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
It intentionally does not enforce an execution-time threshold because timings It intentionally does not enforce an execution-time threshold because timings
@ -54,6 +55,7 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
table_counts: table_counts(), table_counts: table_counts(),
database_bytes: scalar!("SELECT pg_database_size(current_database())"), database_bytes: scalar!("SELECT pg_database_size(current_database())"),
index_bytes: index_sizes(), index_bytes: index_sizes(),
viewport_discovery: viewport_discovery_measurements(),
plans: plans plans: plans
} }
@ -115,7 +117,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
""" """
INSERT INTO help_requests ( INSERT INTO help_requests (
id, title, description, location_label, location, status, urgency, id, title, description, location_label, location, status, urgency,
location_visibility, expires_at, hidden_at, requester_id, category_id, location_visibility, location_radius_meters, expires_at, hidden_at,
requester_id, category_id,
inserted_at, updated_at inserted_at, updated_at
) )
SELECT SELECT
@ -123,10 +126,21 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
'DB scale request ' || value, 'DB scale request ' || value,
'Synthetic isolated database scale request ' || value, 'Synthetic isolated database scale request ' || value,
'Kyiv benchmark point', 'Kyiv benchmark point',
ST_SetSRID(ST_MakePoint(30.5 + (value % 100) / 10000.0, 50.4), 4326), ST_SetSRID(
ST_MakePoint(
CASE
WHEN value <= #{div(rows, 5)}
THEN 30.504 + (value % 100) / 200000.0
ELSE 30.5 + (value % 10000) / 1000.0
END,
50.4
),
4326
),
CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END, CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
'now', 'now',
'approximate_public', 'exact_public',
NULL,
date_trunc('second', now()) + (value % 30 + 1) * interval '1 day', date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END, CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
md5('user-1')::uuid, md5('user-1')::uuid,
@ -358,6 +372,48 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
) )
ORDER BY expires_at ASC, id ASC LIMIT 25 ORDER BY expires_at ASC, id ASC LIMIT 25
""", """,
"help_discovery_viewport" => """
SELECT id FROM help_requests
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
AND location IS NOT NULL
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
2000
)
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
COALESCE(location_radius_meters, 0)
)
AND requester_id NOT IN (
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
)
AND requester_id NOT IN (
SELECT blocker_id FROM blocks WHERE blocked_id = #{viewer}
)
ORDER BY expires_at ASC, id ASC LIMIT 25
""",
"help_discovery_clusters" => """
SELECT
count(id),
ST_X(ST_Centroid(ST_Collect(ST_Transform(location, 3857)))),
ST_Y(ST_Centroid(ST_Collect(ST_Transform(location, 3857))))
FROM help_requests
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
AND location IS NOT NULL
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
2000
)
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
COALESCE(location_radius_meters, 0)
)
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
""",
"help_requester" => """ "help_requester" => """
SELECT id FROM help_requests SELECT id FROM help_requests
WHERE requester_id = md5('user-1')::uuid WHERE requester_id = md5('user-1')::uuid
@ -501,6 +557,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
defp expected_indexes do defp expected_indexes do
%{ %{
"help_discovery" => "help_requests_discovery_cursor_index", "help_discovery" => "help_requests_discovery_cursor_index",
"help_discovery_viewport" => "help_requests_location_geography_index",
"help_discovery_clusters" => "help_requests_location_geography_index",
"help_requester" => "help_requests_requester_cursor_index", "help_requester" => "help_requests_requester_cursor_index",
"activity_discovery" => "activities_discovery_cursor_index", "activity_discovery" => "activities_discovery_cursor_index",
"activity_participant" => "activity_participants_user_id_status_index", "activity_participant" => "activity_participants_user_id_status_index",
@ -573,6 +631,61 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
end end
end end
defp viewport_discovery_measurements do
visible_points =
scalar!("""
SELECT count(*) FROM help_requests
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
AND location IS NOT NULL
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
2000
)
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
COALESCE(location_radius_meters, 0)
)
""")
clusters =
scalar!("""
SELECT count(*) FROM (
SELECT 1 FROM help_requests
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
AND location IS NOT NULL
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
2000
)
AND ST_DWithin(
location::geography,
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
COALESCE(location_radius_meters, 0)
)
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
) AS viewport_clusters
""")
if visible_points <= 0 do
Mix.raise("viewport discovery benchmark did not select any seeded points")
end
if clusters <= 0 or clusters > visible_points do
Mix.raise(
"viewport cluster count must be positive and no larger than the visible point count"
)
end
%{
visible_points: visible_points,
server_cluster_rows: clusters,
rows_avoided_in_map_payload: visible_points - clusters
}
end
defp fetch_help_page(nil) do defp fetch_help_page(nil) do
result = result =
Repo.query!(""" Repo.query!("""

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Help do
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest}
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Push alias WhoNeedHelp.Push
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@ -15,9 +15,24 @@ defmodule WhoNeedHelp.Help do
alias WhoNeedHelp.Trust.Block alias WhoNeedHelp.Trust.Block
@topic "help:requests" @topic "help:requests"
@hidden_discovery_topic "help:discovery:hidden"
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic) def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
def subscribe_user(user_id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:user:#{user_id}")
def subscribe_discovery(%DiscoveryViewport{} = viewport, include_hidden \\ false) do
topics = discovery_topics(viewport, include_hidden)
Enum.each(topics, &Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, &1))
topics
end
def unsubscribe_discovery(topics) when is_list(topics) do
Enum.each(topics, &Phoenix.PubSub.unsubscribe(WhoNeedHelp.PubSub, &1))
:ok
end
def subscribe_request(id), def subscribe_request(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}") do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")
@ -32,28 +47,12 @@ defmodule WhoNeedHelp.Help do
end end
def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
now = DateTime.utc_now(:second)
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
viewport = Keyword.get(options, :viewport)
HelpRequest user
|> where( |> open_requests_query(filters, viewport)
[request],
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> filter_open_requests(filters)
|> after_open_request(cursor) |> after_open_request(cursor)
|> order_by([request], asc: request.expires_at, asc: request.id) |> order_by([request], asc: request.expires_at, asc: request.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
@ -62,6 +61,94 @@ defmodule WhoNeedHelp.Help do
|> Pagination.page(limit, &{&1.expires_at, &1.id}) |> Pagination.page(limit, &{&1.expires_at, &1.id})
end end
def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do
cell_size = DiscoveryViewport.cluster_cell_meters(viewport)
points =
user
|> open_requests_query(filters, viewport)
|> where([request], not is_nil(request.location))
|> select([request], %{
id: request.id,
title: request.title,
location_label: request.location_label,
location_visibility: request.location_visibility,
radius_meters: request.location_radius_meters,
urgency: request.urgency,
public_location:
fragment(
"CASE WHEN ? = 'exact_for_active_match' THEN ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326) ELSE ? END",
request.location_visibility,
request.location,
request.location,
request.location
)
})
points
|> subquery()
|> group_by(
[point],
fragment(
"ST_SnapToGrid(ST_Transform(?, 3857), ?)",
point.public_location,
constant(^cell_size)
)
)
|> select([point], %{
count: count(point.id),
urgent_count: fragment("count(*) FILTER (WHERE ? = 'now')::bigint", point.urgency),
request_id: fragment("CASE WHEN count(*) = 1 THEN min(?::text) END", point.id),
title: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.title),
location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label),
location_visibility:
fragment(
"CASE WHEN count(*) = 1 THEN min(?::text) END",
point.location_visibility
),
radius_meters: fragment("CASE WHEN count(*) = 1 THEN max(?) END", point.radius_meters),
longitude:
fragment(
"ST_X(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
point.public_location
),
latitude:
fragment(
"ST_Y(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
point.public_location
),
cell_x:
fragment(
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
point.public_location,
constant(^cell_size)
),
cell_y:
fragment(
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
point.public_location,
constant(^cell_size)
)
})
|> order_by(
[point],
asc:
fragment(
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
point.public_location,
constant(^cell_size)
),
asc:
fragment(
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
point.public_location,
constant(^cell_size)
)
)
|> Repo.all()
|> Enum.map(&map_discovery_item(&1, viewport.zoom))
end
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required") def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
@ -697,20 +784,59 @@ defmodule WhoNeedHelp.Help do
end end
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
request
|> discovery_broadcast_topics()
|> Enum.each(&Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, &1, event))
[request.requester_id, request.assignment && request.assignment.helper_id]
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.each(fn user_id ->
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:user:#{user_id}", event)
end)
end end
defp discovery_topics(viewport, include_hidden) do
topics =
viewport
|> DiscoveryViewport.subscription_tiles()
|> Enum.map(&tile_topic/1)
if include_hidden, do: [@hidden_discovery_topic | topics], else: topics
end
defp discovery_broadcast_topics(%HelpRequest{location: nil}), do: [@hidden_discovery_topic]
defp discovery_broadcast_topics(%HelpRequest{
location: %Geo.Point{coordinates: {longitude, latitude}},
location_radius_meters: radius_meters
}) do
latitude
|> DiscoveryViewport.point_tiles(longitude, radius_meters || 0)
|> Enum.map(&tile_topic/1)
end
defp discovery_broadcast_topics(_request), do: []
defp tile_topic({zoom, x, y}), do: "help:discovery:#{zoom}:#{x}:#{y}"
defp filter_open_requests(query, filters) do defp filter_open_requests(query, filters) do
query query
|> maybe_filter(:category_id, filters["category_id"] || filters[:category_id]) |> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency]) |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
|> maybe_filter(:area, filters["area"] || filters[:area])
end end
defp request_filter_matches?(request, filters) do defp request_filter_matches?(request, filters) do
category_id = filters["category_id"] || filters[:category_id] category_id = filters["category_id"] || filters[:category_id]
urgency = filters["urgency"] || filters[:urgency] urgency = filters["urgency"] || filters[:urgency]
area = filters["area"] || filters[:area]
(category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and (category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
(urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency)) (urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency)) and
(area in [nil, ""] or
String.contains?(String.downcase(request.location_label), String.downcase(area)))
end end
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
@ -729,6 +855,128 @@ defmodule WhoNeedHelp.Help do
end end
end end
defp maybe_filter(query, :area, value) when is_binary(value) do
value = value |> String.trim() |> String.slice(0, 100)
if value == "" do
query
else
pattern = "%#{escape_like(value)}%"
where(
query,
[request],
fragment("? ILIKE ? ESCAPE E'\\\\'", request.location_label, ^pattern)
)
end
end
defp maybe_filter(query, :area, _value), do: where(query, [request], false)
defp escape_like(value) do
value
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp open_requests_query(user, filters, viewport) do
now = DateTime.utc_now(:second)
HelpRequest
|> where(
[request],
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> filter_open_requests(filters)
|> filter_discovery_viewport(viewport, filters)
end
defp filter_discovery_viewport(query, nil, _filters), do: query
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
maximum_public_radius = Enum.max(HelpRequest.allowed_location_radii())
spatial_condition =
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
{west, south, east, north}, condition ->
dynamic(
[request],
^condition or
fragment(
"ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, ?) AND ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, COALESCE(?, 0))",
request.location,
^west,
^south,
^east,
^north,
^maximum_public_radius,
request.location,
^west,
^south,
^east,
^north,
request.location_radius_meters
)
)
end)
area = filters["area"] || filters[:area]
condition =
if is_binary(area) and String.trim(area) != "" do
dynamic(
[request],
(not is_nil(request.location) and ^spatial_condition) or is_nil(request.location)
)
else
dynamic([request], not is_nil(request.location) and ^spatial_condition)
end
where(query, ^condition)
end
defp map_discovery_item(%{count: 1} = row, _zoom) do
%{
type: "request",
id: row.request_id,
title: row.title,
location: row.location_label,
latitude: row.latitude,
longitude: row.longitude,
exact: row.location_visibility == "exact_public",
radius_meters: row.radius_meters
}
end
defp map_discovery_item(row, zoom) do
digest =
:crypto.hash(:sha256, "#{zoom}:#{row.cell_x}:#{row.cell_y}")
|> Base.url_encode64(padding: false)
|> binary_part(0, 16)
%{
type: "cluster",
id: "cluster-#{digest}",
count: row.count,
urgent_count: row.urgent_count,
latitude: row.latitude,
longitude: row.longitude
}
end
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now} defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today} defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled} defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}

View File

@ -0,0 +1,291 @@
defmodule WhoNeedHelp.Help.DiscoveryViewport do
@moduledoc """
Validated map bounds used by request discovery.
The viewport carries its rendered pixel size so server-side clustering can
match the amount of detail the browser can actually display. Longitudes
crossing the antimeridian are represented as two PostGIS envelopes.
"""
@max_mercator_latitude 85.051_128_78
@maximum_canvas_dimension 4_096
@minimum_canvas_dimension 160
@cluster_cell_pixels 64.0
@map_tile_pixels 256
@maximum_subscription_zoom 12
@earth_radius_meters 6_378_137.0
@enforce_keys [:west, :south, :east, :north, :zoom, :width, :height]
defstruct [:west, :south, :east, :north, :zoom, :width, :height]
@type t :: %__MODULE__{
west: float(),
south: float(),
east: float(),
north: float(),
zoom: float(),
width: pos_integer(),
height: pos_integer()
}
def cast(%{} = attrs) do
with {:ok, west} <- coordinate(attrs, "west"),
{:ok, south} <- coordinate(attrs, "south"),
{:ok, east} <- coordinate(attrs, "east"),
{:ok, north} <- coordinate(attrs, "north"),
{:ok, zoom} <- number(attrs, "zoom"),
{:ok, width} <- dimension(attrs, "width", 1_280),
{:ok, height} <- dimension(attrs, "height", 720),
true <- longitude?(west) and longitude?(east) and west != east,
true <- latitude?(south) and latitude?(north) and south < north,
true <- zoom >= 0 and zoom <= 22 do
{:ok,
%__MODULE__{
west: west,
south: south,
east: east,
north: north,
zoom: zoom,
width: width,
height: height
}}
else
_invalid -> :error
end
end
def cast(_attrs), do: :error
def envelopes(%__MODULE__{west: west, south: south, east: east, north: north})
when west < east do
[{west, south, east, north}]
end
def envelopes(%__MODULE__{west: west, south: south, east: east, north: north}) do
[{west, south, 180.0, north}, {-180.0, south, east, north}]
end
def cluster_cell_meters(%__MODULE__{} = viewport) do
horizontal_cells = max(viewport.width / @cluster_cell_pixels, 1.0)
vertical_cells = max(viewport.height / @cluster_cell_pixels, 1.0)
width_meters =
viewport
|> envelopes()
|> Enum.map(fn {west, _south, east, _north} -> mercator_x(east) - mercator_x(west) end)
|> Enum.sum()
height_meters = mercator_y(viewport.north) - mercator_y(viewport.south)
max(width_meters / horizontal_cells, height_meters / vertical_cells)
|> max(0.01)
end
def query_params(%__MODULE__{} = viewport) do
%{
"west" => format_number(viewport.west),
"south" => format_number(viewport.south),
"east" => format_number(viewport.east),
"north" => format_number(viewport.north),
"zoom" => format_number(viewport.zoom, 2),
"width" => Integer.to_string(viewport.width),
"height" => Integer.to_string(viewport.height)
}
end
def same_area?(%__MODULE__{} = left, %__MODULE__{} = right) do
Enum.all?([:west, :south, :east, :north], fn field ->
abs(Map.fetch!(left, field) - Map.fetch!(right, field)) < 0.000_001
end) and abs(left.zoom - right.zoom) < 0.01 and left.width == right.width and
left.height == right.height
end
def same_area?(_left, _right), do: false
@doc """
Returns the Web Mercator tiles represented by this viewport.
The selected tile zoom follows the browser zoom, but is reduced when forged
URL bounds would require more tiles than the rendered canvas can display.
This keeps PubSub fan-out tied to visible map capacity rather than geography.
"""
def subscription_tiles(%__MODULE__{} = viewport) do
maximum_tiles =
(ceil(viewport.width / @map_tile_pixels) + 2) *
(ceil(viewport.height / @map_tile_pixels) + 2)
viewport.zoom
|> floor()
|> min(@maximum_subscription_zoom)
|> max(0)
|> then(&tiles_at_displayable_zoom(viewport, &1, maximum_tiles))
end
@doc """
Returns all Web Mercator tiles touched by a point or privacy radius at each
discovery subscription zoom.
"""
def point_tiles(latitude, longitude, radius_meters \\ 0)
def point_tiles(latitude, longitude, radius_meters)
when is_number(latitude) and is_number(longitude) and is_number(radius_meters) do
latitude = clamp(latitude * 1.0, -@max_mercator_latitude, @max_mercator_latitude)
longitude = clamp(longitude * 1.0, -180.0, 180.0)
radius_meters = max(radius_meters * 1.0, 0.0)
latitude_delta = radius_meters / 111_320.0
longitude_scale = max(:math.cos(degrees_to_radians(latitude)), 0.01)
longitude_delta = radius_meters / (111_320.0 * longitude_scale)
south = clamp(latitude - latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
north = clamp(latitude + latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
longitude_intervals(longitude - longitude_delta, longitude + longitude_delta)
|> Enum.flat_map(fn {west, east} ->
Enum.flat_map(0..@maximum_subscription_zoom, fn zoom ->
tiles_for_envelope(west, south, east, north, zoom)
end)
end)
|> Enum.uniq()
|> Enum.sort()
end
def point_tiles(_latitude, _longitude, _radius_meters), do: []
defp coordinate(attrs, key), do: number(attrs, key)
defp tiles_at_displayable_zoom(viewport, zoom, maximum_tiles) do
tiles = viewport_tiles(viewport, zoom)
if length(tiles) <= maximum_tiles or zoom == 0 do
tiles
else
tiles_at_displayable_zoom(viewport, zoom - 1, maximum_tiles)
end
end
defp viewport_tiles(viewport, zoom) do
viewport
|> envelopes()
|> Enum.flat_map(fn {west, south, east, north} ->
tiles_for_envelope(west, south, east, north, zoom)
end)
|> Enum.uniq()
|> Enum.sort()
end
defp tiles_for_envelope(west, south, east, north, zoom) do
x_start = tile_x(west, zoom)
x_end = tile_x(east - 1.0e-10, zoom)
y_start = tile_y(north, zoom)
y_end = tile_y(south, zoom)
for x <- x_start..x_end, y <- y_start..y_end, do: {zoom, x, y}
end
defp tile_x(longitude, zoom) do
tile_count = Integer.pow(2, zoom)
longitude
|> clamp(-180.0, 180.0)
|> then(&floor((&1 + 180.0) / 360.0 * tile_count))
|> min(tile_count - 1)
|> max(0)
end
defp tile_y(latitude, zoom) do
tile_count = Integer.pow(2, zoom)
latitude
|> clamp(-@max_mercator_latitude, @max_mercator_latitude)
|> degrees_to_radians()
|> then(fn radians ->
floor((1.0 - :math.asinh(:math.tan(radians)) / :math.pi()) / 2.0 * tile_count)
end)
|> min(tile_count - 1)
|> max(0)
end
defp longitude_intervals(west, east) when west < -180.0,
do: [{west + 360.0, 180.0}, {-180.0, east}]
defp longitude_intervals(west, east) when east > 180.0,
do: [{west, 180.0}, {-180.0, east - 360.0}]
defp longitude_intervals(west, east), do: [{west, east}]
defp number(attrs, key) do
attrs
|> value(key)
|> parse_number()
end
defp dimension(attrs, key, default) do
case value(attrs, key) do
nil -> {:ok, default}
"" -> {:ok, default}
value -> parse_dimension(value)
end
end
defp value(attrs, "west"), do: Map.get(attrs, "west") || Map.get(attrs, :west)
defp value(attrs, "south"), do: Map.get(attrs, "south") || Map.get(attrs, :south)
defp value(attrs, "east"), do: Map.get(attrs, "east") || Map.get(attrs, :east)
defp value(attrs, "north"), do: Map.get(attrs, "north") || Map.get(attrs, :north)
defp value(attrs, "zoom"), do: Map.get(attrs, "zoom") || Map.get(attrs, :zoom)
defp value(attrs, "width"), do: Map.get(attrs, "width") || Map.get(attrs, :width)
defp value(attrs, "height"), do: Map.get(attrs, "height") || Map.get(attrs, :height)
defp parse_number(value) when is_integer(value), do: {:ok, value * 1.0}
defp parse_number(value) when is_float(value), do: {:ok, value}
defp parse_number(value) when is_binary(value) do
case Float.parse(value) do
{number, ""} -> {:ok, number}
_invalid -> :error
end
end
defp parse_number(_value), do: :error
defp parse_dimension(value) when is_integer(value), do: validate_dimension(value)
defp parse_dimension(value) when is_binary(value) do
case Integer.parse(value) do
{dimension, ""} -> validate_dimension(dimension)
_invalid -> :error
end
end
defp parse_dimension(_value), do: :error
defp validate_dimension(value)
when value >= @minimum_canvas_dimension and value <= @maximum_canvas_dimension,
do: {:ok, value}
defp validate_dimension(_value), do: :error
defp longitude?(value), do: value >= -180.0 and value <= 180.0
defp latitude?(value),
do: value >= -@max_mercator_latitude and value <= @max_mercator_latitude
defp mercator_x(longitude), do: @earth_radius_meters * degrees_to_radians(longitude)
defp mercator_y(latitude) do
latitude = min(max(latitude, -@max_mercator_latitude), @max_mercator_latitude)
radians = degrees_to_radians(latitude)
@earth_radius_meters * :math.log(:math.tan(:math.pi() / 4 + radians / 2))
end
defp degrees_to_radians(value), do: value * :math.pi() / 180.0
defp clamp(value, minimum, maximum), do: min(max(value, minimum), maximum)
defp format_number(value, precision \\ 6) do
value
|> Float.round(precision)
|> :erlang.float_to_binary(decimals: precision)
|> String.trim_trailing("0")
|> String.trim_trailing(".")
end
end

View File

@ -2,58 +2,109 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Help, Pagination, Trust} alias WhoNeedHelp.{Help, Pagination, Trust}
alias WhoNeedHelp.Help.DiscoveryViewport
@view_modes ~w(list split map)
@filter_keys ~w(category_id urgency area)
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket), do: Help.subscribe() if connected?(socket), do: Help.subscribe_user(socket.assigns.current_scope.user.id)
filters = empty_filters()
{:ok, {:ok,
socket socket
|> assign(:filters, %{"category_id" => "", "urgency" => ""}) |> assign(:filters, filters)
|> load()} |> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:view_mode, "split")
|> assign(:view_explicit, false)
|> assign(:viewport, nil)
|> assign(:pending_viewport, nil)
|> assign(:discovery_topics, [])
|> assign(:query_params, %{})
|> assign(:requests, [])
|> assign(:requests_cursor, nil)
|> assign(:map_items, Jason.encode!([]))
|> assign(:map_result_count, 0)
|> assign(:refresh_timer, nil)
|> assign(:page_title, gettext("Nearby help"))
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())}
end end
@impl true @impl true
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do def handle_params(params, _uri, socket) do
user = socket.assigns.current_scope.user filters = normalize_filters(params)
viewport = parse_viewport(params)
{requests, requests_cursor} = view_mode = normalize_view_mode(params["view"])
update_entry(
socket.assigns.requests,
request,
Help.visible_open_request?(
socket.assigns.current_scope,
request,
socket.assigns.filters
),
:open,
socket.assigns.requests_cursor
)
{my_requests, my_requests_cursor} =
update_entry(
socket.assigns.my_requests,
request,
request.requester_id == user.id,
:mine,
socket.assigns.my_requests_cursor
)
socket = socket =
socket socket
|> assign(:requests, requests) |> assign(:filters, filters)
|> assign(:requests_cursor, requests_cursor) |> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:my_requests, my_requests) |> assign(:view_mode, view_mode)
|> assign(:my_requests_cursor, my_requests_cursor) |> assign(:view_explicit, params["view"] in @view_modes)
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1))))) |> assign(:viewport, viewport)
|> maybe_refresh_reputation(request, user.id) |> assign(:pending_viewport, nil)
|> assign(:query_params, normalize_query_params(filters, viewport, view_mode))
|> refresh_discovery_subscription(viewport, filters)
|> load()
{:noreply, socket} {:noreply, socket}
end end
@impl true @impl true
def handle_event("filter", %{"filters" => filters}, socket) do def handle_event("filter", %{"filters" => filters}, socket) do
{:noreply, socket |> assign(:filters, filters) |> load()} {:noreply, push_patch(socket, to: request_path(socket, filters: normalize_filters(filters)))}
end
def handle_event("set-view", %{"view" => view_mode}, socket)
when view_mode in @view_modes do
{:noreply, push_patch(socket, to: request_path(socket, view_mode: view_mode))}
end
def handle_event("restore-view-mode", %{"view" => view_mode}, socket)
when view_mode in @view_modes do
if socket.assigns.view_explicit do
{:noreply, socket}
else
{:noreply, push_patch(socket, to: request_path(socket, view_mode: view_mode))}
end
end
def handle_event("map-viewport-change", params, socket) do
case DiscoveryViewport.cast(params) do
{:ok, viewport} ->
pending =
if DiscoveryViewport.same_area?(viewport, socket.assigns.viewport),
do: nil,
else: viewport
{:noreply, assign(socket, :pending_viewport, pending)}
:error ->
{:noreply, socket}
end
end
def handle_event("initialize-map-area", params, socket) do
case {socket.assigns.viewport, DiscoveryViewport.cast(params)} do
{nil, {:ok, viewport}} ->
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
_already_initialized_or_invalid ->
{:noreply, socket}
end
end
def handle_event("search-map-area", _params, socket) do
case socket.assigns.pending_viewport do
%DiscoveryViewport{} = viewport ->
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
_no_pending_viewport ->
{:noreply, socket}
end
end end
def handle_event("load-more-requests", _params, socket) do def handle_event("load-more-requests", _params, socket) do
@ -61,7 +112,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
Help.paginate_open_requests( Help.paginate_open_requests(
socket.assigns.current_scope, socket.assigns.current_scope,
socket.assigns.filters, socket.assigns.filters,
after: socket.assigns.requests_cursor after: socket.assigns.requests_cursor,
viewport: socket.assigns.viewport
) )
requests = append_unique(socket.assigns.requests, page.entries) requests = append_unique(socket.assigns.requests, page.entries)
@ -69,8 +121,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{:noreply, {:noreply,
socket socket
|> assign(:requests, requests) |> assign(:requests, requests)
|> assign(:requests_cursor, page.next_cursor) |> assign(:requests_cursor, page.next_cursor)}
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))}
end end
def handle_event("load-more-my-requests", _params, socket) do def handle_event("load-more-my-requests", _params, socket) do
@ -85,24 +136,74 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|> assign(:my_requests_cursor, page.next_cursor)} |> assign(:my_requests_cursor, page.next_cursor)}
end end
@impl true
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
socket = maybe_refresh_reputation(socket, request, socket.assigns.current_scope.user.id)
if socket.assigns.refresh_timer do
{:noreply, socket}
else
timer = Process.send_after(self(), :refresh_discovery, 100)
{:noreply, assign(socket, :refresh_timer, timer)}
end
end
def handle_info(:refresh_discovery, socket) do
{:noreply, socket |> assign(:refresh_timer, nil) |> load()}
end
defp load(socket) do defp load(socket) do
requests_page = {requests_page, map_items} =
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters) case socket.assigns.viewport do
%DiscoveryViewport{} = viewport ->
page =
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters,
viewport: viewport
)
items =
Help.map_discovery_items(
socket.assigns.current_scope,
socket.assigns.filters,
viewport
)
{page, items}
nil ->
{%Pagination.Page{}, []}
end
my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope) my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope)
requests = requests_page.entries
user = socket.assigns.current_scope.user user = socket.assigns.current_scope.user
socket socket
|> assign(:page_title, gettext("Nearby help")) |> assign(:requests, requests_page.entries)
|> assign(:requests, requests)
|> assign(:requests_cursor, requests_page.next_cursor) |> assign(:requests_cursor, requests_page.next_cursor)
|> assign(:my_requests, my_requests_page.entries) |> assign(:my_requests, my_requests_page.entries)
|> assign(:my_requests_cursor, my_requests_page.next_cursor) |> assign(:my_requests_cursor, my_requests_page.next_cursor)
|> assign(:reputation, Trust.reputation(user.id)) |> assign(:reputation, Trust.reputation(user.id))
|> assign(:categories, WhoNeedHelp.Catalog.list_categories()) |> assign(:map_items, Jason.encode!(map_items))
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters)) |> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1))))) end
defp refresh_discovery_subscription(socket, viewport, filters) do
if connected?(socket) do
:ok = Help.unsubscribe_discovery(socket.assigns.discovery_topics)
topics =
case viewport do
%DiscoveryViewport{} = viewport ->
Help.subscribe_discovery(viewport, String.trim(filters["area"]) != "")
nil ->
[]
end
assign(socket, :discovery_topics, topics)
else
socket
end
end end
defp append_unique(existing, incoming) do defp append_unique(existing, incoming) do
@ -110,36 +211,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id)) existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end end
defp update_entry(entries, request, visible?, order, cursor) do
window_limit = max(length(entries), Pagination.limit([]))
entries = Enum.reject(entries, &(&1.id == request.id))
entries =
if visible?,
do: [request | entries],
else: entries
entries =
case order do
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
end
overflow? = length(entries) > window_limit
entries = Enum.take(entries, window_limit)
cursor =
if overflow? do
request = List.last(entries)
timestamp = if order == :open, do: request.expires_at, else: request.inserted_at
Pagination.encode(timestamp, request.id)
else
cursor
end
{entries, cursor}
end
defp maybe_refresh_reputation(socket, request, user_id) do defp maybe_refresh_reputation(socket, request, user_id) do
assignment = request.assignment assignment = request.assignment
@ -151,18 +222,41 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
end end
end end
defp marker(request) do defp map_item_count(%{type: "cluster", count: count}), do: count
case Help.HelpRequest.public_coordinates(request) do defp map_item_count(_request), do: 1
nil ->
nil
coordinates -> defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""}
Map.merge(coordinates, %{
id: request.id, defp normalize_filters(params) do
title: request.title, Map.new(@filter_keys, fn key ->
location: request.location_label {key, params[key] |> to_string() |> String.slice(0, 100)}
}) end)
end end
defp normalize_view_mode(view_mode) when view_mode in @view_modes, do: view_mode
defp normalize_view_mode(_view_mode), do: "split"
defp parse_viewport(params) do
case DiscoveryViewport.cast(params) do
{:ok, viewport} -> viewport
:error -> nil
end
end
defp normalize_query_params(filters, viewport, view_mode) do
filters
|> Map.reject(fn {_key, value} -> value == "" end)
|> Map.put("view", view_mode)
|> Map.merge(if(viewport, do: DiscoveryViewport.query_params(viewport), else: %{}))
end
defp request_path(socket, overrides) do
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
view_mode = Keyword.get(overrides, :view_mode, socket.assigns.view_mode)
params = normalize_query_params(filters, viewport, view_mode)
~p"/requests?#{params}"
end end
defp urgency_label(:now), do: gettext("Now") defp urgency_label(:now), do: gettext("Now")
@ -187,6 +281,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
defp status_label("expired"), do: gettext("Expired") defp status_label("expired"), do: gettext("Expired")
defp status_label(value), do: value defp status_label(value), do: value
defp viewport_json(nil), do: ""
defp viewport_json(%DiscoveryViewport{} = viewport) do
viewport
|> DiscoveryViewport.query_params()
|> Jason.encode!()
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -196,7 +298,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div> <div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div>
<h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1> <h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1>
<p class="mt-2 text-base-content/65"> <p class="mt-2 text-base-content/65">
{gettext("Only approximate areas are shown before a safe match.")} {gettext(
"Explore privacy-friendly areas and public exact meeting points chosen by requesters."
)}
</p> </p>
</div> </div>
<.link navigate={~p"/requests/new"} class="btn btn-primary"> <.link navigate={~p"/requests/new"} class="btn btn-primary">
@ -217,7 +321,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
for={@filter_form} for={@filter_form}
id="request-filters" id="request-filters"
phx-change="filter" phx-change="filter"
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2" class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 md:grid-cols-3"
> >
<.input <.input
field={@filter_form[:category_id]} field={@filter_form[:category_id]}
@ -245,27 +349,104 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{gettext("Scheduled"), "scheduled"} {gettext("Scheduled"), "scheduled"}
]} ]}
/> />
<.input
field={@filter_form[:area]}
label={gettext("Area or city")}
placeholder={gettext("Optional text search")}
phx-debounce="400"
/>
</.form> </.form>
<div class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]"> <section
<div class="space-y-4"> id="request-discovery"
<%= if @requests == [] do %> phx-hook="RequestDiscovery"
<div class="rounded-3xl border border-dashed border-base-300 p-12 text-center"> data-view-mode={@view_mode}
<h2 class="text-xl font-bold">{gettext("No open requests yet")}</h2> data-view-explicit={to_string(@view_explicit)}
<p class="mt-2 text-base-content/60"> data-has-viewport={to_string(not is_nil(@viewport))}
{gettext("You can be the first person to ask the community.")} class="request-discovery mt-6"
>
<div class="request-discovery-toolbar">
<div>
<strong>
{ngettext(
"%{count} request in this map area",
"%{count} requests in this map area",
@map_result_count,
count: @map_result_count
)}
</strong>
<p class="text-sm text-base-content/60">
{gettext("Move the map, then search the newly visible area.")}
</p>
</div>
<div class="request-view-switcher" role="group" aria-label={gettext("Results layout")}>
<button
type="button"
phx-click="set-view"
phx-value-view="list"
aria-pressed={to_string(@view_mode == "list")}
class={["btn btn-sm", @view_mode == "list" && "btn-active"]}
>
<.icon name="hero-list-bullet" class="size-4" />
<span>{gettext("List")}</span>
</button>
<button
type="button"
phx-click="set-view"
phx-value-view="split"
aria-pressed={to_string(@view_mode == "split")}
class={["btn btn-sm request-view-split", @view_mode == "split" && "btn-active"]}
>
<.icon name="hero-squares-2x2" class="size-4" />
<span>{gettext("Together")}</span>
</button>
<button
type="button"
phx-click="set-view"
phx-value-view="map"
aria-pressed={to_string(@view_mode == "map")}
class={["btn btn-sm", @view_mode == "map" && "btn-active"]}
>
<.icon name="hero-map" class="size-4" />
<span>{gettext("Map")}</span>
</button>
</div>
</div>
<div class="request-discovery-content">
<div id="request-results-list" class="request-results-list space-y-4">
<div
:if={is_nil(@viewport)}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
>
<.icon name="hero-map" class="mx-auto size-8 text-success" />
<h2 class="mt-3 text-xl font-bold">{gettext("Choose an area on the map")}</h2>
<p class="mt-2 text-base-content/60">
{gettext("Only requests from the selected map area will be loaded.")}
</p>
</div>
<div
:if={@viewport && @requests == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
>
<h2 class="text-xl font-bold">{gettext("No open requests in this area")}</h2>
<p class="mt-2 text-base-content/60">
{gettext("Move or zoom the map to search somewhere else.")}
</p> </p>
</div> </div>
<% end %>
<.link <.link
:for={request <- @requests} :for={request <- @requests}
id={"open-request-#{request.id}"} id={"open-request-#{request.id}"}
navigate={~p"/requests/#{request.id}"} navigate={~p"/requests/#{request.id}"}
class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6" data-discovery-card
data-request-id={request.id}
class="help-card request-result-card block rounded-3xl border border-base-300 bg-base-100 p-5"
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div class="min-w-0">
<div class="mb-3 flex flex-wrap gap-2"> <div class="mb-3 flex flex-wrap gap-2">
<span class="badge badge-success badge-outline"> <span class="badge badge-success badge-outline">
{WhoNeedHelp.Catalog.Category.name( {WhoNeedHelp.Catalog.Category.name(
@ -273,15 +454,21 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
Gettext.get_locale(WhoNeedHelpWeb.Gettext) Gettext.get_locale(WhoNeedHelpWeb.Gettext)
)} )}
</span> </span>
<span class="badge badge-error badge-outline">{urgency_label(request.urgency)}</span> <span class="badge badge-error badge-outline">
{urgency_label(request.urgency)}
</span>
</div> </div>
<h2 class="text-xl font-bold">{request.title}</h2> <h2 class="text-xl font-bold">{request.title}</h2>
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{request.description}</p> <p class="mt-2 line-clamp-2 text-sm text-base-content/65">
{request.description}
</p>
</div> </div>
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" /> <.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
</div> </div>
<div class="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55"> <div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
<span>📍 {request.location_label}</span> <span class="inline-flex items-center gap-1.5">
<.icon name="hero-map-pin" class="size-4" /> {request.location_label}
</span>
<span> <span>
{gettext("by %{name}", {gettext("by %{name}",
name: request.requester.display_name || gettext("Community member") name: request.requester.display_name || gettext("Community member")
@ -294,49 +481,74 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
</span> </span>
</div> </div>
</.link> </.link>
<button <button
:if={@requests_cursor} :if={@requests_cursor}
type="button" type="button"
phx-click="load-more-requests" phx-click="load-more-requests"
class="btn btn-outline w-full" class="btn btn-outline w-full"
> >
{gettext("Load more")} {gettext("Load more in this area")}
</button> </button>
<p class="flex items-start gap-2 rounded-2xl bg-base-200 p-4 text-xs text-base-content/60">
<.icon name="hero-eye-slash" class="mt-0.5 size-4 shrink-0" />
<span>
{gettext(
"Requests without map coordinates appear only when their public area description matches the text search."
)}
</span>
</p>
</div> </div>
<aside class="space-y-5"> <aside class="request-discovery-map-panel space-y-4">
<div class="request-discovery-map-shell">
<div <div
id="request-map" id="request-map"
phx-hook="AidMap" phx-hook="AidMap"
phx-update="ignore" phx-update="ignore"
data-markers={@markers} data-discovery-map="true"
data-auto-fit="false"
data-items={@map_items}
data-viewport={viewport_json(@viewport)}
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.") gettext("The map is unavailable in this browser. Request details remain usable.")
} }
class="aid-map" data-request-label={gettext("Open request")}
class="aid-map request-discovery-map"
/> />
<button
:if={@pending_viewport}
id="search-map-area"
type="button"
phx-click="search-map-area"
class="btn btn-primary request-map-search-button"
>
<.icon name="hero-magnifying-glass" class="size-4" />
{gettext("Search this area")}
</button>
</div>
<div class="rounded-3xl bg-base-200 p-5"> <div class="rounded-3xl bg-base-200 p-5">
<h3 class="font-bold">{gettext("Your trust summary")}</h3> <h3 class="font-bold">{gettext("Your trust summary")}</h3>
<div class="mt-4 grid grid-cols-3 gap-2 text-center"> <div class="mt-4 grid grid-cols-3 gap-2 text-center">
<div> <div>
<div class="text-2xl font-black">{@reputation.completed}</div><div class="text-[.65rem]"> <div class="text-2xl font-black">{@reputation.completed}</div>
{gettext("completed")} <div class="text-[.65rem]">{gettext("completed")}</div>
</div>
</div> </div>
<div> <div>
<div class="text-2xl font-black">{@reputation.unique_people}</div><div class="text-[.65rem]"> <div class="text-2xl font-black">{@reputation.unique_people}</div>
{gettext("people")} <div class="text-[.65rem]">{gettext("people")}</div>
</div>
</div> </div>
<div> <div>
<div class="text-2xl font-black">{@reputation.verified_handovers}</div><div class="text-[.65rem]"> <div class="text-2xl font-black">{@reputation.verified_handovers}</div>
{gettext("verified")} <div class="text-[.65rem]">{gettext("verified")}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</aside> </aside>
</div> </div>
</section>
<section :if={@my_requests != []} class="mt-12"> <section :if={@my_requests != []} class="mt-12">
<h2 class="text-2xl font-bold">{gettext("Your requests")}</h2> <h2 class="text-2xl font-bold">{gettext("Your requests")}</h2>

View File

@ -72,7 +72,9 @@ defmodule WhoNeedHelpWeb.Telemetry do
sum("who_need_help.database.query.execution.duration.microseconds.total", sum("who_need_help.database.query.execution.duration.microseconds.total",
event_name: [:who_need_help, :repo, :query], event_name: [:who_need_help, :repo, :query],
measurement: fn measurements -> measurement: fn measurements ->
System.convert_time_unit(measurements.query_time, :native, :microsecond) measurements
|> Map.get(:query_time, 0)
|> System.convert_time_unit(:native, :microsecond)
end, end,
description: "Cumulative database execution duration" description: "Cumulative database execution duration"
), ),

View File

@ -0,0 +1,13 @@
defmodule WhoNeedHelp.Repo.Migrations.AddHelpRequestGeographyIndex do
use Ecto.Migration
def change do
create(
index(:help_requests, ["(location::geography)"],
name: :help_requests_location_geography_index,
using: :gist,
where: "location IS NOT NULL"
)
)
end
end

View File

@ -59,9 +59,9 @@ run_mix() {
"$image" "$@" "$image" "$@"
} }
echo "Migrating to the pre-index baseline" echo "Migrating to the pre-spatial-index baseline"
run_mix mix ecto.create run_mix mix ecto.create
run_mix mix ecto.migrate --to 20260719004249 run_mix mix ecto.migrate --to 20260722110837
echo "Seeding and measuring $row_count rows per large benchmark table" echo "Seeding and measuring $row_count rows per large benchmark table"
run_mix mix wnh.db_scale_benchmark \ run_mix mix wnh.db_scale_benchmark \
@ -69,7 +69,7 @@ run_mix mix wnh.db_scale_benchmark \
--rows "$row_count" \ --rows "$row_count" \
--output /benchmark-output --output /benchmark-output
echo "Applying the generated cursor-index migration and measuring again" echo "Applying the generated geography-index migration and measuring again"
run_mix mix ecto.migrate run_mix mix ecto.migrate
run_mix mix wnh.db_scale_benchmark \ run_mix mix wnh.db_scale_benchmark \
--phase after \ --phase after \

View File

@ -18,6 +18,22 @@ esac
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
# Compose still resolves required interpolation values for `down`. These values
# mirror the isolated runner and do not select containers; the project label
# passed above is the scope used by Compose for removal.
export APP_IMAGE="who-need-help:e2e"
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e"
export POSTGIS_IMAGE="who-need-help:postgis-e2e"
export E2E_OUTPUT_DIR="$ROOT/output/e2e/stop-placeholder"
export E2E_UID
E2E_UID=$(id -u)
export E2E_GID
E2E_GID=$(id -g)
export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="$project"
export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
export MAP_TILE_URL="https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
docker compose \ docker compose \
--project-name "$project" \ --project-name "$project" \
--env-file "$ROOT/.env.e2e" \ --env-file "$ROOT/.env.e2e" \

View File

@ -0,0 +1,186 @@
defmodule WhoNeedHelp.Help.DiscoveryViewportTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.Help.DiscoveryViewport
test "casts string and atom parameters into a validated viewport" do
assert {:ok, viewport} =
DiscoveryViewport.cast(%{
"west" => "30.1",
"south" => "50.1",
"east" => "30.9",
"north" => "50.9",
"zoom" => "11.25",
"width" => "1280",
"height" => "720"
})
assert viewport.west == 30.1
assert viewport.zoom == 11.25
assert viewport.width == 1280
assert {:ok, atom_viewport} =
DiscoveryViewport.cast(%{
west: 30.1,
south: 50.1,
east: 30.9,
north: 50.9,
zoom: 11,
width: 800,
height: 600
})
assert atom_viewport.height == 600
end
test "rejects invalid bounds, zoom, and unbounded browser dimensions" do
base = %{
"west" => "30",
"south" => "50",
"east" => "31",
"north" => "51",
"zoom" => "10",
"width" => "800",
"height" => "600"
}
assert :error = DiscoveryViewport.cast(Map.put(base, "north", "49"))
assert :error = DiscoveryViewport.cast(Map.put(base, "west", "-181"))
assert :error = DiscoveryViewport.cast(Map.put(base, "zoom", "23"))
assert :error = DiscoveryViewport.cast(Map.put(base, "width", "1000000"))
assert :error = DiscoveryViewport.cast(Map.put(base, "south", "not-a-number"))
end
test "splits an antimeridian viewport into two PostGIS envelopes" do
{:ok, viewport} =
DiscoveryViewport.cast(%{
west: 170,
south: -10,
east: -170,
north: 10,
zoom: 3,
width: 800,
height: 600
})
assert DiscoveryViewport.envelopes(viewport) == [
{170.0, -10.0, 180.0, 10.0},
{-180.0, -10.0, -170.0, 10.0}
]
end
test "derives cluster detail from both geographic bounds and rendered pixels" do
{:ok, city} =
DiscoveryViewport.cast(%{
west: 30,
south: 50,
east: 31,
north: 51,
zoom: 9,
width: 800,
height: 600
})
{:ok, neighborhood} =
DiscoveryViewport.cast(%{
west: 30.4,
south: 50.4,
east: 30.6,
north: 50.6,
zoom: 13,
width: 800,
height: 600
})
{:ok, wide_canvas} =
DiscoveryViewport.cast(%{
west: 30,
south: 50,
east: 31,
north: 51,
zoom: 9,
width: 1600,
height: 1200
})
assert DiscoveryViewport.cluster_cell_meters(city) >
DiscoveryViewport.cluster_cell_meters(neighborhood)
assert DiscoveryViewport.cluster_cell_meters(city) >
DiscoveryViewport.cluster_cell_meters(wide_canvas)
end
test "serializes stable URL parameters and compares visible areas" do
{:ok, viewport} =
DiscoveryViewport.cast(%{
west: 30.1234567,
south: 50,
east: 31,
north: 51,
zoom: 10.126,
width: 800,
height: 600
})
assert DiscoveryViewport.query_params(viewport) == %{
"west" => "30.123457",
"south" => "50",
"east" => "31",
"north" => "51",
"zoom" => "10.13",
"width" => "800",
"height" => "600"
}
assert DiscoveryViewport.same_area?(viewport, viewport)
refute DiscoveryViewport.same_area?(viewport, %{viewport | zoom: 11.0})
end
test "subscription tiles stay bounded by rendered map capacity" do
{:ok, viewport} =
DiscoveryViewport.cast(%{
west: -180,
south: -80,
east: 180,
north: 80,
zoom: 22,
width: 800,
height: 600
})
tiles = DiscoveryViewport.subscription_tiles(viewport)
maximum_visible_tiles = (ceil(800 / 256) + 2) * (ceil(600 / 256) + 2)
assert length(tiles) <= maximum_visible_tiles
assert Enum.all?(tiles, fn {zoom, _x, _y} -> zoom < 22 end)
end
test "a privacy radius publishes to every intersected regional tile" do
point_tiles = DiscoveryViewport.point_tiles(50.4501, 30.5234, 0)
radius_tiles = DiscoveryViewport.point_tiles(50.4501, 30.5234, 2_000)
assert MapSet.subset?(MapSet.new(point_tiles), MapSet.new(radius_tiles))
assert length(radius_tiles) > length(point_tiles)
assert {0, 0, 0} in radius_tiles
end
test "a city viewport overlaps local point topics but not another country's at its zoom" do
{:ok, kyiv_viewport} =
DiscoveryViewport.cast(%{
west: 30.0,
south: 50.0,
east: 31.0,
north: 51.0,
zoom: 10,
width: 1_200,
height: 720
})
viewport_tiles = MapSet.new(DiscoveryViewport.subscription_tiles(kyiv_viewport))
kyiv_tiles = MapSet.new(DiscoveryViewport.point_tiles(50.4501, 30.5234, 1_000))
berlin_tiles = MapSet.new(DiscoveryViewport.point_tiles(52.52, 13.405, 1_000))
refute MapSet.disjoint?(viewport_tiles, kyiv_tiles)
assert MapSet.disjoint?(viewport_tiles, berlin_tiles)
end
end

View File

@ -4,6 +4,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust} alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
alias WhoNeedHelp.Help.DiscoveryViewport
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position alias WhoNeedHelp.Tracking.Position
alias WhoNeedHelpWeb.Presence alias WhoNeedHelpWeb.Presence
@ -299,6 +300,214 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert third.next_cursor == nil assert third.next_cursor == nil
end end
test "request discovery returns only the visible map area", context do
{:ok, kyiv_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Visible Kyiv request")
|> Map.put("location_label", "Kyiv center")
)
{:ok, intersecting_area} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Area intersects viewport")
|> Map.put("location_label", "Kyiv western edge")
|> Map.put("longitude", "29.99")
|> Map.put("location_radius_meters", 2_000)
)
{:ok, berlin_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Berlin request")
|> Map.put("location_label", "Berlin Mitte")
|> Map.put("latitude", "52.5200")
|> Map.put("longitude", "13.4050")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
ids =
context.helper_scope
|> Help.paginate_open_requests(%{}, viewport: viewport, limit: 100)
|> Map.fetch!(:entries)
|> Enum.map(& &1.id)
assert kyiv_request.id in ids
assert intersecting_area.id in ids
refute berlin_request.id in ids
end
test "hidden coordinates require an explicit area-label search and never become map points",
context do
{:ok, hidden_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Hidden Kyiv request")
|> Map.put("location_label", "Kyiv Podil")
|> Map.put("location_visibility", "hidden")
|> Map.delete("latitude")
|> Map.delete("longitude")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
without_search =
Help.paginate_open_requests(context.helper_scope, %{}, viewport: viewport, limit: 100)
refute Enum.any?(without_search.entries, &(&1.id == hidden_request.id))
with_search =
Help.paginate_open_requests(
context.helper_scope,
%{"area" => "Kyiv Podil"},
viewport: viewport,
limit: 100
)
assert Enum.any?(with_search.entries, &(&1.id == hidden_request.id))
refute Enum.any?(
Help.map_discovery_items(
context.helper_scope,
%{"area" => "Kyiv Podil"},
viewport
),
&(&1[:id] == hidden_request.id)
)
end
test "map discovery aggregates dense points on the server and preserves sparse requests",
context do
dense_requests =
for {offset, index} <- Enum.with_index([0.0, 0.00005, 0.0001], 1) do
{:ok, request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Dense request #{index}")
|> Map.put("longitude", Float.to_string(30.5234 + offset))
)
request
end
{:ok, sparse_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Sparse exact request")
|> Map.put("latitude", "50.8")
|> Map.put("longitude", "30.8")
|> Map.put("location_visibility", "exact_public")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 9)
items = Help.map_discovery_items(context.helper_scope, %{}, viewport)
assert Enum.any?(items, fn
%{type: "cluster", count: 3} -> true
_item -> false
end)
assert Enum.any?(items, fn
%{type: "request", id: id, exact: true} -> id == sparse_request.id
_item -> false
end)
dense_ids = MapSet.new(dense_requests, & &1.id)
refute Enum.any?(items, fn
%{type: "request", id: id} -> MapSet.member?(dense_ids, id)
_item -> false
end)
end
test "map discovery does not expose an exact-for-match coordinate", context do
{:ok, request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Private until matched")
|> Map.put("latitude", "50.4567")
|> Map.put("longitude", "30.5267")
|> Map.put("location_visibility", "exact_for_active_match")
)
item =
context.helper_scope
|> Help.map_discovery_items(%{}, viewport!(30.50, 50.40, 30.60, 50.50, 15))
|> Enum.find(&(&1[:id] == request.id))
assert %{type: "request", exact: false, radius_meters: 1_000} = item
assert_in_delta item.latitude, 50.46, 0.000_001
assert_in_delta item.longitude, 30.53, 0.000_001
refute_in_delta item.latitude, 50.4567, 0.000_001
refute_in_delta item.longitude, 30.5267, 0.000_001
end
test "area search treats SQL wildcard characters as literal text", context do
{:ok, literal_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Literal wildcard label")
|> Map.put("location_label", "Sector 100%_safe")
)
{:ok, _ordinary_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Ordinary label")
|> Map.put("location_label", "Sector 100 percent safe")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
page =
Help.paginate_open_requests(
context.helper_scope,
%{"area" => "%_"},
viewport: viewport,
limit: 100
)
assert Enum.map(page.entries, & &1.id) == [literal_request.id]
end
test "regional discovery PubSub ignores requests from another map area", context do
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
topics = Help.subscribe_discovery(viewport)
on_exit(fn -> Help.unsubscribe_discovery(topics) end)
{:ok, berlin_request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Berlin regional event")
|> Map.put("latitude", "52.5200")
|> Map.put("longitude", "13.4050")
)
berlin_request_id = berlin_request.id
refute_receive {:request_created, %{id: ^berlin_request_id}}
{:ok, kyiv_request} =
Help.create_request(
context.requester_scope,
Map.put(context.request_attrs, "title", "Kyiv regional event")
)
kyiv_request_id = kyiv_request.id
assert_receive {:request_created, %{id: ^kyiv_request_id}}
end
test "matched chat loads newest cursor pages and preserves chronological order", context do test "matched chat loads newest cursor pages and preserves chronological order", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -339,6 +548,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert oldest.next_cursor == nil assert oldest.next_cursor == nil
end end
defp viewport!(west, south, east, north, zoom) do
{:ok, viewport} =
DiscoveryViewport.cast(%{
west: west,
south: south,
east: east,
north: north,
zoom: zoom,
width: 1_200,
height: 720
})
viewport
end
test "chat and review return not found for a stale assignment reference", context do test "chat and review return not found for a stale assignment reference", context do
stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()} stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}

View File

@ -42,4 +42,15 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
assert body =~ "who_need_help_database_query_queue_duration_microseconds_total " assert body =~ "who_need_help_database_query_queue_duration_microseconds_total "
assert body =~ "who_need_help_database_query_decode_duration_microseconds_total " assert body =~ "who_need_help_database_query_decode_duration_microseconds_total "
end end
test "database execution metric tolerates events without query_time" do
metric =
WhoNeedHelpWeb.Telemetry.prometheus_metrics()
|> Enum.find(fn metric ->
metric.name ==
[:who_need_help, :database, :query, :execution, :duration, :microseconds, :total]
end)
assert metric.measurement.(%{queue_time: 100, total_time: 100}) == 0
end
end end

View File

@ -126,7 +126,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Realtime requester") requester = user_fixture(display_name: "Realtime requester")
helper = user_fixture(display_name: "Realtime helper") helper = user_fixture(display_name: "Realtime helper")
{:ok, view, _html} = build_conn() |> log_in_user(helper) |> live(~p"/requests")
{:ok, view, _html} =
build_conn() |> log_in_user(helper) |> live(requests_in_kyiv_path())
{:ok, request} = {:ok, request} =
Help.create_request(Accounts.Scope.for_user(requester), %{ Help.create_request(Accounts.Scope.for_user(requester), %{
@ -144,17 +146,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"safety_confirmed" => true "safety_confirmed" => true
}) })
assert render(view) =~ "Realtime medicine pickup" assert_eventually(fn -> render(view) =~ "Realtime medicine pickup" end)
{:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id) {:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
refute render(view) =~ "Realtime medicine pickup"
assert_eventually(fn ->
not String.contains?(render(view), "Realtime medicine pickup")
end)
end end
test "request realtime updates keep a bounded window without skipping pagination" do test "request realtime updates keep a bounded window without skipping pagination" do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Bounded realtime requester") requester = user_fixture(display_name: "Bounded realtime requester")
viewer = user_fixture(display_name: "Bounded realtime viewer") viewer = user_fixture(display_name: "Bounded realtime viewer")
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/requests")
{:ok, view, _html} =
build_conn() |> log_in_user(viewer) |> live(requests_in_kyiv_path())
requests = requests =
for index <- 1..25 do for index <- 1..25 do
@ -171,14 +178,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
request request
end end
render(view)
first = List.first(requests) first = List.first(requests)
overflow = List.last(requests) overflow = List.last(requests)
assert has_element?(view, "#open-request-#{first.id}") assert_eventually(fn -> has_element?(view, "#open-request-#{first.id}") end)
refute has_element?(view, "#open-request-#{overflow.id}") refute has_element?(view, "#open-request-#{overflow.id}")
view |> element("button", "Load more") |> render_click() view |> element("button", "Load more in this area") |> render_click()
assert has_element?(view, "#open-request-#{overflow.id}") assert has_element?(view, "#open-request-#{overflow.id}")
end end
@ -1062,6 +1068,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
} }
end end
defp requests_in_kyiv_path do
~p"/requests?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720, view: "split"}}"
end
defp assert_eventually(fun, attempts \\ 50)
defp assert_eventually(fun, attempts) when attempts > 0 do
if fun.() do
assert true
else
Process.sleep(10)
assert_eventually(fun, attempts - 1)
end
end
defp assert_eventually(_fun, 0), do: flunk("condition did not become true")
defp activity_attrs(category) do defp activity_attrs(category) do
%{ %{
"title" => "Public coffee meetup", "title" => "Public coffee meetup",