Files
crowd-funder-from-jason/src/views/SearchAreaView.vue
Matthew Raymer 9067bec54a fix: Convert searchBoxes arrays to JSON strings in $saveSettings and $updateSettings
- Add _convertSettingsForStorage helper method to handle Settings → SettingsWithJsonStrings conversion
- Fix $saveSettings and $saveUserSettings to properly convert searchBoxes arrays to JSON strings before database storage
- Update SearchAreaView.vue to use array format instead of manual JSON.stringify conversion
- Add comprehensive test UI in PlatformServiceMixinTest.vue with visual feedback and clear demonstration of conversion process
- Document migration strategy for consolidating $updateSettings into $saveSettings to reduce code duplication
- Add deprecation notices to $updateSettings method with clear migration guidance

The fix ensures that searchBoxes arrays are properly converted to JSON strings before database storage, preventing data corruption and maintaining consistency with the SettingsWithJsonStrings type definition. The enhanced test interface provides clear visualization of the conversion process and database storage format.

Migration Strategy:
- $saveSettings:  KEEP (will be primary method after consolidation)
- $updateSettings: ⚠️ DEPRECATED (will be removed in favor of $saveSettings)
- Future: Consolidate to single $saveSettings(changes, did?) method

Files changed:
- src/utils/PlatformServiceMixin.ts: Add conversion helper, fix save methods, add deprecation notices
- src/views/SearchAreaView.vue: Remove manual JSON conversion
- src/test/PlatformServiceMixinTest.vue: Add comprehensive test UI with highlighting
- docs/migration-templates/updateSettings-consolidation-plan.md: Document future consolidation strategy
2025-07-30 09:48:52 +00:00

385 lines
12 KiB
Vue

