diff --git a/MapView.js b/MapView.js
new file mode 100644
index 0000000..8a47118
--- /dev/null
+++ b/MapView.js
@@ -0,0 +1,837 @@
+/**
+ * MapView Component
+ *
+ * OpenLayers map with ol-ext LayerSwitcher for base map selection.
+ *
+ * Usage:
+ * import { MapView } from './components/MapView.js';
+ *
+ * const map = new MapView('map', {
+ * center: [-1.5, 7.5], // Ghana
+ * zoom: 7,
+ * basemap: 'osm'
+ * });
+ *
+ * map.onClick((lon, lat) => console.log('Clicked:', lon, lat));
+ * map.addMarker(lon, lat, { name: 'Point A' });
+ */
+
+import Map from 'ol/Map';
+import View from 'ol/View';
+import Overlay from 'ol/Overlay';
+import TileLayer from 'ol/layer/Tile';
+import LayerGroup from 'ol/layer/Group';
+import VectorLayer from 'ol/layer/Vector';
+import VectorSource from 'ol/source/Vector';
+import OSM from 'ol/source/OSM';
+import XYZ from 'ol/source/XYZ';
+import { fromLonLat, toLonLat } from 'ol/proj';
+import { Point } from 'ol/geom';
+import Feature from 'ol/Feature';
+import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
+
+// ol-ext LayerSwitcher
+import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
+
+// ol-ext GeolocationButton
+import GeolocationButton from 'ol-ext/control/GeolocationButton';
+
+// CSS imports
+import 'ol/ol.css';
+import 'ol-ext/dist/ol-ext.css';
+
+export class MapView {
+ constructor(targetId, options = {}) {
+ this.options = options;
+ this.markerSource = new VectorSource();
+ this.clickCallbacks = [];
+
+ // Category emoji mapping
+ this.categoryEmojis = {
+ 'water': '💧',
+ 'school': '🏫',
+ 'health': '🏥',
+ 'market': '🏪',
+ 'hotel': '🏨',
+ 'restaurant': '🍽️',
+ 'default': '📍',
+ 'other': '📌'
+ };
+
+ // Create emoji style helper
+ this.createEmojiStyle = (emoji, fontSize = 24) => {
+ return new Style({
+ text: new Text({
+ text: emoji,
+ font: `${fontSize}px sans-serif`,
+ textBaseline: 'bottom',
+ textAlign: 'center',
+ offsetY: -5,
+ }),
+ });
+ };
+
+ // Default marker style (pin emoji)
+ this.defaultStyle = this.createEmojiStyle('📍', 32);
+
+ // Selected marker style (larger)
+ this.selectedStyle = this.createEmojiStyle('📍', 42);
+
+ // Initialize category styles with emojis
+ this.categoryStyles = {};
+ for (const [category, emoji] of Object.entries(this.categoryEmojis)) {
+ this.categoryStyles[category] = this.createEmojiStyle(emoji, 32);
+ }
+
+ // Create base layers group
+ const baseLayers = this.createBaseLayers(options.basemap || 'osm');
+
+ // Markers layer
+ this.markersLayer = new VectorLayer({
+ title: 'Markers',
+ source: this.markerSource,
+ style: (feature) => this.getFeatureStyle(feature),
+ });
+
+ // Create map
+ this.map = new Map({
+ target: targetId,
+ layers: [
+ baseLayers,
+ this.markersLayer,
+ ],
+ view: new View({
+ center: fromLonLat(options.center || [0, 0]),
+ zoom: options.zoom || 2,
+ minZoom: options.minZoom || 2,
+ maxZoom: options.maxZoom || 19,
+ }),
+ });
+
+ // Add LayerSwitcher control
+ const layerSwitcher = new LayerSwitcher({
+ collapsed: true,
+ mouseover: true,
+ extent: false,
+ trash: false,
+ oninfo: null,
+ });
+ this.map.addControl(layerSwitcher);
+
+ // Add GeolocationButton control
+ const geolocationButton = new GeolocationButton({
+ title: 'My Location',
+ delay: 3000, // Auto-center duration
+ zoom: 16, // Zoom level when centering on location
+ });
+ this.map.addControl(geolocationButton);
+
+ // Store reference for external access
+ this.geolocationButton = geolocationButton;
+
+ // Track selected feature
+ this.selectedFeature = null;
+
+ // Create popup overlay for hover
+ this.createPopup();
+
+ // Create Add Location popup form
+ this.createAddLocationPopup();
+ }
+
+ /**
+ * Create the popup overlay element and add to map
+ */
+ createPopup() {
+ // Create popup container element
+ this.popupElement = document.createElement('div');
+ this.popupElement.className = 'map-popup';
+ this.popupElement.style.cssText = `
+ position: absolute;
+ background: white;
+ border-radius: 8px;
+ padding: 10px 14px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 13px;
+ min-width: 150px;
+ max-width: 280px;
+ pointer-events: none;
+ z-index: 1000;
+ border: 1px solid #e0e0e0;
+ `;
+
+ // Create the overlay
+ this.popup = new Overlay({
+ element: this.popupElement,
+ positioning: 'bottom-center',
+ offset: [0, -15],
+ stopEvent: false,
+ });
+
+ this.map.addOverlay(this.popup);
+
+ // Set up hover handler
+ this.setupHoverPopup();
+ }
+
+ /**
+ * Set up the hover popup behavior
+ */
+ setupHoverPopup() {
+ let currentFeature = null;
+
+ this.map.on('pointermove', (evt) => {
+ if (evt.dragging) {
+ this.hidePopup();
+ return;
+ }
+
+ const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
+
+ if (feature && feature !== currentFeature) {
+ currentFeature = feature;
+ this.showPopup(feature, evt.coordinate);
+ } else if (!feature && currentFeature) {
+ currentFeature = null;
+ this.hidePopup();
+ }
+
+ // Update cursor
+ this.map.getTargetElement().style.cursor = feature ? 'pointer' : '';
+ });
+
+ // Hide popup when mouse leaves the map
+ this.map.getTargetElement().addEventListener('mouseleave', () => {
+ this.hidePopup();
+ currentFeature = null;
+ });
+ }
+
+ /**
+ * Show popup with feature attributes
+ */
+ showPopup(feature, coordinate) {
+ const name = feature.get('name') || 'Unnamed';
+ const category = feature.get('category') || 'default';
+ const description = feature.get('description');
+ const lon = feature.get('lon');
+ const lat = feature.get('lat');
+ const emoji = this.categoryEmojis[category] || '📍';
+
+ // Build popup content
+ let html = `
+
+ ${emoji} ${this.escapeHtml(name)}
+
+ `;
+
+ // Category badge
+ const categoryColors = {
+ 'water': '#3b82f6',
+ 'school': '#f59e0b',
+ 'health': '#ef4444',
+ 'market': '#8b5cf6',
+ 'hotel': '#8b5cf6',
+ 'restaurant': '#8b5cf6',
+ 'default': '#2d5016',
+ 'other': '#6b7280'
+ };
+ const catColor = categoryColors[category] || '#6b7280';
+ html += `
+
+ ${category}
+
+ `;
+
+ // Description if available
+ if (description) {
+ html += `
+
+ ${this.escapeHtml(description)}
+
+ `;
+ }
+
+ // Coordinates
+ if (lon !== undefined && lat !== undefined) {
+ html += `
+
+ ${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
+
+ `;
+ }
+
+ this.popupElement.innerHTML = html;
+ this.popup.setPosition(coordinate);
+ }
+
+ /**
+ * Hide the popup
+ */
+ hidePopup() {
+ this.popup.setPosition(undefined);
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ */
+ escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Create the Add Location popup form overlay
+ */
+ createAddLocationPopup() {
+ // Create popup container element
+ this.addLocationPopupElement = document.createElement('div');
+ this.addLocationPopupElement.className = 'map-add-location-popup';
+ this.addLocationPopupElement.innerHTML = `
+
+
+ `;
+
+ // Create the overlay
+ this.addLocationPopup = new Overlay({
+ element: this.addLocationPopupElement,
+ positioning: 'bottom-center',
+ offset: [0, -10],
+ stopEvent: true, // Prevent click from propagating
+ autoPan: true,
+ autoPanAnimation: {
+ duration: 250,
+ },
+ });
+
+ this.map.addOverlay(this.addLocationPopup);
+
+ // Store clicked coordinates
+ this.addLocationCoords = null;
+
+ // Set up close button handler
+ const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
+ closeBtn.addEventListener('click', () => {
+ this.hideAddLocationPopup();
+ });
+
+ // Store form submit callbacks
+ this.addLocationCallbacks = [];
+ }
+
+ /**
+ * Show the Add Location popup at the specified coordinate
+ */
+ showAddLocationPopup(coordinate) {
+ const [lon, lat] = toLonLat(coordinate);
+ this.addLocationCoords = { lon, lat };
+
+ // Update coordinates display
+ const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
+ coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;
+
+ // Reset form
+ const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
+ form.reset();
+
+ // Position and show popup
+ this.addLocationPopup.setPosition(coordinate);
+ }
+
+ /**
+ * Hide the Add Location popup
+ */
+ hideAddLocationPopup() {
+ this.addLocationPopup.setPosition(undefined);
+ this.addLocationCoords = null;
+ }
+
+ /**
+ * Register a callback for when a location is submitted via the map popup
+ * Callback receives: { name, category, description, lon, lat }
+ */
+ onAddLocation(callback) {
+ this.addLocationCallbacks.push(callback);
+
+ // Set up form submit handler (only once)
+ if (this.addLocationCallbacks.length === 1) {
+ const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+
+ if (!this.addLocationCoords) return;
+
+ const formData = new FormData(form);
+ const data = {
+ name: formData.get('name'),
+ category: formData.get('category'),
+ description: formData.get('description'),
+ lon: this.addLocationCoords.lon,
+ lat: this.addLocationCoords.lat,
+ };
+
+ // Call all registered callbacks
+ this.addLocationCallbacks.forEach(cb => cb(data));
+
+ // Hide popup after submission
+ this.hideAddLocationPopup();
+ });
+ }
+ }
+
+ /**
+ * Create base layers group for LayerSwitcher
+ */
+ createBaseLayers(defaultBasemap) {
+
+
+ const topoLayer = new TileLayer({
+ title: 'Topographic',
+ type: 'base',
+ visible: defaultBasemap === 'topo',
+ source: new XYZ({
+ url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',
+ attributions: 'Map data: © OpenTopoMap',
+ maxZoom: 17,
+ crossOrigin: 'anonymous',
+ }),
+ });
+
+ const cartoLightLayer = new TileLayer({
+ title: 'Carto Light',
+ type: 'base',
+ visible: defaultBasemap === 'carto-light',
+ source: new XYZ({
+ url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
+ attributions: '© CARTO',
+ maxZoom: 19,
+ crossOrigin: 'anonymous',
+ }),
+ });
+
+ const cartoDarkLayer = new TileLayer({
+ title: 'Carto Dark',
+ type: 'base',
+ visible: defaultBasemap === 'carto-dark',
+ source: new XYZ({
+ url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
+ attributions: '© CARTO',
+ maxZoom: 19,
+ crossOrigin: 'anonymous',
+ }),
+ });
+ const osmCycleLayer = new TileLayer({
+ title: 'OSM Cycle map',
+ type: 'base',
+ visible: false, //defaultBasemap === 'osm',
+ source: new OSM({
+ "url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760"
+ }),
+ });
+
+ const satelliteLayer = new TileLayer({
+ title: 'Satellite',
+ type: 'base',
+ visible: defaultBasemap === 'satellite',
+ source: new XYZ({
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ attributions: 'Tiles © Esri',
+ maxZoom: 19,
+ crossOrigin: 'anonymous',
+ }),
+ });
+ const googleLayer = new TileLayer({
+ title: 'Google Sat',
+ type: 'base',
+ visible: defaultBasemap === 'googlesat',
+ source: new XYZ({
+// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
+ attributions: 'Tiles © Google',
+ maxZoom: 19,
+ crossOrigin: 'anonymous',
+ }),
+ });
+ const osmLayer = new TileLayer({
+ title: 'OpenStreetMap',
+ type: 'base',
+ visible: defaultBasemap === 'osm',
+ source: new OSM(),
+ });
+
+
+ // Return LayerGroup for LayerSwitcher
+ return new LayerGroup({
+ title: 'Base Maps',
+ layers: [
+ cartoLightLayer,
+ cartoDarkLayer,
+ satelliteLayer,
+ osmCycleLayer,
+ topoLayer,
+ googleLayer,
+ osmLayer,
+ ],
+ });
+ }
+
+ /**
+ * Get style for a feature (handles selection state)
+ */
+ getFeatureStyle(feature) {
+ const category = feature.get('category') || 'default';
+ const emoji = this.categoryEmojis[category] || '📍';
+
+ if (feature === this.selectedFeature) {
+ // Return selected style with the correct emoji and highlight
+ return [
+ // Background highlight circle
+ new Style({
+ image: new Circle({
+ radius: 22,
+ fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }),
+ stroke: new Stroke({ color: '#dc2626', width: 3 }),
+ }),
+ }),
+ // Emoji on top, larger
+ new Style({
+ text: new Text({
+ text: emoji,
+ font: '40px sans-serif',
+ textBaseline: 'bottom',
+ textAlign: 'center',
+ offsetY: -5,
+ }),
+ }),
+ ];
+ }
+
+ // Check for custom style
+ const customStyle = feature.get('style');
+ if (customStyle) {
+ return customStyle;
+ }
+
+ // Return category-based emoji style
+ if (this.categoryStyles[category]) {
+ return this.categoryStyles[category];
+ }
+
+ return this.defaultStyle;
+ }
+
+ /**
+ * Set category-based styles with emojis
+ * @param {Object} styles - Map of category to config { emoji, fontSize }
+ */
+ setCategoryStyles(styles) {
+ for (const [category, config] of Object.entries(styles)) {
+ // Update emoji mapping if provided
+ if (config.emoji) {
+ this.categoryEmojis[category] = config.emoji;
+ }
+
+ // Create/update style
+ const emoji = this.categoryEmojis[category] || '📍';
+ const fontSize = config.fontSize || 28;
+
+ this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize);
+ }
+
+ // Refresh markers
+ this.markerSource.changed();
+ }
+
+ /**
+ * Add a single marker
+ */
+ addMarker(lon, lat, properties = {}) {
+ console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties);
+
+ const feature = new Feature({
+ geometry: new Point(fromLonLat([lon, lat])),
+ ...properties,
+ });
+
+ // Store original coordinates for easy access
+ feature.set('lon', lon);
+ feature.set('lat', lat);
+
+ this.markerSource.addFeature(feature);
+ console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length);
+ return feature;
+ }
+
+ /**
+ * Add multiple markers from an array of location objects
+ */
+ addMarkers(locations) {
+ console.log('[MapView] Adding', locations.length, 'markers');
+
+ const features = locations.map((loc) => {
+ const feature = new Feature({
+ geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
+ id: loc.id,
+ name: loc.name,
+ description: loc.description,
+ category: loc.category,
+ lon: loc.longitude,
+ lat: loc.latitude,
+ });
+ return feature;
+ });
+
+ this.markerSource.addFeatures(features);
+ console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length);
+ return features;
+ }
+
+ /**
+ * Clear all markers
+ */
+ clearMarkers() {
+ this.markerSource.clear();
+ this.selectedFeature = null;
+ }
+
+ /**
+ * Remove a specific marker by feature or ID
+ */
+ removeMarker(featureOrId) {
+ if (typeof featureOrId === 'object') {
+ this.markerSource.removeFeature(featureOrId);
+ } else {
+ const feature = this.markerSource.getFeatures().find(
+ f => f.get('id') === featureOrId
+ );
+ if (feature) {
+ this.markerSource.removeFeature(feature);
+ }
+ }
+ }
+
+ /**
+ * Get all markers
+ */
+ getMarkers() {
+ return this.markerSource.getFeatures();
+ }
+
+ /**
+ * Find marker by ID
+ */
+ findMarker(id) {
+ return this.markerSource.getFeatures().find(f => f.get('id') === id);
+ }
+
+ /**
+ * Select a marker (highlights it)
+ */
+ selectMarker(featureOrId) {
+ if (typeof featureOrId === 'object') {
+ this.selectedFeature = featureOrId;
+ } else {
+ this.selectedFeature = this.findMarker(featureOrId);
+ }
+ this.markerSource.changed();
+ return this.selectedFeature;
+ }
+
+ /**
+ * Clear selection
+ */
+ clearSelection() {
+ this.selectedFeature = null;
+ this.markerSource.changed();
+ }
+
+ /**
+ * Zoom to a specific location
+ */
+ zoomTo(lon, lat, zoom = 15) {
+ this.map.getView().animate({
+ center: fromLonLat([lon, lat]),
+ zoom: zoom,
+ duration: 500,
+ });
+ }
+
+ /**
+ * Fit view to show all markers
+ */
+ fitToMarkers(padding = 50) {
+ const extent = this.markerSource.getExtent();
+ if (extent && extent[0] !== Infinity) {
+ this.map.getView().fit(extent, {
+ padding: [padding, padding, padding, padding],
+ duration: 500,
+ maxZoom: 16,
+ });
+ }
+ }
+
+ /**
+ * Get current map center in lon/lat
+ */
+ getCenter() {
+ const center = this.map.getView().getCenter();
+ return toLonLat(center);
+ }
+
+ /**
+ * Get current zoom level
+ */
+ getZoom() {
+ return this.map.getView().getZoom();
+ }
+
+ /**
+ * Set map center
+ */
+ setCenter(lon, lat) {
+ this.map.getView().setCenter(fromLonLat([lon, lat]));
+ }
+
+ /**
+ * Set zoom level
+ */
+ setZoom(zoom) {
+ this.map.getView().setZoom(zoom);
+ }
+
+ /**
+ * Register click callback
+ * Callback receives (lon, lat, feature, event)
+ */
+ onClick(callback) {
+ this.clickCallbacks.push(callback);
+
+ // Set up click handler if this is the first callback
+ if (this.clickCallbacks.length === 1) {
+ this.map.on('click', (evt) => {
+ const [lon, lat] = toLonLat(evt.coordinate);
+
+ // Check if clicked on a feature
+ let clickedFeature = null;
+ this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
+ clickedFeature = feature;
+ return true; // Stop at first feature
+ });
+
+ // Call all registered callbacks
+ for (const cb of this.clickCallbacks) {
+ cb(lon, lat, clickedFeature, evt);
+ }
+ });
+ }
+
+ // Return unsubscribe function
+ return () => {
+ const index = this.clickCallbacks.indexOf(callback);
+ if (index > -1) {
+ this.clickCallbacks.splice(index, 1);
+ }
+ };
+ }
+
+ /**
+ * Register pointer move callback (for hover effects)
+ */
+ onPointerMove(callback) {
+ this.map.on('pointermove', (evt) => {
+ if (evt.dragging) return;
+
+ const [lon, lat] = toLonLat(evt.coordinate);
+
+ let hoveredFeature = null;
+ this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
+ hoveredFeature = feature;
+ return true;
+ });
+
+ // Change cursor
+ this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';
+
+ callback(lon, lat, hoveredFeature, evt);
+ });
+ }
+
+ /**
+ * Enable cursor change on marker hover
+ * Note: This is now handled automatically by the popup system
+ */
+ enableHoverCursor() {
+ // Cursor changes are now handled by setupHoverPopup()
+ // This method is kept for backwards compatibility
+ }
+
+ /**
+ * Get the OpenLayers map instance for advanced usage
+ */
+ getMap() {
+ return this.map;
+ }
+
+ /**
+ * Get the marker source for advanced usage
+ */
+ getMarkerSource() {
+ return this.markerSource;
+ }
+
+ /**
+ * Get the markers layer for advanced usage
+ */
+ getMarkersLayer() {
+ return this.markersLayer;
+ }
+
+ /**
+ * Update map size (call after container resize)
+ */
+ updateSize() {
+ this.map.updateSize();
+ }
+}
+
+// Export OpenLayers utilities for convenience
+export { fromLonLat, toLonLat };
+
+export default MapView;
diff --git a/database.js b/database.js
new file mode 100644
index 0000000..0d1d49d
--- /dev/null
+++ b/database.js
@@ -0,0 +1,551 @@
+/**
+ * Database Module
+ *
+ * Uses SQLocal directly with BroadcastChannel for cross-tab coordination.
+ *
+ * Why this approach instead of SharedWorker?
+ * - SQLocal already uses its own internal worker for OPFS access
+ * - Wrapping it in another SharedWorker adds complexity and causes issues
+ * - BroadcastChannel provides simple cross-tab communication
+ * - Each tab has its own SQLocal instance but they share the same OPFS database file
+ *
+ * Usage:
+ * import { sql, dbReady, addLocation, getLocations } from './database.js';
+ *
+ * await dbReady;
+ * await addLocation('Point A', -1.5, 7.5);
+ * const locations = await getLocations();
+ */
+
+import { SQLocal } from 'sqlocal';
+
+// Database configuration
+const DATABASE_PATH = 'lupmis.sqlite3';
+const BROADCAST_CHANNEL = 'lupmis-db-sync';
+
+// Create SQLocal instance
+const db = new SQLocal(DATABASE_PATH);
+
+// Get the sql tagged template function
+const { sql } = db;
+
+console.log('[Database] SQLocal instance created for:', DATABASE_PATH);
+
+// Export sql for direct queries
+export { sql };
+
+// Create broadcast channel for cross-tab coordination
+const channel = new BroadcastChannel(BROADCAST_CHANNEL);
+
+// Track if database is ready
+let isReady = false;
+let readyResolve;
+let readyReject;
+
+export const dbReady = new Promise((resolve, reject) => {
+ readyResolve = resolve;
+ readyReject = reject;
+});
+
+// Database change listeners
+const changeListeners = new Set();
+
+/**
+ * Subscribe to database changes (from any tab)
+ * @param {Function} listener - Called with { table, action, id }
+ * @returns {Function} Unsubscribe function
+ */
+export function onDatabaseChange(listener) {
+ changeListeners.add(listener);
+ return () => changeListeners.delete(listener);
+}
+
+// Handle messages from other tabs
+channel.onmessage = (event) => {
+ const { type, payload } = event.data;
+ if (type === 'DB_CHANGE') {
+ // Notify local listeners about changes from other tabs
+ for (const listener of changeListeners) {
+ try {
+ listener(payload);
+ } catch (e) {
+ console.error('[Database] Change listener error:', e);
+ }
+ }
+ }
+};
+
+/**
+ * Broadcast a database change to other tabs
+ */
+function broadcastChange(table, action, id = null) {
+ channel.postMessage({
+ type: 'DB_CHANGE',
+ payload: { table, action, id, timestamp: Date.now() }
+ });
+
+ // Also notify local listeners
+ for (const listener of changeListeners) {
+ try {
+ listener({ table, action, id, timestamp: Date.now(), local: true });
+ } catch (e) {
+ console.error('[Database] Change listener error:', e);
+ }
+ }
+}
+
+// ============================================================================
+// Database Initialization
+// ============================================================================
+
+/**
+ * Initialize the database schema
+ */
+export async function initSchema() {
+ try {
+ console.log('[Database] Initializing schema...');
+
+ // Test connection
+ const testResult = await sql`SELECT sqlite_version() as version`;
+ console.log('[Database] SQLite version:', testResult[0]?.version);
+
+ // Create locations table
+ console.log('[Database] Creating locations table...');
+ await sql`
+ CREATE TABLE IF NOT EXISTS locations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ longitude REAL NOT NULL,
+ latitude REAL NOT NULL,
+ description TEXT,
+ category TEXT DEFAULT 'default',
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ synced INTEGER DEFAULT 0
+ )
+ `;
+
+ // Verify table exists
+ const tablesAfterLocations = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
+ console.log('[Database] Locations table exists:', tablesAfterLocations.length > 0);
+
+ // Create sync_log table
+ console.log('[Database] Creating sync_log table...');
+ await sql`
+ CREATE TABLE IF NOT EXISTS sync_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ table_name TEXT NOT NULL,
+ record_id INTEGER NOT NULL,
+ action TEXT NOT NULL,
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
+ synced INTEGER DEFAULT 0
+ )
+ `;
+
+ // Create indexes
+ await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
+ await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
+
+ // Final verification
+ const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
+ console.log('[Database] All tables:', allTables.map(t => t.name));
+
+ isReady = true;
+ readyResolve(true);
+ console.log('[Database] ✓ Schema initialized');
+
+ } catch (error) {
+ console.error('[Database] ✗ Schema init failed:', error);
+ readyReject(error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// Location Operations
+// ============================================================================
+
+/**
+ * Add a new location
+ */
+export async function addLocation(name, longitude, latitude, options = {}) {
+ const { description = null, category = 'default' } = options;
+
+ console.log('[Database] Adding location:', name, longitude, latitude, category);
+
+ try {
+ // Check table exists first
+ const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
+ console.log('[Database] Table check before insert:', tableCheck);
+
+ if (tableCheck.length === 0) {
+ console.error('[Database] ✗ locations table does not exist!');
+ throw new Error('locations table does not exist');
+ }
+
+ // Insert - using explicit values
+ console.log('[Database] Executing INSERT...');
+ await sql`
+ INSERT INTO locations (name, longitude, latitude, description, category)
+ VALUES (${name}, ${longitude}, ${latitude}, ${description}, ${category})
+ `;
+ console.log('[Database] INSERT completed');
+
+ // Get the ID
+ const idResult = await sql`SELECT last_insert_rowid() as id`;
+ const newId = idResult[0]?.id;
+ console.log('[Database] New ID:', newId);
+
+ // Verify it was actually inserted
+ const verifyResult = await sql`SELECT * FROM locations WHERE id = ${newId}`;
+ console.log('[Database] Verify insert:', verifyResult);
+
+ if (verifyResult.length === 0) {
+ console.error('[Database] ✗ Insert verification failed - row not found!');
+ throw new Error('Insert verification failed');
+ }
+
+ // Log for sync
+ await sql`
+ INSERT INTO sync_log (table_name, record_id, action)
+ VALUES ('locations', ${newId}, 'INSERT')
+ `;
+
+ // Broadcast to other tabs
+ broadcastChange('locations', 'INSERT', newId);
+
+ console.log('[Database] ✓ Location added:', newId);
+ return { id: newId };
+
+ } catch (error) {
+ console.error('[Database] ✗ Failed to add location:', error);
+ throw error;
+}
+}
+
+/**
+ * Get all locations
+ */
+export async function getLocations(options = {}) {
+ const { category = null, limit = 1000 } = options;
+
+ try {
+ // First check if table exists
+ const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
+ console.log('[Database] getLocations - table exists:', tableCheck.length > 0);
+
+ if (tableCheck.length === 0) {
+ console.warn('[Database] locations table does not exist yet');
+ return [];
+ }
+
+ let results;
+ if (category) {
+ results = await sql`
+ SELECT * FROM locations
+ WHERE category = ${category}
+ ORDER BY created_at DESC
+ LIMIT ${limit}
+ `;
+ } else {
+ results = await sql`
+ SELECT * FROM locations
+ ORDER BY created_at DESC
+ LIMIT ${limit}
+ `;
+}
+
+ console.log('[Database] getLocations returned', results.length, 'rows');
+ return results;
+
+ } catch (error) {
+ console.error('[Database] getLocations error:', error);
+ return [];
+ }
+}
+
+export async function getLocation(id) {
+ try {
+ const results = await sql`SELECT * FROM locations WHERE id = ${id}`;
+ return results[0] || null;
+ } catch (error) {
+ console.error('[Database] getLocation error:', error);
+ return null;
+}
+}
+
+/**
+ * Update a location
+ */
+export async function updateLocation(id, updates) {
+ const { name, longitude, latitude, description, category } = updates;
+
+ try {
+ const location = await getLocation(id);
+ if (!location) {
+ throw new Error(`Location ${id} not found`);
+ }
+
+ await sql`
+ UPDATE locations
+ SET
+ name = ${name ?? location.name},
+ longitude = ${longitude ?? location.longitude},
+ latitude = ${latitude ?? location.latitude},
+ description = ${description ?? location.description},
+ category = ${category ?? location.category},
+ updated_at = CURRENT_TIMESTAMP,
+ synced = 0
+ WHERE id = ${id}
+ `;
+
+ // Log for sync
+ await sql`
+ INSERT INTO sync_log (table_name, record_id, action)
+ VALUES ('locations', ${id}, 'UPDATE')
+ `;
+
+ // Broadcast to other tabs
+ broadcastChange('locations', 'UPDATE', id);
+ console.log('[Database] ✓ Location updated:', id);
+
+ } catch (error) {
+ console.error('[Database] ✗ updateLocation error:', error);
+ throw error;
+}
+}
+
+/**
+ * Delete a location
+ */
+export async function deleteLocation(id) {
+ try {
+ await sql`
+ INSERT INTO sync_log (table_name, record_id, action)
+ VALUES ('locations', ${id}, 'DELETE')
+ `;
+
+ await sql`DELETE FROM locations WHERE id = ${id}`;
+
+ // Broadcast to other tabs
+ broadcastChange('locations', 'DELETE', id);
+ console.log('[Database] ✓ Location deleted:', id);
+
+ } catch (error) {
+ console.error('[Database] ✗ deleteLocation error:', error);
+ throw error;
+}
+}
+
+/**
+ * Get location count
+ */
+export async function getLocationCount() {
+ try {
+ const result = await sql`SELECT COUNT(*) as count FROM locations`;
+ return result[0]?.count ?? 0;
+ } catch (error) {
+ console.error('[Database] getLocationCount error:', error);
+ return 0;
+}
+}
+
+// ============================================================================
+// Sync Operations
+// ============================================================================
+
+/**
+ * Get unsynced changes
+ */
+export async function getUnsyncedChanges() {
+ return sql`SELECT * FROM sync_log WHERE synced = 0 ORDER BY timestamp ASC`;
+}
+
+/**
+ * Mark changes as synced
+ */
+export async function markSynced(syncLogIds) {
+ if (!syncLogIds.length) return;
+ for (const id of syncLogIds) {
+ await sql`UPDATE sync_log SET synced = 1 WHERE id = ${id}`;
+}
+}
+
+/**
+ * Get locations that need syncing
+ */
+export async function getUnsyncedLocations() {
+ return sql`SELECT * FROM locations WHERE synced = 0`;
+}
+
+/**
+ * Mark locations as synced
+ */
+export async function markLocationsSynced(ids) {
+ if (!ids.length) return;
+ for (const id of ids) {
+ await sql`UPDATE locations SET synced = 1 WHERE id = ${id}`;
+}
+}
+
+// ============================================================================
+// Export / Import
+// ============================================================================
+
+/**
+ * Export database for backup
+ */
+export async function exportDatabase() {
+ return db.getDatabaseFile();
+}
+
+/**
+ * Import database from backup
+ */
+export async function importDatabase(data) {
+ await db.overwriteDatabaseFile(data);
+ broadcastChange('*', 'IMPORT', null);
+}
+
+/**
+ * Download database as file
+ */
+export async function downloadDatabase(filename = 'lupmis-backup.sqlite3') {
+ const data = await exportDatabase();
+ const blob = new Blob([data], { type: 'application/x-sqlite3' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ URL.revokeObjectURL(url);
+}
+
+// Export to GeoJSON
+export async function exportToGeoJSON() {
+ const locations = await getLocations();
+
+ return {
+ type: 'FeatureCollection',
+ features: locations.map((loc) => ({
+ type: 'Feature',
+ properties: {
+ id: loc.id,
+ name: loc.name,
+ category: loc.category,
+ notes: loc.notes,
+ created_at: loc.created_at,
+ },
+ geometry: {
+ type: 'Point',
+ coordinates: [loc.lon, loc.lat],
+ },
+ })),
+ };
+}
+
+// ============================================================================
+// Utility & Debug
+// ============================================================================
+
+/**
+ * Get database status
+ */
+export async function getDatabaseStatus() {
+ try {
+ const tables = await sql`
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
+ ORDER BY name
+ `;
+
+ const locationCount = await getLocationCount();
+
+ return {
+ ready: isReady,
+ databasePath: DATABASE_PATH,
+ tables: tables.map(t => t.name),
+ locationCount
+ };
+ } catch (error) {
+ return {
+ ready: false,
+ error: error.message
+ };
+}
+}
+
+// Debug function - call from console to test
+export async function testDatabase() {
+ console.log('=== DATABASE TEST ===');
+
+ try {
+ // 1. Check connection
+ const version = await sql`SELECT sqlite_version() as v`;
+ console.log('1. SQLite version:', version[0].v);
+
+ // 2. Check tables
+ const tables = await sql`SELECT name FROM sqlite_master WHERE type='table'`;
+ console.log('2. Tables:', tables.map(t => t.name));
+
+ // 3. Try to insert a test row
+ console.log('3. Inserting test row...');
+ await sql`INSERT INTO locations (name, longitude, latitude, category) VALUES ('TEST', -1.0, 7.0, 'test')`;
+
+ // 4. Read it back
+ const rows = await sql`SELECT * FROM locations WHERE name = 'TEST'`;
+ console.log('4. Test row:', rows);
+
+ // 5. Count all rows
+ const count = await sql`SELECT COUNT(*) as c FROM locations`;
+ console.log('5. Total rows:', count[0].c);
+
+ // 6. Delete test row
+ await sql`DELETE FROM locations WHERE name = 'TEST'`;
+ console.log('6. Test row deleted');
+
+ console.log('=== TEST PASSED ===');
+ return true;
+ } catch (error) {
+ console.error('=== TEST FAILED ===', error);
+ return false;
+}
+}
+
+// Expose to window for debugging
+if (typeof window !== 'undefined') {
+ window.testDatabase = testDatabase;
+ window.dbStatus = getDatabaseStatus;
+}
+
+export async function closeDatabase() {
+ channel.close();
+ if (db.destroy) {
+ await db.destroy();
+}
+}
+
+export default {
+ sql,
+ dbReady,
+ initSchema,
+ addLocation,
+ getLocations,
+ getLocation,
+ updateLocation,
+ deleteLocation,
+ getLocationCount,
+ getUnsyncedChanges,
+ getUnsyncedLocations,
+ markSynced,
+ markLocationsSynced,
+ exportDatabase,
+ importDatabase,
+ downloadDatabase,
+ getDatabaseStatus,
+ testDatabase,
+ onDatabaseChange,
+ closeDatabase
+};
diff --git a/pwa.js b/pwa.js
new file mode 100644
index 0000000..2cd206f
--- /dev/null
+++ b/pwa.js
@@ -0,0 +1,317 @@
+/**
+ * PWA Module
+ *
+ * Handles Progressive Web App functionality:
+ * - Service Worker registration
+ * - Install prompt handling
+ * - Offline detection
+ * - Update notifications
+ *
+ * Note: The Service Worker (sw.js) handles caching.
+ * The SharedWorker (shared-db-worker.js) handles database.
+ * They are separate workers with different purposes.
+ */
+
+// ============================================================================
+// Service Worker Registration
+// ============================================================================
+
+let swRegistration = null;
+
+export async function registerServiceWorker() {
+ if (!('serviceWorker' in navigator)) {
+ console.warn('[PWA] Service Workers not supported');
+ return null;
+ }
+
+ try {
+ swRegistration = await navigator.serviceWorker.register('/sw.js', {
+ scope: '/'
+ });
+
+ console.log('[PWA] Service Worker registered:', swRegistration.scope);
+
+ // Handle updates
+ swRegistration.addEventListener('updatefound', () => {
+ const newWorker = swRegistration.installing;
+
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ // New version available
+ console.log('[PWA] New version available');
+ showUpdateNotification();
+ }
+ });
+ });
+
+ return swRegistration;
+
+ } catch (error) {
+ console.error('[PWA] Service Worker registration failed:', error);
+ return null;
+ }
+}
+
+// ============================================================================
+// Install Prompt
+// ============================================================================
+
+let deferredPrompt = null;
+let installButton = null;
+
+/**
+ * Initialize install prompt handling
+ * @param {string|HTMLElement} buttonSelector - Button element or selector
+ */
+export function initInstallPrompt(buttonSelector = '#install-btn') {
+ installButton = typeof buttonSelector === 'string'
+ ? document.querySelector(buttonSelector)
+ : buttonSelector;
+
+ if (!installButton) {
+ console.warn('[PWA] Install button not found:', buttonSelector);
+ return;
+ }
+
+ // Initially hide the button
+ installButton.style.display = 'none';
+
+ // Listen for the beforeinstallprompt event
+ window.addEventListener('beforeinstallprompt', (e) => {
+ e.preventDefault();
+ deferredPrompt = e;
+
+ // Show the install button
+ installButton.style.display = 'block';
+ console.log('[PWA] Install prompt ready');
+ });
+
+ // Handle install button click
+ installButton.addEventListener('click', async () => {
+ if (!deferredPrompt) {
+ // Show manual instructions for Safari
+ showManualInstallInstructions();
+ return;
+ }
+
+ deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+
+ console.log('[PWA] Install prompt outcome:', outcome);
+
+ deferredPrompt = null;
+ installButton.style.display = 'none';
+ });
+
+ // Hide button if app is already installed
+ window.addEventListener('appinstalled', () => {
+ console.log('[PWA] App installed');
+ deferredPrompt = null;
+ installButton.style.display = 'none';
+ });
+
+ // Check if running as installed PWA
+ if (window.matchMedia('(display-mode: standalone)').matches) {
+ installButton.style.display = 'none';
+ }
+}
+
+function showManualInstallInstructions() {
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+
+ let message = 'To install this app:\n\n';
+
+ if (isIOS) {
+ message += '1. Tap the Share button (square with arrow)\n';
+ message += '2. Scroll down and tap "Add to Home Screen"';
+ } else if (isSafari) {
+ message += '1. Click File menu\n';
+ message += '2. Click "Add to Dock"';
+ } else {
+ message += '1. Click the menu button (three dots)\n';
+ message += '2. Click "Install" or "Add to Home Screen"';
+ }
+
+ alert(message);
+}
+
+// ============================================================================
+// Offline Detection
+// ============================================================================
+
+let offlineIndicator = null;
+const offlineListeners = new Set();
+
+/**
+ * Initialize offline detection
+ * @param {string|HTMLElement} indicatorSelector - Element to show when offline
+ */
+export function initOfflineDetection(indicatorSelector = '#offline-indicator') {
+ offlineIndicator = typeof indicatorSelector === 'string'
+ ? document.querySelector(indicatorSelector)
+ : indicatorSelector;
+
+ // Set initial state
+ updateOfflineUI(!navigator.onLine);
+
+ // Listen for online/offline events
+ window.addEventListener('online', () => {
+ console.log('[PWA] Back online');
+ updateOfflineUI(false);
+ notifyOfflineListeners(false);
+ });
+
+ window.addEventListener('offline', () => {
+ console.log('[PWA] Gone offline');
+ updateOfflineUI(true);
+ notifyOfflineListeners(true);
+ });
+}
+
+function updateOfflineUI(isOffline) {
+ if (offlineIndicator) {
+ offlineIndicator.style.display = isOffline ? 'block' : 'none';
+ }
+
+ // Also toggle a class on body for CSS styling
+ document.body.classList.toggle('is-offline', isOffline);
+}
+
+/**
+ * Subscribe to offline state changes
+ * @param {Function} listener - Callback(isOffline: boolean)
+ * @returns {Function} Unsubscribe function
+ */
+export function onOfflineChange(listener) {
+ offlineListeners.add(listener);
+ // Immediately call with current state
+ listener(!navigator.onLine);
+ return () => offlineListeners.delete(listener);
+}
+
+function notifyOfflineListeners(isOffline) {
+ for (const listener of offlineListeners) {
+ try {
+ listener(isOffline);
+ } catch (e) {
+ console.error('[PWA] Offline listener error:', e);
+ }
+ }
+}
+
+/**
+ * Check if currently online
+ */
+export function isOnline() {
+ return navigator.onLine;
+}
+
+// ============================================================================
+// Update Handling
+// ============================================================================
+
+let updateCallback = null;
+
+/**
+ * Set callback for when updates are available
+ * @param {Function} callback - Called when new version is ready
+ */
+export function onUpdateAvailable(callback) {
+ updateCallback = callback;
+}
+
+function showUpdateNotification() {
+ if (updateCallback) {
+ updateCallback();
+ return;
+ }
+
+ // Default behavior
+ if (confirm('A new version is available. Reload now?')) {
+ applyUpdate();
+ }
+}
+
+/**
+ * Apply pending update (reload with new version)
+ */
+export function applyUpdate() {
+ if (swRegistration?.waiting) {
+ swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
+ }
+ window.location.reload();
+}
+
+// ============================================================================
+// Communication with Service Worker
+// ============================================================================
+
+/**
+ * Send a message to the service worker
+ * @param {Object} message - Message to send
+ */
+export function postToServiceWorker(message) {
+ navigator.serviceWorker.controller?.postMessage(message);
+}
+
+/**
+ * Request the service worker to cache specific modules
+ * @param {string[]} moduleNames - Array of module names to cache
+ */
+export function cacheModules(moduleNames) {
+ postToServiceWorker({
+ type: 'CACHE_MODULES',
+ payload: { modules: moduleNames }
+ });
+}
+
+/**
+ * Request the service worker to clear user-specific caches
+ * (Call this on logout)
+ */
+export function clearUserCaches() {
+ postToServiceWorker({
+ type: 'CLEAR_USER_CACHE'
+ });
+}
+
+// ============================================================================
+// Auto-initialization
+// ============================================================================
+
+/**
+ * Initialize all PWA features
+ * @param {Object} options
+ */
+export async function initPWA(options = {}) {
+ const {
+ installButton = '#install-btn',
+ offlineIndicator = '#offline-indicator',
+ autoRegisterSW = true
+ } = options;
+
+ if (autoRegisterSW) {
+ await registerServiceWorker();
+ }
+
+ initInstallPrompt(installButton);
+ initOfflineDetection(offlineIndicator);
+
+ console.log('[PWA] Initialized');
+}
+
+// Export for direct use
+export default {
+ registerServiceWorker,
+ initInstallPrompt,
+ initOfflineDetection,
+ initPWA,
+ isOnline,
+ onOfflineChange,
+ onUpdateAvailable,
+ applyUpdate,
+ postToServiceWorker,
+ cacheModules,
+ clearUserCaches
+};