dawarich/app/javascript/maps_v2/PHASE_1_MVP.md

26 KiB

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

/**
 * 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<Object>} Array of MapLibre layer configs
   */
  getLayerConfigs() {
    throw new Error('Must implement getLayerConfigs()')
  }

  /**
   * Get all layer IDs for this layer
   * @returns {Array<string>}
   */
  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

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

/**
 * 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

/**
 * 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<Object>} { 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<Array>} 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

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 `
      <div class="point-popup">
        <div class="popup-header">
          <strong>Point #${id}</strong>
        </div>
        <div class="popup-body">
          <div class="popup-row">
            <span class="label">Time:</span>
            <span class="value">${formatTimestamp(timestamp)}</span>
          </div>
          ${altitude ? `
            <div class="popup-row">
              <span class="label">Altitude:</span>
              <span class="value">${Math.round(altitude)}m</span>
            </div>
          ` : ''}
          ${battery ? `
            <div class="popup-row">
              <span class="label">Battery:</span>
              <span class="value">${battery}%</span>
            </div>
          ` : ''}
          ${accuracy ? `
            <div class="popup-row">
              <span class="label">Accuracy:</span>
              <span class="value">${Math.round(accuracy)}m</span>
            </div>
          ` : ''}
          ${velocity ? `
            <div class="popup-row">
              <span class="label">Speed:</span>
              <span class="value">${Math.round(velocity * 3.6)} km/h</span>
            </div>
          ` : ''}
        </div>
      </div>
    `
  }
}

1.6 Main Map Controller

Stimulus controller orchestrating everything.

File: app/javascript/maps_v2/controllers/map_controller.js

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

<div class="maps-v2-container"
     data-controller="map"
     data-map-api-key-value="<%= current_api_user.api_key %>"
     data-map-start-date-value="<%= @start_date.to_s %>"
     data-map-end-date-value="<%= @end_date.to_s %>">

  <!-- Map container -->
  <div class="map-wrapper">
    <div data-map-target="container" class="map-container"></div>

    <!-- Loading overlay -->
    <div data-map-target="loading" class="loading-overlay hidden">
      <div class="loading-spinner"></div>
      <div class="loading-text">Loading points...</div>
    </div>
  </div>

  <!-- Month selector -->
  <div class="controls-panel">
    <div class="control-group">
      <label for="month-select">Month:</label>
      <select id="month-select"
              data-map-target="monthSelect"
              data-action="change->map#monthChanged"
              class="month-selector">
        <% 12.times do |i| %>
          <% date = Date.today.beginning_of_month - i.months %>
          <option value="<%= date.strftime('%Y-%m') %>"
                  <%= 'selected' if date.year == @start_date.year && date.month == @start_date.month %>>
            <%= date.strftime('%B %Y') %>
          </option>
        <% end %>
      </select>
    </div>
  </div>
</div>

<style>
  .maps-v2-container {
    height: 100vh;
    display: flex;
    flex-direction: column;
  }

  .map-wrapper {
    flex: 1;
    position: relative;
  }

  .map-container {
    width: 100%;
    height: 100%;
  }

  .loading-overlay {
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.9);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    z-index: 1000;
  }

  .loading-overlay.hidden {
    display: none;
  }

  .loading-spinner {
    width: 40px;
    height: 40px;
    border: 4px solid #e5e7eb;
    border-top-color: #3b82f6;
    border-radius: 50%;
    animation: spin 1s linear infinite;
  }

  @keyframes spin {
    to { transform: rotate(360deg); }
  }

  .loading-text {
    margin-top: 16px;
    font-size: 14px;
    color: #6b7280;
  }

  .controls-panel {
    padding: 16px;
    background: white;
    border-top: 1px solid #e5e7eb;
  }

  .control-group {
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .control-group label {
    font-weight: 500;
    color: #374151;
  }

  .month-selector {
    padding: 8px 12px;
    border: 1px solid #d1d5db;
    border-radius: 6px;
    font-size: 14px;
  }

  /* Popup styles */
  .point-popup {
    font-family: system-ui, -apple-system, sans-serif;
  }

  .popup-header {
    margin-bottom: 8px;
    padding-bottom: 8px;
    border-bottom: 1px solid #e5e7eb;
  }

  .popup-body {
    font-size: 13px;
  }

  .popup-row {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    padding: 4px 0;
  }

  .popup-row .label {
    color: #6b7280;
  }

  .popup-row .value {
    font-weight: 500;
    color: #111827;
  }
</style>

1.8 Controller (Rails)

File: app/controllers/maps_v2_controller.rb

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)

# Maps V2
get '/maps_v2', to: 'maps_v2#index', as: :maps_v2

🧪 E2E Tests

File: e2e/v2/phase-1-mvp.spec.ts

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

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

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:

git checkout main
git merge maps-v2-phase-1
git push origin main

🔄 Rollback Plan

If issues arise:

# 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. 🗺️