<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="goBack"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Area for Nearby Search
</h1>
</div>
<div class="px-2 py-4">
This location is only stored on your device. It is sometimes sent from
your device to run searches but it is not stored on our servers.
</div>
<div class="text-center">
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
Click to Choose a Location for Nearby Search
</button>
<button
v-if="isNewMarkerSet"
:class="actionButtonClass"
@click="storeSearchBox"
>
<font-awesome icon="save" class="fa-fw" />
Store This Location for Nearby Search
</button>
<button
v-if="searchBox"
:class="actionButtonClass"
@click="forgetSearchBox"
>
<font-awesome icon="trash-can" class="fa-fw" />
Delete Stored Location
</button>
<button v-if="searchBox" :class="actionButtonClass" @click="resetLatLong">
<font-awesome icon="rotate" class="fa-fw" />
Reset To Original
</button>
<button
v-if="isNewMarkerSet"
:class="actionButtonClass"
@click="isNewMarkerSet = false"
>
<font-awesome icon="eraser" class="fa-fw" />
Erase Marker
</button>
<div v-if="isNewMarkerSet">
Click on the pin to erase it. Click anywhere else to set a different
different corner.
</div>
</div>
<div class="aspect-video">
<l-map
ref="map"
v-model:zoom="localZoom"
:center="[localCenterLat, localCenterLong]"
class="!z-40 rounded-md"
@click="setMapPoint"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="isNewMarkerSet"
:lat-lng="[localCenterLat, localCenterLong]"
@click="isNewMarkerSet = false"
/>
<l-rectangle
v-if="isNewMarkerSet"
:bounds="[
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
]"
:weight="1"
/>
</l-map>
</div>
</section>
</template>
<script lang="ts">
/**
* @fileoverview SearchAreaView - Geographic Search Area Management Component
*
* This component provides an interactive map interface for users to set and manage
* geographic search areas for location-based content filtering. It uses Leaflet maps
* to allow users to select, store, and delete geographic bounding boxes that define
* their preferred search areas.
*
* Key Features:
* - Interactive Leaflet map with marker and rectangle overlays
* - Geographic bounding box calculation and storage
* - Privacy-preserving local storage (no server transmission)
* - Real-time map interaction with visual feedback
* - Automatic bounding box size estimation based on map zoom
*
* Enhanced Triple Migration Pattern Status:
* ✅ Phase 1: Database Migration - PlatformServiceMixin integration
* ⏳ Phase 2: SQL Abstraction - No raw SQL queries to migrate
* ⏳ Phase 3: Notification Migration - Helper system integration
* ⏳ Phase 4: Template Streamlining - Method extraction and styling
*
* Security: All location data is stored locally on the user's device and is never
* transmitted to external servers except for explicit search operations.
*
* @component SearchAreaView
* @requires PlatformServiceMixin - Database operations
* @requires Leaflet - Map rendering and interaction
* @requires Vue-Leaflet - Vue.js Leaflet integration
* @author TimeSafari Development Team
* @since 2024-01-01
* @version 1.0.0
* @migrated 2025-07-09 (Enhanced Triple Migration Pattern)
*/
import { LeafletMouseEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { BoundingBox } from "../db/tables/settings";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import {
NOTIFY_SEARCH_AREA_SAVED,
NOTIFY_SEARCH_AREA_ERROR,
NOTIFY_SEARCH_AREA_NO_LOCATION,
NOTIFY_SEARCH_AREA_DELETED,
} from "../constants/notifications";
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
// Geographic constants for map rendering and bounding box calculations
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
@Component({
components: {
QuickNav,
LRectangle,
LMap,
LMarker,
LTileLayer,
},
mixins: [PlatformServiceMixin],
})
export default class SearchAreaView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
// Notification helper system - will be initialized in mounted()
private notify: ReturnType<typeof createNotifyHelpers> | null = null;
// User interface state management
isChoosingSearchBox = false;
isNewMarkerSet = false;
// Local geographic coordinates for current map selection
// These represent the currently selected area before storage
localCenterLat = 0;
localCenterLong = 0;
localLatDiff = DEFAULT_LAT_LONG_DIFF;
localLongDiff = DEFAULT_LAT_LONG_DIFF;
localZoom = DEFAULT_ZOOM;
// Stored search box configuration loaded from database
// This represents the persisted geographic search area
searchBox: { name: string; bbox: BoundingBox } | null = null;
/**
* Component lifecycle hook - Initialize geographic search area
* Loads existing search box configuration from local database
* and initializes map display with stored or default coordinates
*/
async mounted() {
try {
// Initialize notification helper system
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
this.searchBox = settings.searchBoxes?.[0] || null;
this.resetLatLong();
logger.info("[SearchAreaView] Component mounted", {
hasStoredSearchBox: !!this.searchBox,
searchBoxName: this.searchBox?.name,
coordinates: this.searchBox?.bbox,
});
} catch (error) {
logger.error(
"[SearchAreaView] Failed to load search area settings",
error,
);
// Continue with default behavior if settings load fails
this.resetLatLong();
}
}
/**
* Handle map click events for geographic area selection
* Supports two modes: initial marker placement and bounding box sizing
*
* @param event - Leaflet mouse event containing geographic coordinates
*/
setMapPoint(event: LeafletMouseEvent) {
if (this.isNewMarkerSet) {
// Existing marker - calculate bounding box size based on click distance
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
} else {
// New marker - set center point and estimate initial bounding box size
this.localCenterLat = event.latlng.lat;
this.localCenterLong = event.latlng.lng;
let latDiff = DEFAULT_LAT_LONG_DIFF;
let longDiff = DEFAULT_LAT_LONG_DIFF;
// Estimate reasonable bounding box size based on current map bounds
const bounds = event.target.boxZoom?._map?.getBounds();
if (bounds) {
latDiff =
Math.abs(bounds.getNorthEast().lat - bounds.getSouthWest().lat) / 8;
longDiff =
Math.abs(bounds.getNorthEast().lng - bounds.getSouthWest().lng) / 8;
}
this.localLatDiff = latDiff;
this.localLongDiff = longDiff;
this.isNewMarkerSet = true;
}
}
/**
* Reset map coordinates to stored search box or default values
* Used when component loads or when user wants to restore original area
*/
public resetLatLong() {
if (this.searchBox?.bbox) {
// Restore coordinates from stored search box
const bbox = this.searchBox.bbox;
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
this.localZoom = WORLD_ZOOM;
this.isNewMarkerSet = true;
} else {
// No stored search box - reset to default state
this.isNewMarkerSet = false;
}
}
/**
* Computed property for consistent action button styling
* Provides standardized classes for all geographic action buttons
*/
get actionButtonClass() {
return "m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500";
}
/**
* Navigate back to previous page
* Extracted from template for better maintainability
*/
goBack() {
this.$router.back();
}
/**
* Store the currently selected geographic area as search box preference
* Validates coordinates and persists bounding box to local database
* Provides user feedback and navigates back on success
*/
public async storeSearchBox() {
if (this.localCenterLong || this.localCenterLat) {
try {
// Create search box configuration with calculated bounding box
const newSearchBox = {
name: "Local",
bbox: {
eastLong: this.localCenterLong + this.localLongDiff,
maxLat: this.localCenterLat + this.localLatDiff,
minLat: this.localCenterLat - this.localLatDiff,
westLong: this.localCenterLong - this.localLongDiff,
},
};
// Store search box configuration using platform service
// searchBoxes will be automatically converted to JSON string by $updateSettings
await this.$updateSettings({ searchBoxes: [newSearchBox] });
this.searchBox = newSearchBox;
this.isChoosingSearchBox = false;
logger.info("[SearchAreaView] Search box stored successfully", {
searchBox: newSearchBox,
coordinates: newSearchBox.bbox,
});
// Enhanced notification system with proper timeout
this.notify?.success(NOTIFY_SEARCH_AREA_SAVED.text, TIMEOUTS.VERY_LONG);
this.$router.back();
} catch (err) {
logger.error("[SearchAreaView] Failed to store search box", err);
// Enhanced notification system with proper timeout
this.notify?.error(NOTIFY_SEARCH_AREA_ERROR.text, TIMEOUTS.LONG);
}
} else {
// Invalid coordinates - show validation warning
this.notify?.warning(NOTIFY_SEARCH_AREA_NO_LOCATION.text, TIMEOUTS.LONG);
}
}
/**
* Delete stored search box preference and reset component state
* Removes geographic search area from database and UI
* Provides success feedback on completion
*/
public async forgetSearchBox() {
try {
// Clear search box settings and disable nearby filtering
await this.$updateSettings({
searchBoxes: [],
filterFeedByNearby: false,
});
// Reset component state to default values
this.searchBox = null;
this.localCenterLat = 0;
this.localCenterLong = 0;
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
this.localZoom = DEFAULT_ZOOM;
this.isChoosingSearchBox = false;
this.isNewMarkerSet = false;
logger.info("[SearchAreaView] Search box deleted successfully");
// Enhanced notification system with proper timeout
this.notify?.success(NOTIFY_SEARCH_AREA_DELETED.text, TIMEOUTS.STANDARD);
} catch (err) {
logger.error("[SearchAreaView] Failed to delete search box", err);
// Enhanced notification system with proper timeout
this.notify?.error(NOTIFY_SEARCH_AREA_ERROR.text, TIMEOUTS.LONG);
}
}
/**
* Cancel search box selection and reset zoom level
* Used when user wants to abort the selection process
*/
public cancelSearchBoxSelect() {
this.isChoosingSearchBox = false;
this.localZoom = WORLD_ZOOM;
}
}
</script>