From bfa61e5610d69264793ee22ee9ca3cb0adca3106 Mon Sep 17 00:00:00 2001 From: Jacob Hurwitz Date: Wed, 13 May 2026 16:36:55 -0700 Subject: [PATCH] Show historical lo locations only up to the current lo's timestamp - Adds `before` param to /api/lo/history to filter by timestamp - Intensity is renormalized so the current lo is always intensity=1 (orange) - Fixes duplicate history markers being added on re-render - Uses cubic curve (t^3) for color interpolation to distinguish recent locations Co-Authored-By: Claude Sonnet 4.6 --- src/components/OysList.tsx | 2 +- src/map.ts | 8 ++++++-- tests/worker/oys.test.ts | 25 +++++++++++++++++++++++++ tests/worker/testUtils.ts | 7 ++++--- worker/routes/oys.ts | 8 +++++++- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/components/OysList.tsx b/src/components/OysList.tsx index d462619..2b94a88 100644 --- a/src/components/OysList.tsx +++ b/src/components/OysList.tsx @@ -129,7 +129,7 @@ export function OysList(props: OysListProps) { oy.from_user_id === props.currentUserId ? oy.to_user_id : oy.from_user_id; try { const response = await props.api( - `/api/lo/history?friendId=${friendId}&direction=${direction}`, + `/api/lo/history?friendId=${friendId}&direction=${direction}&before=${oy.created_at}`, ); setHistoryCache((prev) => { const next = new Map(prev); diff --git a/src/map.ts b/src/map.ts index 9aff674..f816bea 100644 --- a/src/map.ts +++ b/src/map.ts @@ -19,6 +19,7 @@ L.Icon.Default.imagePath = ""; type MapContainer = HTMLDivElement & { _leafletMap?: L.Map; _leafletTileLayer?: L.TileLayer; + _historyAdded?: boolean; }; export type LoHistoryPoint = { @@ -73,8 +74,9 @@ export function initLocationMap( history?: LoHistoryPoint[], ) { if (container.dataset.mapInit === "true") { - if (history && container._leafletMap) { + if (history && container._leafletMap && !container._historyAdded) { addHistoryMarkers(container._leafletMap, history); + container._historyAdded = true; } return; } @@ -96,6 +98,7 @@ export function initLocationMap( if (history && history.length > 0) { addHistoryMarkers(map, history); + container._historyAdded = true; } L.marker([lat, lon]).addTo(map); @@ -121,7 +124,8 @@ function addHistoryMarkers(map: L.Map, history: LoHistoryPoint[]) { const accent = getCssVar("--accent", "#f59e0b"); for (const point of history) { - const t = point.intensity; + // Cubic curve compresses old points toward purple, spreading recent ones across the color range. + const t = Math.pow(point.intensity, 3); const color = interpolateColor(primary, accent, t); L.circleMarker([point.lat, point.lon], { radius: lerp(3, 7, t), diff --git a/tests/worker/oys.test.ts b/tests/worker/oys.test.ts index bbab770..25d16dc 100644 --- a/tests/worker/oys.test.ts +++ b/tests/worker/oys.test.ts @@ -544,6 +544,31 @@ describe("oys and los", () => { assert.ok(multi.locations[1].intensity > 0 && multi.locations[1].intensity < 1); }); + it("filters to locations at or before the given before timestamp, renormalizing intensity", async () => { + const { env, db } = createTestEnv(); + const me = seedUser(db, { username: "Me" }); + const friend = seedUser(db, { username: "Friend" }); + seedSession(db, me.id, "before-token"); + + seedOy(db, { fromUserId: friend.id, toUserId: me.id, type: "lo", payload: '{"lat":1.0,"lon":1.0}', createdAt: 100 }); + seedOy(db, { fromUserId: friend.id, toUserId: me.id, type: "lo", payload: '{"lat":2.0,"lon":2.0}', createdAt: 200 }); + seedOy(db, { fromUserId: friend.id, toUserId: me.id, type: "lo", payload: '{"lat":3.0,"lon":3.0}', createdAt: 300 }); + + // before=200 should return only createdAt 100 and 200, with intensity re-normalized + const { res, json } = await jsonRequest( + env, + `/api/lo/history?friendId=${friend.id}&direction=inbound&before=200`, + { headers: { "x-session-token": "before-token" } }, + ); + const body = json as { locations: Array<{ lat: number; intensity: number }> }; + assert.equal(res.status, 200); + assert.equal(body.locations.length, 2); + assert.equal(body.locations[0].lat, 1.0); + assert.equal(body.locations[0].intensity, 0); // oldest + assert.equal(body.locations[1].lat, 2.0); + assert.equal(body.locations[1].intensity, 1); // newest = the current lo, always 1 + }); + it("cannot fetch another user's location history by spoofing friendId", async () => { const { env, db } = createTestEnv(); const me = seedUser(db, { username: "Me" }); diff --git a/tests/worker/testUtils.ts b/tests/worker/testUtils.ts index e48b614..3d3cf26 100644 --- a/tests/worker/testUtils.ts +++ b/tests/worker/testUtils.ts @@ -1559,17 +1559,18 @@ class FakeD1PreparedStatement implements D1PreparedStatement { "SELECT payload, created_at FROM oys WHERE from_user_id = ? AND to_user_id = ? AND type = 'lo' AND payload IS NOT NULL", ) ) { - const [fromUserId, toUserId, limit] = this.params as [number, number, number]; + const [fromUserId, toUserId, before] = this.params as [number, number, number | undefined]; const results = this.db.oys .filter( (row) => row.from_user_id === fromUserId && row.to_user_id === toUserId && row.type === "lo" && - row.payload !== null, + row.payload !== null && + (before === undefined || row.created_at <= before), ) .sort((a, b) => b.created_at - a.created_at) - .slice(0, limit) + .slice(0, 50) .map((row) => ({ payload: row.payload, created_at: row.created_at })); return { results }; } diff --git a/worker/routes/oys.ts b/worker/routes/oys.ts index 99e6b00..727ec7f 100644 --- a/worker/routes/oys.ts +++ b/worker/routes/oys.ts @@ -301,7 +301,9 @@ export function registerOyRoutes(app: App) { const friendIdRaw = c.req.query("friendId"); const direction = c.req.query("direction"); + const beforeRaw = c.req.query("before"); const friendId = friendIdRaw ? Number(friendIdRaw) : Number.NaN; + const before = beforeRaw ? Number(beforeRaw) : undefined; if (!Number.isFinite(friendId)) { return c.json({ error: "Missing friendId" }, 400); @@ -313,15 +315,19 @@ export function registerOyRoutes(app: App) { const fromId = direction === "inbound" ? friendId : user.id; const toId = direction === "inbound" ? user.id : friendId; + const params: (number | undefined)[] = [fromId, toId]; + const beforeClause = + before !== undefined ? `AND created_at <= $${params.push(before)}` : ""; const result = await c.get("db").query<{ payload: string | null; created_at: number; }>( `SELECT payload, created_at FROM oys WHERE from_user_id = $1 AND to_user_id = $2 AND type = 'lo' AND payload IS NOT NULL + ${beforeClause} ORDER BY created_at DESC LIMIT 50`, - [fromId, toId], + params, ); // Reverse so oldest is first (index 0), newest is last