fix: restore list map previews (batch geojson query)
Map previews were missing on every list view (activities, routes, home,
profile, feed): the batch geojson helpers used `WHERE id = ANY(${ids}::text[])`,
but drizzle expands a JS array in a sql template to `($1,$2,...)`, producing the
invalid `ANY((...)::text[])`. That throws, and the helpers' `try/catch` swallowed
it — so every card fell back to "No map preview".
Rewrite getSimplifiedActivityGeojsonBatch (activities) and getSimplifiedGeojsonBatch
(routes) to use the query builder: `inArray(table.id, ids)` for the id list +
a raw `ST_AsGeoJSON(ST_Simplify(geom, 0.001))` select column. Same result, valid
SQL.
E2E: list-map-previews.test.ts creates an activity with geometry and asserts a
Leaflet preview renders on /activities (no "No map preview"). Registered in
playwright.config. typecheck + lint + unit (journal 315) green; verified in the
browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8cacc7d91e
commit
0ee4daaed1
4 changed files with 62 additions and 14 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq, desc, and, isNotNull, sql } from "drizzle-orm";
|
import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm";
|
||||||
import { unionAll } from "drizzle-orm/pg-core";
|
import { unionAll } from "drizzle-orm/pg-core";
|
||||||
import { getDb } from "./db.ts";
|
import { getDb } from "./db.ts";
|
||||||
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
|
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
|
||||||
|
|
@ -376,12 +376,17 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<str
|
||||||
if (ids.length === 0) return map;
|
if (ids.length === 0) return map;
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = await db.execute(
|
// Use the query builder for the id list: a raw `ANY(${ids}::text[])`
|
||||||
sql`SELECT id, ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson
|
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid
|
||||||
FROM journal.activities
|
// `ANY((...)::text[])` — which throws and silently drops every preview.
|
||||||
WHERE id = ANY(${ids}::text[]) AND geom IS NOT NULL`,
|
const rows = await db
|
||||||
);
|
.select({
|
||||||
for (const row of result as unknown as Array<{ id: string; geojson: string }>) {
|
id: activities.id,
|
||||||
|
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`,
|
||||||
|
})
|
||||||
|
.from(activities)
|
||||||
|
.where(and(inArray(activities.id, ids), isNotNull(activities.geom)));
|
||||||
|
for (const row of rows) {
|
||||||
if (row.geojson) map.set(row.id, row.geojson);
|
if (row.geojson) map.set(row.id, row.geojson);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq, desc, and } from "drizzle-orm";
|
import { eq, desc, and, inArray, isNotNull } from "drizzle-orm";
|
||||||
import { getDb } from "./db.ts";
|
import { getDb } from "./db.ts";
|
||||||
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
|
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||||
|
|
@ -209,12 +209,17 @@ async function getSimplifiedGeojsonBatch(ids: string[]): Promise<Map<string, str
|
||||||
if (ids.length === 0) return map;
|
if (ids.length === 0) return map;
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = await db.execute(
|
// Query-builder id list: a raw `ANY(${ids}::text[])` makes drizzle expand
|
||||||
sql`SELECT id, ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson
|
// the array to `($1,$2,...)`, yielding the invalid `ANY((...)::text[])`
|
||||||
FROM journal.routes
|
// — it throws and silently drops every route preview.
|
||||||
WHERE id = ANY(${ids}::text[]) AND geom IS NOT NULL`,
|
const rows = await db
|
||||||
);
|
.select({
|
||||||
for (const row of result as unknown as Array<{ id: string; geojson: string }>) {
|
id: routes.id,
|
||||||
|
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${routes.geom}, 0.001))`,
|
||||||
|
})
|
||||||
|
.from(routes)
|
||||||
|
.where(and(inArray(routes.id, ids), isNotNull(routes.geom)));
|
||||||
|
for (const row of rows) {
|
||||||
if (row.geojson) map.set(row.id, row.geojson);
|
if (row.geojson) map.set(row.id, row.geojson);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
30
e2e/list-map-previews.test.ts
Normal file
30
e2e/list-map-previews.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { test, expect, gotoHydrated } from "./fixtures/test";
|
||||||
|
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
|
||||||
|
|
||||||
|
// A GPX fixture with geometry → the activity gets a PostGIS geom, so its list
|
||||||
|
// card should render a Leaflet map preview. Guards the batch-geojson query
|
||||||
|
// (a malformed `ANY(${ids}::text[])` previously threw and was swallowed,
|
||||||
|
// silently dropping every list preview).
|
||||||
|
const GPX = "packages/fit/__fixtures__/alpine.gpx";
|
||||||
|
|
||||||
|
test.describe("List map previews", () => {
|
||||||
|
test("activities list renders a map preview for an activity with geometry", async ({ page }) => {
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await setupVirtualAuthenticator(cdp);
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
await registerUser(page, `lmp-${stamp}@example.com`, `lmp${stamp}`);
|
||||||
|
|
||||||
|
await gotoHydrated(page, "/activities/new");
|
||||||
|
await page.getByLabel("Name").fill("Preview check");
|
||||||
|
await page.locator('input[name="gpx"]').setInputFiles(GPX);
|
||||||
|
await page.getByRole("button", { name: "Create Activity" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/activities\/[0-9a-f-]+$/, { timeout: 10000 });
|
||||||
|
|
||||||
|
await gotoHydrated(page, "/activities");
|
||||||
|
// The Leaflet container mounts after the client-only dynamic import; no
|
||||||
|
// "No map preview" fallback should remain.
|
||||||
|
await expect(page.locator(".leaflet-container").first()).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText("No map preview")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -130,6 +130,14 @@ export default defineConfig({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "list-map-previews",
|
||||||
|
testMatch: "list-map-previews.test.ts",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webServer: process.env.CI
|
webServer: process.env.CI
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue