# Phase 1: MVP - Basic Map with Points **Timeline**: Week 1 **Goal**: Deploy a minimal viable map showing location points **Status**: Ready for implementation ## 🎯 Phase Objectives Create a **working, deployable map application** with: - βœ… MapLibre GL JS map rendering - βœ… Points layer with clustering - βœ… Basic point popups - βœ… Simple date range selector - βœ… Loading states - βœ… API integration for points - βœ… E2E tests **Deploy Decision**: Users can view their location history on a map. --- ## πŸ“‹ Features Checklist - [ ] MapLibre map initialization - [ ] Points layer with automatic clustering - [ ] Click point to see popup with details - [ ] Month selector (simple dropdown) - [ ] Loading indicator while fetching data - [ ] API client for `/api/v1/points` endpoint - [ ] Basic error handling - [ ] E2E tests passing --- ## πŸ—οΈ Files to Create ``` app/javascript/maps_v2/ β”œβ”€β”€ controllers/ β”‚ └── map_controller.js # Main Stimulus controller β”œβ”€β”€ services/ β”‚ └── api_client.js # API wrapper β”œβ”€β”€ layers/ β”‚ β”œβ”€β”€ base_layer.js # Base class for layers β”‚ └── points_layer.js # Points with clustering β”œβ”€β”€ utils/ β”‚ └── geojson_transformers.js # API β†’ GeoJSON └── components/ └── popup_factory.js # Point popups app/views/maps_v2/ └── index.html.erb # Main view e2e/v2/ β”œβ”€β”€ phase-1-mvp.spec.ts # E2E tests └── helpers/ └── setup.ts # Test setup ``` --- ## 1.1 Base Layer Class All layers extend this base class. **File**: `app/javascript/maps_v2/layers/base_layer.js` ```javascript /** * Base class for all map layers * Provides common functionality for layer management */ export class BaseLayer { constructor(map, options = {}) { this.map = map this.id = options.id || this.constructor.name.toLowerCase() this.sourceId = `${this.id}-source` this.visible = options.visible !== false this.data = null } /** * Add layer to map with data * @param {Object} data - GeoJSON or layer-specific data */ add(data) { this.data = data // Add source if (!this.map.getSource(this.sourceId)) { this.map.addSource(this.sourceId, this.getSourceConfig()) } // Add layers const layers = this.getLayerConfigs() layers.forEach(layerConfig => { if (!this.map.getLayer(layerConfig.id)) { this.map.addLayer(layerConfig) } }) this.setVisibility(this.visible) } /** * Update layer data * @param {Object} data - New data */ update(data) { this.data = data const source = this.map.getSource(this.sourceId) if (source && source.setData) { source.setData(data) } } /** * Remove layer from map */ remove() { this.getLayerIds().forEach(layerId => { if (this.map.getLayer(layerId)) { this.map.removeLayer(layerId) } }) if (this.map.getSource(this.sourceId)) { this.map.removeSource(this.sourceId) } this.data = null } /** * Toggle layer visibility * @param {boolean} visible - Show/hide layer */ toggle(visible = !this.visible) { this.visible = visible this.setVisibility(visible) } /** * Set visibility for all layer IDs * @param {boolean} visible */ setVisibility(visible) { const visibility = visible ? 'visible' : 'none' this.getLayerIds().forEach(layerId => { if (this.map.getLayer(layerId)) { this.map.setLayoutProperty(layerId, 'visibility', visibility) } }) } /** * Get source configuration (override in subclass) * @returns {Object} MapLibre source config */ getSourceConfig() { throw new Error('Must implement getSourceConfig()') } /** * Get layer configurations (override in subclass) * @returns {Array} Array of MapLibre layer configs */ getLayerConfigs() { throw new Error('Must implement getLayerConfigs()') } /** * Get all layer IDs for this layer * @returns {Array} */ getLayerIds() { return this.getLayerConfigs().map(config => config.id) } } ``` --- ## 1.2 Points Layer Points with clustering support. **File**: `app/javascript/maps_v2/layers/points_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Points layer with automatic clustering */ export class PointsLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'points', ...options }) this.clusterRadius = options.clusterRadius || 50 this.clusterMaxZoom = options.clusterMaxZoom || 14 } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] }, cluster: true, clusterMaxZoom: this.clusterMaxZoom, clusterRadius: this.clusterRadius } } getLayerConfigs() { return [ // Cluster circles { id: `${this.id}-clusters`, type: 'circle', source: this.sourceId, filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 50, '#f28cb1', 100, '#ff6b6b' ], 'circle-radius': [ 'step', ['get', 'point_count'], 20, 10, 30, 50, 40, 100, 50 ] } }, // Cluster count labels { id: `${this.id}-count`, type: 'symbol', source: this.sourceId, filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 12 }, paint: { 'text-color': '#ffffff' } }, // Individual points { id: this.id, type: 'circle', source: this.sourceId, filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#3b82f6', 'circle-radius': 6, 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } } ] } } ``` --- ## 1.3 GeoJSON Transformers Convert API responses to GeoJSON. **File**: `app/javascript/maps_v2/utils/geojson_transformers.js` ```javascript /** * Transform points array to GeoJSON FeatureCollection * @param {Array} points - Array of point objects from API * @returns {Object} GeoJSON FeatureCollection */ export function pointsToGeoJSON(points) { return { type: 'FeatureCollection', features: points.map(point => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [point.longitude, point.latitude] }, properties: { id: point.id, timestamp: point.timestamp, altitude: point.altitude, battery: point.battery, accuracy: point.accuracy, velocity: point.velocity } })) } } /** * Format timestamp for display * @param {number} timestamp - Unix timestamp * @returns {string} Formatted date/time */ export function formatTimestamp(timestamp) { const date = new Date(timestamp * 1000) return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } ``` --- ## 1.4 API Client Wrapper for API endpoints. **File**: `app/javascript/maps_v2/services/api_client.js` ```javascript /** * API client for Maps V2 * Wraps all API endpoints with consistent error handling */ export class ApiClient { constructor(apiKey) { this.apiKey = apiKey this.baseURL = '/api/v1' } /** * Fetch points for date range (paginated) * @param {Object} options - { start_at, end_at, page, per_page } * @returns {Promise} { points, currentPage, totalPages } */ async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) { const params = new URLSearchParams({ start_at, end_at, page: page.toString(), per_page: per_page.toString() }) const response = await fetch(`${this.baseURL}/points?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch points: ${response.statusText}`) } const points = await response.json() return { points, currentPage: parseInt(response.headers.get('X-Current-Page') || '1'), totalPages: parseInt(response.headers.get('X-Total-Pages') || '1') } } /** * Fetch all points for date range (handles pagination) * @param {Object} options - { start_at, end_at, onProgress } * @returns {Promise} All points */ async fetchAllPoints({ start_at, end_at, onProgress = null }) { const allPoints = [] let page = 1 let totalPages = 1 do { const { points, currentPage, totalPages: total } = await this.fetchPoints({ start_at, end_at, page, per_page: 1000 }) allPoints.push(...points) totalPages = total page++ if (onProgress) { onProgress({ loaded: allPoints.length, currentPage, totalPages, progress: currentPage / totalPages }) } } while (page <= totalPages) return allPoints } getHeaders() { return { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } } } ``` --- ## 1.5 Popup Factory Create popups for points. **File**: `app/javascript/maps_v2/components/popup_factory.js` ```javascript import { formatTimestamp } from '../utils/geojson_transformers' /** * Factory for creating map popups */ export class PopupFactory { /** * Create popup for a point * @param {Object} properties - Point properties * @returns {string} HTML for popup */ static createPointPopup(properties) { const { id, timestamp, altitude, battery, accuracy, velocity } = properties return `
` } } ``` --- ## 1.6 Main Map Controller Stimulus controller orchestrating everything. **File**: `app/javascript/maps_v2/controllers/map_controller.js` ```javascript import { Controller } from '@hotwired/stimulus' import maplibregl from 'maplibre-gl' import { ApiClient } from '../services/api_client' import { PointsLayer } from '../layers/points_layer' import { pointsToGeoJSON } from '../utils/geojson_transformers' import { PopupFactory } from '../components/popup_factory' /** * Main map controller for Maps V2 * Phase 1: MVP with points layer */ export default class extends Controller { static values = { apiKey: String, startDate: String, endDate: String } static targets = ['container', 'loading', 'monthSelect'] connect() { this.initializeMap() this.initializeAPI() this.loadMapData() } disconnect() { this.map?.remove() } /** * Initialize MapLibre map */ initializeMap() { this.map = new maplibregl.Map({ container: this.containerTarget, style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', center: [0, 0], zoom: 2 }) // Add navigation controls this.map.addControl(new maplibregl.NavigationControl(), 'top-right') // Setup click handler for points this.map.on('click', 'points', this.handlePointClick.bind(this)) // Change cursor on hover this.map.on('mouseenter', 'points', () => { this.map.getCanvas().style.cursor = 'pointer' }) this.map.on('mouseleave', 'points', () => { this.map.getCanvas().style.cursor = '' }) } /** * Initialize API client */ initializeAPI() { this.api = new ApiClient(this.apiKeyValue) } /** * Load points data from API */ async loadMapData() { this.showLoading() try { // Fetch all points for selected month const points = await this.api.fetchAllPoints({ start_at: this.startDateValue, end_at: this.endDateValue, onProgress: this.updateLoadingProgress.bind(this) }) console.log(`Loaded ${points.length} points`) // Transform to GeoJSON const geojson = pointsToGeoJSON(points) // Create/update points layer if (!this.pointsLayer) { this.pointsLayer = new PointsLayer(this.map) // Wait for map to load before adding layer if (this.map.loaded()) { this.pointsLayer.add(geojson) } else { this.map.on('load', () => { this.pointsLayer.add(geojson) }) } } else { this.pointsLayer.update(geojson) } // Fit map to data bounds if (points.length > 0) { this.fitMapToBounds(geojson) } } catch (error) { console.error('Failed to load map data:', error) alert('Failed to load location data. Please try again.') } finally { this.hideLoading() } } /** * Handle point click */ handlePointClick(e) { const feature = e.features[0] const coordinates = feature.geometry.coordinates.slice() const properties = feature.properties // Create popup new maplibregl.Popup() .setLngLat(coordinates) .setHTML(PopupFactory.createPointPopup(properties)) .addTo(this.map) } /** * Fit map to data bounds */ fitMapToBounds(geojson) { const coordinates = geojson.features.map(f => f.geometry.coordinates) const bounds = coordinates.reduce((bounds, coord) => { return bounds.extend(coord) }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) this.map.fitBounds(bounds, { padding: 50, maxZoom: 15 }) } /** * Month selector changed */ monthChanged(event) { const [year, month] = event.target.value.split('-') // Update date values this.startDateValue = `${year}-${month}-01T00:00:00Z` const lastDay = new Date(year, month, 0).getDate() this.endDateValue = `${year}-${month}-${lastDay}T23:59:59Z` // Reload data this.loadMapData() } /** * Show loading indicator */ showLoading() { this.loadingTarget.classList.remove('hidden') } /** * Hide loading indicator */ hideLoading() { this.loadingTarget.classList.add('hidden') } /** * Update loading progress */ updateLoadingProgress({ loaded, totalPages, progress }) { const percentage = Math.round(progress * 100) this.loadingTarget.textContent = `Loading... ${percentage}%` } } ``` --- ## 1.7 View Template **File**: `app/views/maps_v2/index.html.erb` ```erb
``` --- ## 1.8 Controller (Rails) **File**: `app/controllers/maps_v2_controller.rb` ```ruby class MapsV2Controller < ApplicationController before_action :authenticate_user! def index # Default to current month @start_date = Date.today.beginning_of_month @end_date = Date.today.end_of_month end end ``` --- ## 1.9 Routes **File**: `config/routes.rb` (add) ```ruby # Maps V2 get '/maps_v2', to: 'maps_v2#index', as: :maps_v2 ``` --- ## πŸ§ͺ E2E Tests **File**: `e2e/v2/phase-1-mvp.spec.ts` ```typescript import { test, expect } from '@playwright/test' test.describe('Phase 1: MVP - Basic Map with Points', () => { test.beforeEach(async ({ page }) => { // Login await page.goto('/users/sign_in') await page.fill('input[name="user[email]"]', 'demo@dawarich.app') await page.fill('input[name="user[password]"]', 'password') await page.click('button[type="submit"]') await page.waitForURL('/') // Navigate to Maps V2 await page.goto('/maps_v2') }) test('map container loads', async ({ page }) => { const mapContainer = page.locator('[data-map-target="container"]') await expect(mapContainer).toBeVisible() }) test('map initializes with MapLibre', async ({ page }) => { // Wait for map to load await page.waitForSelector('.maplibregl-canvas') const canvas = page.locator('.maplibregl-canvas') await expect(canvas).toBeVisible() }) test('month selector is present', async ({ page }) => { const monthSelect = page.locator('[data-map-target="monthSelect"]') await expect(monthSelect).toBeVisible() // Should have 12 options const options = await monthSelect.locator('option').count() expect(options).toBe(12) }) test('points load and render on map', async ({ page }) => { // Wait for loading to complete await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) // Check if points source exists const hasPoints = await page.evaluate(() => { const map = window.mapInstance || document.querySelector('[data-controller="map"]')?.map if (!map) return false const source = map.getSource('points-source') return source && source._data?.features?.length > 0 }) expect(hasPoints).toBe(true) }) test('clicking point shows popup', async ({ page }) => { // Wait for map to load await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) // Click on map center (likely to have a point) const mapContainer = page.locator('[data-map-target="container"]') await mapContainer.click({ position: { x: 400, y: 300 } }) // Wait for popup (may not always appear if no point clicked) try { await page.waitForSelector('.maplibregl-popup', { timeout: 2000 }) const popup = page.locator('.maplibregl-popup') await expect(popup).toBeVisible() } catch (e) { console.log('No point clicked, trying again...') await mapContainer.click({ position: { x: 500, y: 300 } }) await page.waitForSelector('.maplibregl-popup', { timeout: 2000 }) } }) test('changing month selector reloads data', async ({ page }) => { // Wait for initial load await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) // Get initial month const initialMonth = await page.locator('[data-map-target="monthSelect"]').inputValue() // Change month await page.selectOption('[data-map-target="monthSelect"]', { index: 1 }) // Loading should appear await expect(page.locator('[data-map-target="loading"]')).not.toHaveClass(/hidden/) // Wait for loading to complete await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) // Month should have changed const newMonth = await page.locator('[data-map-target="monthSelect"]').inputValue() expect(newMonth).not.toBe(initialMonth) }) test('navigation controls are present', async ({ page }) => { const navControls = page.locator('.maplibregl-ctrl-top-right') await expect(navControls).toBeVisible() // Zoom controls const zoomIn = page.locator('.maplibregl-ctrl-zoom-in') const zoomOut = page.locator('.maplibregl-ctrl-zoom-out') await expect(zoomIn).toBeVisible() await expect(zoomOut).toBeVisible() }) test('map fits bounds to data', async ({ page }) => { await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) // Get map zoom level (should be > 2 if fitBounds worked) const zoom = await page.evaluate(() => { const map = window.mapInstance || document.querySelector('[data-controller="map"]')?.map return map?.getZoom() }) expect(zoom).toBeGreaterThan(2) }) test('loading indicator shows during fetch', async ({ page }) => { // Reload page to see loading await page.reload() // Loading should be visible const loading = page.locator('[data-map-target="loading"]') await expect(loading).not.toHaveClass(/hidden/) // Wait for it to hide await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) }) }) ``` **File**: `e2e/v2/helpers/setup.ts` ```typescript import { Page } from '@playwright/test' /** * Login helper for E2E tests */ export async function login(page: Page, email = 'demo@dawarich.app', password = 'password') { await page.goto('/users/sign_in') await page.fill('input[name="user[email]"]', email) await page.fill('input[name="user[password]"]', password) await page.click('button[type="submit"]') await page.waitForURL('/') } /** * Wait for map to be ready */ export async function waitForMap(page: Page) { await page.waitForSelector('.maplibregl-canvas') await page.waitForSelector('[data-map-target="loading"].hidden', { timeout: 15000 }) } /** * Expose map instance for testing */ export async function exposeMapInstance(page: Page) { await page.evaluate(() => { const controller = document.querySelector('[data-controller="map"]') if (controller && controller.map) { window.mapInstance = controller.map } }) } ``` --- ## βœ… Phase 1 Completion Checklist ### Implementation - [ ] Created all JavaScript files - [ ] Created view template - [ ] Added controller and routes - [ ] Installed MapLibre GL JS (`npm install maplibre-gl`) - [ ] Map renders successfully - [ ] Points load and display - [ ] Clustering works - [ ] Popups show on click - [ ] Month selector changes data ### Testing - [ ] All E2E tests pass (`npx playwright test e2e/v2/phase-1-mvp.spec.ts`) - [ ] Manual testing complete - [ ] Tested on mobile viewport - [ ] Tested on desktop viewport - [ ] No console errors ### Performance - [ ] Map loads in < 3 seconds - [ ] Points render smoothly - [ ] No memory leaks (check DevTools) ### Documentation - [ ] Code comments added - [ ] README updated with Phase 1 status --- ## πŸš€ Deployment ### Staging Deployment ```bash git checkout -b maps-v2-phase-1 git add app/javascript/maps_v2/ app/views/maps_v2/ app/controllers/maps_v2_controller.rb git commit -m "feat: Maps V2 Phase 1 - MVP with points layer" git push origin maps-v2-phase-1 # Deploy to staging # Test at: https://staging.example.com/maps_v2 ``` ### Production Deployment After staging approval: ```bash git checkout main git merge maps-v2-phase-1 git push origin main ``` --- ## πŸ”„ Rollback Plan If issues arise: ```bash # Revert deployment git revert HEAD # Or disable route # In config/routes.rb, comment out: # get '/maps_v2', to: 'maps_v2#index' ``` --- ## πŸ“Š Success Metrics | Metric | Target | How to Verify | |--------|--------|---------------| | Map loads | < 3s | E2E test timing | | Points render | All visible | E2E test assertion | | Clustering | Works at zoom < 14 | Manual testing | | Popup | Shows on click | E2E test | | Month selector | Changes data | E2E test | | No errors | 0 console errors | Browser DevTools | --- ## πŸŽ‰ What's Next? After Phase 1 is deployed and tested: - **Phase 2**: Add routes layer and enhanced date navigation - Get user feedback on Phase 1 - Monitor performance metrics - Plan Phase 2 timeline **Phase 1 Complete!** You now have a working location history map. πŸ—ΊοΈ