# LUPMIS PWA with Offline SQLite and Maps

A Progressive Web App with:
- **OpenLayers** map with **ol-ext LayerSwitcher** for base map selection
- **SQLocal** for SQLite database in the browser (via OPFS)
- **BroadcastChannel** for cross-tab synchronization
- **Service Worker** for asset caching and offline support
- **Vite** for development and building

## Features

- 🗺️ Interactive map with 5 base layers (OSM, Satellite, Topo, Carto Light/Dark)
- 📍 Click map to set coordinates, markers colored by category
- 💾 Offline SQLite database (data persists in browser)
- 🔄 Cross-tab sync via BroadcastChannel
- 📴 Works offline (cached assets + up to 500 map tiles)
- 📱 Installable as PWA on mobile and desktop

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                         Browser                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐                  │
│  │  Tab 1   │    │  Tab 2   │    │  Tab 3   │                  │
│  │ main.js  │    │ main.js  │    │ main.js  │                  │
│  │ SQLocal  │    │ SQLocal  │    │ SQLocal  │                  │
│  └────┬─────┘    └────┬─────┘    └────┬─────┘                  │
│       │               │               │                         │
│       └───────────────┼───────────────┘                         │
│                       │                                         │
│              BroadcastChannel                                   │
│         (notifies other tabs of changes)                        │
│                       │                                         │
│                       ▼                                         │
│              ┌────────────────┐                                 │
│              │     OPFS       │  ← Single database file         │
│              │ (lupmis.db)    │    shared by all tabs           │
│              └────────────────┘                                 │
│                                                                  │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│                                                                  │
│              ┌────────────────┐                                 │
│              │ Service Worker │  ← Caches assets for offline    │
│              │    (sw.js)     │                                 │
│              └────────────────┘                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
```

## Why This Architecture?

Initially we considered using a **SharedWorker** to manage a single database connection. However:

1. **SQLocal already uses its own internal worker** - It handles OPFS access internally
2. **OPFS handles file coordination** - Multiple SQLocal instances can access the same database file
3. **Simpler is better** - BroadcastChannel provides easy cross-tab notification without the complexity of SharedWorker bundling issues in Vite

The result is simpler code that works reliably with Vite's build system.

## File Structure

```
project/
├── index.html              # Entry HTML with map container
├── main.js                 # App entry point
├── vite.config.js          # Vite configuration
├── package.json
│
├── src/
│   ├── components/
│   │   └── MapView.js      # OpenLayers map with ol-ext LayerSwitcher
│   ├── database.js         # SQLocal + BroadcastChannel
│   └── pwa.js              # PWA utilities (install, offline)
│
└── public/
    ├── sw.js               # Service Worker (caching)
    ├── manifest.json       # PWA manifest
    ├── offline.html        # Offline fallback page
    └── icons/              # PWA icons
```

## Setup

```bash
# Install dependencies
npm install

# Start development server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview
```

## Usage

### Basic Database Operations

```javascript
import { sql, dbReady, addLocation, getLocations } from './src/database.js';

// Wait for database to be ready
await dbReady;

// Add a location
await addLocation('Water Point', -1.5234, 7.4567, {
  description: 'Main village well',
  category: 'water'
});

// Get all locations
const locations = await getLocations();

// Direct SQL queries using tagged templates
const results = await sql`SELECT * FROM locations WHERE category = ${'water'}`;
```

### MapView Component

```javascript
import { MapView } from './src/components/MapView.js';

// Create map centered on Ghana
const map = new MapView('map-container', {
  center: [-1.5, 7.5],    // [longitude, latitude]
  zoom: 7,
  basemap: 'osm'          // 'osm' | 'satellite' | 'topo' | 'carto-light' | 'carto-dark'
});

// Set category-based marker colors
map.setCategoryStyles({
  'water': { color: '#3b82f6' },
  'school': { color: '#f59e0b' },
  'health': { color: '#ef4444' },
});

// Add markers from database
const locations = await getLocations();
map.addMarkers(locations);

// Handle map clicks
map.onClick((lon, lat, feature) => {
  if (feature) {
    // Clicked on existing marker
    console.log('Selected:', feature.get('name'));
  } else {
    // Clicked on empty space - use coordinates
    document.getElementById('longitude').value = lon.toFixed(6);
    document.getElementById('latitude').value = lat.toFixed(6);
  }
});

// Zoom to a location
map.zoomTo(-1.5, 7.5, 14);

// Fit view to show all markers
map.fitToMarkers();

// Select a marker by ID
map.selectMarker(locationId);
```

### Available Base Maps

| Name | Key | Source |
|------|-----|--------|
| OpenStreetMap | `osm` | OpenStreetMap |
| Satellite | `satellite` | Esri World Imagery |
| Topographic | `topo` | OpenTopoMap |
| Carto Light | `carto-light` | CARTO |
| Carto Dark | `carto-dark` | CARTO |

### Cross-Tab Synchronization

```javascript
import { onDatabaseChange } from './src/database.js';

// Listen for changes from other tabs
onDatabaseChange((change) => {
  console.log('Database changed:', change);
  // { table: 'locations', action: 'INSERT', id: 5, timestamp: 1234567890 }
  
  if (change.table === 'locations') {
    refreshLocationsList();
  }
});
```

### PWA Features

```javascript
import { initPWA, isOnline, onOfflineChange } from './src/pwa.js';

// Initialize PWA
await initPWA();

// Check online status
if (isOnline()) {
  syncWithServer();
}

// React to offline/online changes
onOfflineChange((offline) => {
  if (offline) {
    showOfflineBanner();
  } else {
    hideOfflineBanner();
    syncWithServer();
  }
});
```

## Deployment

### Required Headers

Your web server must send these headers for OPFS to work:

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

### Nginx Configuration

```nginx
server {
    listen 443 ssl http2;
    server_name your-domain.com;
    
    root /var/www/dist;
    index index.html;
    
    # Required for OPFS/SQLite
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    
    # Cache static assets
    location ~* \.(js|css|wasm|png|jpg|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
    }
    
    # SPA fallback
    location / {
        try_files $uri $uri/ /index.html;
    }
}
```

### Apache (.htaccess)

```apache
<IfModule mod_headers.c>
    Header always set Cross-Origin-Opener-Policy "same-origin"
    Header always set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>
```

### OpenResty (Docker)

```nginx
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
```

## Browser Support

- **Chrome/Edge 89+** - Full support
- **Firefox 111+** - Full support  
- **Safari 15.2+** - OPFS supported
- **Mobile** - Chrome Android, Safari iOS 15.2+

## Troubleshooting

### "SecurityError" or "NotAllowedError"

The COOP/COEP headers are missing. Check your server configuration.

### Database not persisting

1. Check that you're using HTTPS (or localhost)
2. Verify COOP/COEP headers are present (DevTools → Network → check response headers)
3. Check browser DevTools → Application → Storage → OPFS

### Changes not syncing between tabs

The BroadcastChannel should handle this automatically. Check the browser console for any errors.

### Vite HMR WebSocket errors

The cross-origin isolation can break Vite's hot reload. Options:
1. Use `vite-plugin-cross-origin-isolation` (add to vite.config.js)
2. Or manually refresh the browser after changes

## License

MIT
