diff --git a/.gitignore b/.gitignore index cedad00..a181d55 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ who_need_help-*.tar npm-debug.log /assets/node_modules/ /e2e/node_modules/ +/e2e/output/ /.tools/ __pycache__/ *.py[cod] diff --git a/README.md b/README.md index 8bfa496..d92081a 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,11 @@ local Codex CLI authenticated with their ChatGPT subscription. only the linked evidence to audited moderators. - Request lifecycle: `open → matched → in_progress → completed`, plus cancel and expiry paths. -- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix - PubSub/Presence, and optional consent-driven live location sharing. +- PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side + 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. - Double-blind reviews, public trust summaries, and a helper leaderboard that prioritizes unique location-supported and handover-verified counterparts diff --git a/assets/css/app.css b/assets/css/app.css index 0c10035..51982cb 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -158,6 +158,184 @@ html { 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 { min-height: 32rem; border-radius: 2rem; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index a80d06e..3f37000 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -17,7 +17,7 @@ const defaultStyle = { const markerPoints = element => { try { - const points = JSON.parse(element.dataset.markers || "[]") + const points = JSON.parse(element.dataset.items || element.dataset.markers || "[]") if (!Array.isArray(points)) return [] @@ -31,7 +31,8 @@ const markerPoints = element => { 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 angularDistance = radiusMeters / earthRadiusMeters const latitudeRadians = latitude * Math.PI / 180 @@ -54,7 +55,13 @@ const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => { return { 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]} } } @@ -66,6 +73,55 @@ const areaFeatureCollection = points => ({ .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) => { feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate)) } @@ -85,10 +141,20 @@ const createAidMap = element => { const state = { element, map: null, + active: true, + viewportFrame: null, + resizeFrame: null, + resizeObserver: null, markers: [], + markerElements: new Map(), + initializedViewport: false, areaSourceId: `${element.id || "aid-map"}-areas`, 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 = () => { @@ -154,21 +220,92 @@ const createAidMap = element => { 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 = () => { if (!state.map) return state.markers.forEach(marker => marker.remove()) state.markers = [] + state.markerElements.clear() 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 clusterFeatures = clusterFeatureCollection(points) if (state.map.isStyleLoaded()) { - const source = state.map.getSource(state.areaSourceId) + const areaSource = state.map.getSource(state.areaSourceId) - if (source) { - source.setData(areaFeatures) + if (areaSource) { + areaSource.setData(areaFeatures) } else { state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures}) state.map.addLayer({ @@ -187,28 +324,75 @@ const createAidMap = element => { "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 => { - const popupContent = document.createElement("div") - 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 popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point)) const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"}) .setLngLat([point.longitude, point.latitude]) .setPopup(popup) .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) }) - 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() exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude])) 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}) } } @@ -229,6 +413,9 @@ const createAidMap = element => { center: [30.5234, 50.4501], zoom: 5, attributionControl: true, + renderWorldCopies: false, + maxBounds: [[-180, -85.05112878], [180, 85.05112878]], + trackResize: false, dragRotate: !demoMap, pitchWithRotate: !demoMap, scrollZoom: !demoMap @@ -244,22 +431,71 @@ const createAidMap = element => { new maplibregl.NavigationControl({showCompass: !demoMap}), demoMap ? "bottom-right" : "top-right" ) - state.onLoad = () => { - state.markReady() - state.renderMarkers() + + if (state.element.dataset.discoveryMap === "true" && navigator.geolocation) { + 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("load", state.onLoad) 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.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("load", state.onLoad) state.map?.off("idle", state.markReady) + state.map?.off("moveend", state.onMoveEnd) + window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) state.map?.remove() + state.map = null } return state @@ -557,13 +793,105 @@ export const Hooks = { AidMap: { mounted() { 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() }, updated() { + this.aidMap.applySavedViewport() this.aidMap.renderMarkers() + this.aidMap.resize() }, destroyed() { 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 - context.getExtension("WEBGL_lose_context")?.loseContext() return true } catch (_error) { return false diff --git a/docs/architecture.md b/docs/architecture.md index 3826441..d04c8e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 - Requests, activities, membership decisions, messages, reviews, reports, and diff --git a/e2e/tests/request-discovery.spec.ts b/e2e/tests/request-discovery.spec.ts new file mode 100644 index 0000000..7de0b08 --- /dev/null +++ b/e2e/tests/request-discovery.spec.ts @@ -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[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(); +}); diff --git a/lib/mix/tasks/wnh.db_scale_benchmark.ex b/lib/mix/tasks/wnh.db_scale_benchmark.ex index 4ee17d0..9b8ab11 100644 --- a/lib/mix/tasks/wnh.db_scale_benchmark.ex +++ b/lib/mix/tasks/wnh.db_scale_benchmark.ex @@ -3,10 +3,11 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do 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 """ - 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. 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(), database_bytes: scalar!("SELECT pg_database_size(current_database())"), index_bytes: index_sizes(), + viewport_discovery: viewport_discovery_measurements(), plans: plans } @@ -115,7 +117,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do """ INSERT INTO help_requests ( 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 ) SELECT @@ -123,10 +126,21 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do 'DB scale request ' || value, 'Synthetic isolated database scale request ' || value, '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, 'now', - 'approximate_public', + 'exact_public', + NULL, date_trunc('second', now()) + (value % 30 + 1) * interval '1 day', CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END, md5('user-1')::uuid, @@ -358,6 +372,48 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do ) 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" => """ SELECT id FROM help_requests WHERE requester_id = md5('user-1')::uuid @@ -501,6 +557,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do defp expected_indexes do %{ "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", "activity_discovery" => "activities_discovery_cursor_index", "activity_participant" => "activity_participants_user_id_status_index", @@ -573,6 +631,61 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do 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 result = Repo.query!(""" diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 9dc82e2..720679c 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Help do alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog.Category - alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest} alias WhoNeedHelp.Pagination alias WhoNeedHelp.Push alias WhoNeedHelp.Repo @@ -15,9 +15,24 @@ defmodule WhoNeedHelp.Help do alias WhoNeedHelp.Trust.Block @topic "help:requests" + @hidden_discovery_topic "help:discovery:hidden" 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), do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}") @@ -32,28 +47,12 @@ defmodule WhoNeedHelp.Help do end def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do - now = DateTime.utc_now(:second) limit = Pagination.limit(options) cursor = Pagination.cursor(options) + viewport = Keyword.get(options, :viewport) - 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) + user + |> open_requests_query(filters, viewport) |> after_open_request(cursor) |> order_by([request], asc: request.expires_at, asc: request.id) |> limit(^(limit + 1)) @@ -62,6 +61,94 @@ defmodule WhoNeedHelp.Help do |> Pagination.page(limit, &{&1.expires_at, &1.id}) 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 visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do @@ -697,20 +784,59 @@ defmodule WhoNeedHelp.Help do end 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 + 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 query |> maybe_filter(:category_id, filters["category_id"] || filters[:category_id]) |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency]) + |> maybe_filter(:area, filters["area"] || filters[:area]) end defp request_filter_matches?(request, filters) do category_id = filters["category_id"] || filters[:category_id] 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 - (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 defp maybe_filter(query, _field, value) when value in [nil, ""], do: query @@ -729,6 +855,128 @@ defmodule WhoNeedHelp.Help do 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 [:today, "today"], do: {:ok, :today} defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled} diff --git a/lib/who_need_help/help/discovery_viewport.ex b/lib/who_need_help/help/discovery_viewport.ex new file mode 100644 index 0000000..45e4ea3 --- /dev/null +++ b/lib/who_need_help/help/discovery_viewport.ex @@ -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 diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex index e169f86..25d75d1 100644 --- a/lib/who_need_help_web/live/request_live/index.ex +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -2,58 +2,109 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do use WhoNeedHelpWeb, :live_view alias WhoNeedHelp.{Help, Pagination, Trust} + alias WhoNeedHelp.Help.DiscoveryViewport + + @view_modes ~w(list split map) + @filter_keys ~w(category_id urgency area) @impl true 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, socket - |> assign(:filters, %{"category_id" => "", "urgency" => ""}) - |> load()} + |> assign(:filters, filters) + |> 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 @impl true - def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do - user = socket.assigns.current_scope.user - - {requests, requests_cursor} = - 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 - ) + def handle_params(params, _uri, socket) do + filters = normalize_filters(params) + viewport = parse_viewport(params) + view_mode = normalize_view_mode(params["view"]) socket = socket - |> assign(:requests, requests) - |> assign(:requests_cursor, requests_cursor) - |> assign(:my_requests, my_requests) - |> assign(:my_requests_cursor, my_requests_cursor) - |> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1))))) - |> maybe_refresh_reputation(request, user.id) + |> assign(:filters, filters) + |> assign(:filter_form, to_form(filters, as: :filters)) + |> assign(:view_mode, view_mode) + |> assign(:view_explicit, params["view"] in @view_modes) + |> assign(:viewport, viewport) + |> assign(:pending_viewport, nil) + |> assign(:query_params, normalize_query_params(filters, viewport, view_mode)) + |> refresh_discovery_subscription(viewport, filters) + |> load() {:noreply, socket} end @impl true 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 def handle_event("load-more-requests", _params, socket) do @@ -61,7 +112,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do Help.paginate_open_requests( socket.assigns.current_scope, 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) @@ -69,8 +121,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do {:noreply, socket |> assign(:requests, requests) - |> assign(:requests_cursor, page.next_cursor) - |> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))} + |> assign(:requests_cursor, page.next_cursor)} end 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)} 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 - requests_page = - Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters) + {requests_page, map_items} = + 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) - requests = requests_page.entries user = socket.assigns.current_scope.user socket - |> assign(:page_title, gettext("Nearby help")) - |> assign(:requests, requests) + |> assign(:requests, requests_page.entries) |> assign(:requests_cursor, requests_page.next_cursor) |> assign(:my_requests, my_requests_page.entries) |> assign(:my_requests_cursor, my_requests_page.next_cursor) |> assign(:reputation, Trust.reputation(user.id)) - |> assign(:categories, WhoNeedHelp.Catalog.list_categories()) - |> assign(:filter_form, to_form(socket.assigns.filters, as: :filters)) - |> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1))))) + |> assign(:map_items, Jason.encode!(map_items)) + |> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2))) + 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 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)) 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 assignment = request.assignment @@ -151,20 +222,43 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do end end - defp marker(request) do - case Help.HelpRequest.public_coordinates(request) do - nil -> - nil + defp map_item_count(%{type: "cluster", count: count}), do: count + defp map_item_count(_request), do: 1 - coordinates -> - Map.merge(coordinates, %{ - id: request.id, - title: request.title, - location: request.location_label - }) + defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""} + + defp normalize_filters(params) do + Map.new(@filter_keys, fn key -> + {key, params[key] |> to_string() |> String.slice(0, 100)} + 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 + defp urgency_label(:now), do: gettext("Now") defp urgency_label("now"), do: gettext("Now") defp urgency_label(:today), do: gettext("Today") @@ -187,6 +281,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do defp status_label("expired"), do: gettext("Expired") 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 def render(assigns) do ~H""" @@ -196,7 +298,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{gettext("NEARBY MUTUAL AID")}

{gettext("Who needs help?")}

- {gettext("Only approximate areas are shown before a safe match.")} + {gettext( + "Explore privacy-friendly areas and public exact meeting points chosen by requesters." + )}

<.link navigate={~p"/requests/new"} class="btn btn-primary"> @@ -217,7 +321,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do for={@filter_form} id="request-filters" 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 field={@filter_form[:category_id]} @@ -245,98 +349,206 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do {gettext("Scheduled"), "scheduled"} ]} /> + <.input + field={@filter_form[:area]} + label={gettext("Area or city")} + placeholder={gettext("Optional text search")} + phx-debounce="400" + /> -
-
- <%= if @requests == [] do %> -
-

{gettext("No open requests yet")}

-

- {gettext("You can be the first person to ask the community.")} -

-
- <% end %> +
+
+
+ + {ngettext( + "%{count} request in this map area", + "%{count} requests in this map area", + @map_result_count, + count: @map_result_count + )} + +

+ {gettext("Move the map, then search the newly visible area.")} +

+
- <.link - :for={request <- @requests} - id={"open-request-#{request.id}"} - navigate={~p"/requests/#{request.id}"} - class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6" - > -
-
-
- - {WhoNeedHelp.Catalog.Category.name( - request.category, - Gettext.get_locale(WhoNeedHelpWeb.Gettext) - )} - - {urgency_label(request.urgency)} -
-

{request.title}

-

{request.description}

-
- <.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" /> -
-
- 📍 {request.location_label} - - {gettext("by %{name}", - name: request.requester.display_name || gettext("Community member") - )} - - - {gettext("expires %{time}", - time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC") - )} - -
- - +
+ + + +
- +
+

{gettext("Your requests")}

diff --git a/lib/who_need_help_web/telemetry.ex b/lib/who_need_help_web/telemetry.ex index 98d8c1c..71d36b1 100644 --- a/lib/who_need_help_web/telemetry.ex +++ b/lib/who_need_help_web/telemetry.ex @@ -72,7 +72,9 @@ defmodule WhoNeedHelpWeb.Telemetry do sum("who_need_help.database.query.execution.duration.microseconds.total", event_name: [:who_need_help, :repo, :query], 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, description: "Cumulative database execution duration" ), diff --git a/priv/repo/migrations/20260722123052_add_help_request_geography_index.exs b/priv/repo/migrations/20260722123052_add_help_request_geography_index.exs new file mode 100644 index 0000000..17ad8d8 --- /dev/null +++ b/priv/repo/migrations/20260722123052_add_help_request_geography_index.exs @@ -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 diff --git a/scripts/db-scale-benchmark.sh b/scripts/db-scale-benchmark.sh index af5b1bc..26b1f71 100755 --- a/scripts/db-scale-benchmark.sh +++ b/scripts/db-scale-benchmark.sh @@ -59,9 +59,9 @@ run_mix() { "$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.migrate --to 20260719004249 +run_mix mix ecto.migrate --to 20260722110837 echo "Seeding and measuring $row_count rows per large benchmark table" run_mix mix wnh.db_scale_benchmark \ @@ -69,7 +69,7 @@ run_mix mix wnh.db_scale_benchmark \ --rows "$row_count" \ --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 wnh.db_scale_benchmark \ --phase after \ diff --git a/scripts/e2e-stop.sh b/scripts/e2e-stop.sh index 1469c4b..5003ffe 100755 --- a/scripts/e2e-stop.sh +++ b/scripts/e2e-stop.sh @@ -18,6 +18,22 @@ esac 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 \ --project-name "$project" \ --env-file "$ROOT/.env.e2e" \ diff --git a/test/who_need_help/help/discovery_viewport_test.exs b/test/who_need_help/help/discovery_viewport_test.exs new file mode 100644 index 0000000..d2f9842 --- /dev/null +++ b/test/who_need_help/help/discovery_viewport_test.exs @@ -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 diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index 4d94b23..3e9fdbe 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -4,6 +4,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do import WhoNeedHelp.AccountsFixtures alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust} + alias WhoNeedHelp.Help.DiscoveryViewport alias WhoNeedHelp.Repo alias WhoNeedHelp.Tracking.Position alias WhoNeedHelpWeb.Presence @@ -299,6 +300,214 @@ defmodule WhoNeedHelp.MutualAidFlowTest do assert third.next_cursor == nil 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 {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) @@ -339,6 +548,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do assert oldest.next_cursor == nil 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 stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()} diff --git a/test/who_need_help_web/controllers/metrics_controller_test.exs b/test/who_need_help_web/controllers/metrics_controller_test.exs index 93cc5fd..7b124b4 100644 --- a/test/who_need_help_web/controllers/metrics_controller_test.exs +++ b/test/who_need_help_web/controllers/metrics_controller_test.exs @@ -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_decode_duration_microseconds_total " 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 diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index f4a657d..2b323e0 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -126,7 +126,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do category = Catalog.seed_defaults() requester = user_fixture(display_name: "Realtime requester") 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} = Help.create_request(Accounts.Scope.for_user(requester), %{ @@ -144,17 +146,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do "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) - refute render(view) =~ "Realtime medicine pickup" + + assert_eventually(fn -> + not String.contains?(render(view), "Realtime medicine pickup") + end) end test "request realtime updates keep a bounded window without skipping pagination" do category = Catalog.seed_defaults() requester = user_fixture(display_name: "Bounded realtime requester") 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 = for index <- 1..25 do @@ -171,14 +178,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do request end - render(view) first = List.first(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}") - 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}") end @@ -1062,6 +1068,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do } 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 %{ "title" => "Public coffee meetup",