From f9cb22ee0e1c0309d62986d6fadf997c89a878e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 6 Jun 2026 11:36:36 +0400 Subject: [PATCH 1/2] Include saved places in search results --- app/components/search-box.gjs | 42 ++- app/controllers/search.js | 17 +- .../components/search-box-test.gjs | 265 ++++++++++++++++++ 3 files changed, 314 insertions(+), 10 deletions(-) diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs index 9b625c1..88310b8 100644 --- a/app/components/search-box.gjs +++ b/app/components/search-box.gjs @@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers'; export default class SearchBoxComponent extends Component { @service photon; @service osm; + @service storage; @service router; @service mapUi; @service map; // Assuming we might need map context, but mostly we use router @@ -76,8 +77,39 @@ export default class SearchBoxComponent extends Component { icon: 'search', })); + // Filter saved places (minimum 3 characters) + let savedMatches = []; + if (q.length >= 3) { + savedMatches = this.storage.savedPlaces + .filter((p) => p.title && p.title.toLowerCase().includes(q)) + .map((p) => ({ + source: 'saved', + id: p.id, + title: p.title, + icon: 'bookmark', + description: 'Saved place', + osmId: p.osmId, + osmType: p.osmType, + lat: p.lat, + lon: p.lon, + })); + } + const results = await this.photon.search(query, lat, lon); - this.results = [...categoryMatches, ...results]; + + // Deduplicate Photon results that are already in saved matches + const savedOsmIds = new Set( + savedMatches.map((s) => s.osmId).filter(Boolean) + ); + const filteredPhotonResults = results.filter( + (r) => !savedOsmIds.has(r.osmId) + ); + + this.results = [ + ...categoryMatches, + ...savedMatches, + ...filteredPhotonResults, + ]; } catch (e) { console.error('Search failed', e); this.results = []; @@ -156,8 +188,12 @@ export default class SearchBoxComponent extends Component { } this.results = []; // Hide popover - // If it has an OSM ID, go to place details - if (place.osmId) { + // If it's a custom saved place without an OSM ID, go to place details via internal ID + if (place.source === 'saved' && place.id && !place.osmId) { + this.router.transitionTo('place', place.id); + } + // If it has an OSM ID, go to place details via OSM ID + else if (place.osmId) { // Format: osm:node:123 // place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService const id = `osm:${place.osmType}:${place.osmId}`; diff --git a/app/controllers/search.js b/app/controllers/search.js index f96a803..3cd7305 100644 --- a/app/controllers/search.js +++ b/app/controllers/search.js @@ -78,14 +78,17 @@ export default class SearchController extends Controller { // Search with Photon (using lat/lon for bias if available) pois = await this.photon.search(params.q, lat, lon); - // Search local bookmarks by name + // Search local bookmarks by name (minimum 3 characters) const queryLower = params.q.toLowerCase(); - const localMatches = this.storage.savedPlaces.filter((p) => { - return ( - p.title?.toLowerCase().includes(queryLower) || - p.description?.toLowerCase().includes(queryLower) - ); - }); + let localMatches = []; + if (queryLower.length >= 3) { + localMatches = this.storage.savedPlaces.filter((p) => { + return ( + p.title?.toLowerCase().includes(queryLower) || + p.description?.toLowerCase().includes(queryLower) + ); + }); + } // Merge local matches localMatches.forEach((local) => { diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index 0a9e4c3..58857e2 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -230,4 +230,269 @@ module('Integration | Component | search-box', function (hooks) { 'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}', ]); }); + + test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) { + // Mock MapUi Service + class MockMapUiService extends Service { + currentCenter = { lat: 52.52, lon: 13.405 }; + setSearchBoxFocus() {} + } + this.owner.register('service:map-ui', MockMapUiService); + + // Mock Router Service + class MockRouterService extends Service { + transitionTo(routeName, id) { + assert.step(`transitionTo: ${routeName} ["${id}"]`); + } + } + this.owner.register('service:router', MockRouterService); + + // Mock Storage Service + class MockStorageService extends Service { + savedPlaces = [ + { + title: 'Awesome Coffee', + lat: 52.5, + lon: 13.4, + osmId: '999', + osmType: 'node', + }, + ]; + } + this.owner.register('service:storage', MockStorageService); + + // Mock Photon Service + class MockPhotonService extends Service { + async search(query) { + if (query === 'coffee') { + return [ + { + title: 'Awesome Coffee', + osmId: '999', + osmType: 'node', + description: 'Duplicate to be removed', + }, + { + title: 'Other Coffee', + osmId: '888', + osmType: 'node', + description: 'A different coffee shop', + }, + ]; + } + return []; + } + } + this.owner.register('service:photon', MockPhotonService); + + this.noop = () => {}; + await render( + + ); + + // Type "coffee" to trigger matches in Category, Saved, and Photon + await fillIn('.search-input', 'coffee'); + await waitFor('.search-results-popover', { timeout: 2000 }); + + const resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + + // Should be exactly 3 items: + // 1. Category (Coffee) + // 2. Saved (Awesome Coffee) + // 3. Photon (Other Coffee) + // (The Photon duplicate of "Awesome Coffee" is removed) + assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items'); + + // 1. Category + assert.ok( + resultItems[0].textContent.includes('Coffee'), + 'First item is the category match' + ); + assert + .dom(resultItems[0].querySelector('.result-icon svg')) + .hasClass('feather-search', 'Category uses search icon'); + + // 2. Saved Place + assert.ok( + resultItems[1].textContent.includes('Awesome Coffee'), + 'Second item is the saved place match' + ); + assert.ok( + resultItems[1].textContent.includes('Saved place'), + 'Saved place has correct description text' + ); + assert + .dom(resultItems[1].querySelector('.result-icon svg')) + .hasClass('feather-bookmark', 'Saved place uses bookmark icon'); + + // 3. Photon Match + assert.ok( + resultItems[2].textContent.includes('Other Coffee'), + 'Third item is the unique photon result' + ); + + // Click the Saved Place + await click(resultItems[1]); + + assert.verifySteps(['transitionTo: place ["osm:node:999"]']); + }); + + test('it requires 3 or more characters to match saved places', async function (assert) { + // Mock MapUi Service + class MockMapUiService extends Service { + currentCenter = { lat: 52.52, lon: 13.405 }; + setSearchBoxFocus() {} + } + this.owner.register('service:map-ui', MockMapUiService); + + // Mock Router Service + class MockRouterService extends Service { + transitionTo() {} + } + this.owner.register('service:router', MockRouterService); + + // Mock Storage Service + class MockStorageService extends Service { + savedPlaces = [ + { + title: 'Awesome Coffee', + lat: 52.5, + lon: 13.4, + osmId: '999', + osmType: 'node', + }, + ]; + } + this.owner.register('service:storage', MockStorageService); + + // Mock Photon Service + class MockPhotonService extends Service { + async search(query) { + if (query === 'aw' || query === 'awe') { + return [ + { + title: 'Aww Some Place', + osmId: '111', + osmType: 'node', + }, + ]; + } + return []; + } + } + this.owner.register('service:photon', MockPhotonService); + + this.noop = () => {}; + await render( + + ); + + // Type "aw" (2 characters) + await fillIn('.search-input', 'aw'); + await waitFor('.search-results-popover', { timeout: 2000 }); + + let resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + + // Should only show Photon match since 'aw' is < 3 characters + assert.strictEqual( + resultItems.length, + 1, + 'Renders exactly 1 item for 2 chars' + ); + assert.ok( + resultItems[0].textContent.includes('Aww Some Place'), + 'Shows photon match' + ); + assert.notOk( + resultItems.some((item) => item.textContent.includes('Awesome Coffee')), + 'Saved place is NOT shown for 2 char query' + ); + + // Type "awe" (3 characters) + await fillIn('.search-input', 'awe'); + await waitFor('.search-results-popover', { timeout: 2000 }); + + resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + + // Should now show Saved Place and Photon match + assert.strictEqual( + resultItems.length, + 2, + 'Renders exactly 2 items for 3 chars' + ); + assert.ok( + resultItems.some((item) => item.textContent.includes('Awesome Coffee')), + 'Saved place is now shown' + ); + }); + + test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) { + // Mock MapUi Service + class MockMapUiService extends Service { + currentCenter = { lat: 52.52, lon: 13.405 }; + setSearchBoxFocus() {} + } + this.owner.register('service:map-ui', MockMapUiService); + + // Mock Router Service + class MockRouterService extends Service { + transitionTo(routeName, id) { + assert.step(`transitionTo: ${routeName} ["${id}"]`); + } + } + this.owner.register('service:router', MockRouterService); + + // Mock Storage Service (Custom Place) + class MockStorageService extends Service { + savedPlaces = [ + { + id: 'custom-1234', + title: 'My Custom Home', + lat: 52.5, + lon: 13.4, + // Notice NO osmId or osmType + }, + ]; + } + this.owner.register('service:storage', MockStorageService); + + // Mock Photon Service + class MockPhotonService extends Service { + async search() { + return []; + } + } + this.owner.register('service:photon', MockPhotonService); + + this.noop = () => {}; + await render( + + ); + + // Type 3 chars to trigger saved place match + await fillIn('.search-input', 'cus'); + await waitFor('.search-results-popover', { timeout: 2000 }); + + const resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + + // Ensure our custom place is rendered + const customResult = resultItems.find((item) => + item.textContent.includes('My Custom Home') + ); + assert.ok(customResult, 'Custom place is rendered'); + + // Click it + await click(customResult); + + // Verify it navigated using the internal ID, NOT a search query + assert.verifySteps(['transitionTo: place ["custom-1234"]']); + }); }); From f82a79772046af596d1910cc24f1d713321ea594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 6 Jun 2026 12:00:48 +0400 Subject: [PATCH 2/2] Include list names in search results for saved places --- app/components/search-box.gjs | 35 +++++++++++++------ .../components/search-box-test.gjs | 10 ++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs index 88310b8..f7f0ef3 100644 --- a/app/components/search-box.gjs +++ b/app/components/search-box.gjs @@ -51,6 +51,29 @@ export default class SearchBoxComponent extends Component { this.searchTask.perform(value); } + formatSavedPlace(place) { + const listNames = (place._listIds || []) + .map((id) => this.storage.lists?.find((l) => l.id === id)?.title) + .filter(Boolean) + .join(', '); + + const description = listNames + ? `Saved place (${listNames})` + : 'Saved place'; + + return { + source: 'saved', + id: place.id, + title: place.title, + icon: 'bookmark', + description, + osmId: place.osmId, + osmType: place.osmType, + lat: place.lat, + lon: place.lon, + }; + } + searchTask = task({ restartable: true }, async (term) => { await timeout(300); @@ -82,17 +105,7 @@ export default class SearchBoxComponent extends Component { if (q.length >= 3) { savedMatches = this.storage.savedPlaces .filter((p) => p.title && p.title.toLowerCase().includes(q)) - .map((p) => ({ - source: 'saved', - id: p.id, - title: p.title, - icon: 'bookmark', - description: 'Saved place', - osmId: p.osmId, - osmType: p.osmType, - lat: p.lat, - lon: p.lon, - })); + .map((p) => this.formatSavedPlace(p)); } const results = await this.photon.search(query, lat, lon); diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index 58857e2..fb24b5e 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -249,6 +249,7 @@ module('Integration | Component | search-box', function (hooks) { // Mock Storage Service class MockStorageService extends Service { + lists = [{ id: 'favs', title: 'Favorites' }]; savedPlaces = [ { title: 'Awesome Coffee', @@ -256,6 +257,7 @@ module('Integration | Component | search-box', function (hooks) { lon: 13.4, osmId: '999', osmType: 'node', + _listIds: ['favs'], }, ]; } @@ -355,6 +357,7 @@ module('Integration | Component | search-box', function (hooks) { // Mock Storage Service class MockStorageService extends Service { + lists = [{ id: 'favs', title: 'Favorites' }]; savedPlaces = [ { title: 'Awesome Coffee', @@ -362,6 +365,7 @@ module('Integration | Component | search-box', function (hooks) { lon: 13.4, osmId: '999', osmType: 'node', + _listIds: ['favs'], }, ]; } @@ -430,6 +434,12 @@ module('Integration | Component | search-box', function (hooks) { resultItems.some((item) => item.textContent.includes('Awesome Coffee')), 'Saved place is now shown' ); + assert.ok( + resultItems.some((item) => + item.textContent.includes('Saved place (Favorites)') + ), + 'List names are appended to the description' + ); }); test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {