diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs
index 9b625c1..f7f0ef3 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
@@ -50,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);
@@ -76,8 +100,29 @@ 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) => this.formatSavedPlace(p));
+ }
+
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 +201,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..fb24b5e 100644
--- a/tests/integration/components/search-box-test.gjs
+++ b/tests/integration/components/search-box-test.gjs
@@ -230,4 +230,279 @@ 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 {
+ lists = [{ id: 'favs', title: 'Favorites' }];
+ savedPlaces = [
+ {
+ title: 'Awesome Coffee',
+ lat: 52.5,
+ lon: 13.4,
+ osmId: '999',
+ osmType: 'node',
+ _listIds: ['favs'],
+ },
+ ];
+ }
+ 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 {
+ lists = [{ id: 'favs', title: 'Favorites' }];
+ savedPlaces = [
+ {
+ title: 'Awesome Coffee',
+ lat: 52.5,
+ lon: 13.4,
+ osmId: '999',
+ osmType: 'node',
+ _listIds: ['favs'],
+ },
+ ];
+ }
+ 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'
+ );
+ 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) {
+ // 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"]']);
+ });
});