# Phase 4: Visits + Photos **Timeline**: Week 4 **Goal**: Add visits detection and photo integration **Dependencies**: Phases 1-3 complete **Status**: Ready for implementation ## ๐ŸŽฏ Phase Objectives Build on Phases 1-3 by adding: - โœ… Visits layer (suggested + confirmed) - โœ… Photos layer with camera icons - โœ… Visits drawer with search/filter - โœ… Photo popups with image preview - โœ… Visit statistics - โœ… E2E tests **Deploy Decision**: Users can see detected visits and photos on the map. --- ## ๐Ÿ“‹ Features Checklist - [ ] Visits layer (yellow = suggested, green = confirmed) - [ ] Photos layer with camera icons - [ ] Click visit to see details - [ ] Click photo to see preview - [ ] Visits drawer (slide-in panel) - [ ] Search visits by name - [ ] Filter by suggested/confirmed - [ ] Visit statistics (duration, frequency) - [ ] E2E tests passing --- ## ๐Ÿ—๏ธ New Files (Phase 4) ``` app/javascript/maps_v2/ โ”œโ”€โ”€ layers/ โ”‚ โ”œโ”€โ”€ visits_layer.js # NEW: Visits markers โ”‚ โ””โ”€โ”€ photos_layer.js # NEW: Photo markers โ”œโ”€โ”€ controllers/ โ”‚ โ””โ”€โ”€ visits_drawer_controller.js # NEW: Visits search/filter โ””โ”€โ”€ components/ โ”œโ”€โ”€ visit_popup.js # NEW: Visit popup factory โ””โ”€โ”€ photo_popup.js # NEW: Photo popup factory app/views/maps_v2/ โ””โ”€โ”€ _visits_drawer.html.erb # NEW: Visits drawer partial e2e/v2/ โ””โ”€โ”€ phase-4-visits.spec.ts # NEW: E2E tests ``` --- ## 4.1 Visits Layer Display suggested and confirmed visits with different colors. **File**: `app/javascript/maps_v2/layers/visits_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Visits layer showing suggested and confirmed visits * Yellow = suggested, Green = confirmed */ export class VisitsLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'visits', ...options }) } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] } } } getLayerConfigs() { return [ // Visit circles { id: this.id, type: 'circle', source: this.sourceId, paint: { 'circle-radius': 12, 'circle-color': [ 'case', ['==', ['get', 'status'], 'confirmed'], '#22c55e', // Green for confirmed '#eab308' // Yellow for suggested ], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.8 } }, // Visit labels { id: `${this.id}-labels`, type: 'symbol', source: this.sourceId, layout: { 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-offset': [0, 1.5], 'text-anchor': 'top' }, paint: { 'text-color': '#111827', 'text-halo-color': '#ffffff', 'text-halo-width': 2 } } ] } getLayerIds() { return [this.id, `${this.id}-labels`] } } ``` --- ## 4.2 Photos Layer Display photos with camera icon markers. **File**: `app/javascript/maps_v2/layers/photos_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Photos layer with camera icons */ export class PhotosLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'photos', ...options }) this.cameraIcon = null } async add(data) { // Load camera icon before adding layer await this.loadCameraIcon() super.add(data) } async loadCameraIcon() { if (this.cameraIcon || this.map.hasImage('camera-icon')) return // Create camera icon SVG const svg = ` ` const img = new Image(24, 24) img.src = 'data:image/svg+xml;base64,' + btoa(svg) await new Promise((resolve, reject) => { img.onload = () => { this.map.addImage('camera-icon', img) this.cameraIcon = true resolve() } img.onerror = reject }) } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] } } } getLayerConfigs() { return [ { id: this.id, type: 'symbol', source: this.sourceId, layout: { 'icon-image': 'camera-icon', 'icon-size': 1, 'icon-allow-overlap': true } } ] } } ``` --- ## 4.3 Visit Popup Factory **File**: `app/javascript/maps_v2/components/visit_popup.js` ```javascript import { formatTimestamp } from '../utils/geojson_transformers' /** * Factory for creating visit popups */ export class VisitPopupFactory { /** * Create popup for a visit * @param {Object} properties - Visit properties * @returns {string} HTML for popup */ static createVisitPopup(properties) { const { id, name, status, started_at, ended_at, duration, place_name } = properties const startTime = formatTimestamp(started_at) const endTime = formatTimestamp(ended_at) const durationHours = Math.round(duration / 3600) return `
` } } ``` --- ## 4.4 Photo Popup Factory **File**: `app/javascript/maps_v2/components/photo_popup.js` ```javascript /** * Factory for creating photo popups */ export class PhotoPopupFactory { /** * Create popup for a photo * @param {Object} properties - Photo properties * @returns {string} HTML for popup */ static createPhotoPopup(properties) { const { id, thumbnail_url, url, taken_at, camera, location_name } = properties return `
Photo
${location_name ? `
${location_name}
` : ''} ${taken_at ? `
${new Date(taken_at * 1000).toLocaleString()}
` : ''} ${camera ? `
${camera}
` : ''}
View Full Size
` } } ``` --- ## 4.5 Visits Drawer Controller Search and filter visits. **File**: `app/javascript/maps_v2/controllers/visits_drawer_controller.js` ```javascript import { Controller } from '@hotwired/stimulus' /** * Visits drawer controller * Manages visits list with search and filter */ export default class extends Controller { static targets = [ 'drawer', 'searchInput', 'filterSelect', 'visitsList', 'visitItem', 'emptyState' ] static values = { open: { type: Boolean, default: false } } static outlets = ['map'] connect() { this.visits = [] this.filteredVisits = [] } /** * Toggle drawer */ toggle() { this.openValue = !this.openValue this.drawerTarget.classList.toggle('open', this.openValue) } /** * Open drawer */ open() { this.openValue = true this.drawerTarget.classList.add('open') } /** * Close drawer */ close() { this.openValue = false this.drawerTarget.classList.remove('open') } /** * Load visits from API * @param {Array} visits - Visits data */ loadVisits(visits) { this.visits = visits this.applyFilters() } /** * Search visits */ search() { this.applyFilters() } /** * Filter visits by status */ filter() { this.applyFilters() } /** * Apply search and filter */ applyFilters() { const searchTerm = this.hasSearchInputTarget ? this.searchInputTarget.value.toLowerCase() : '' const filterStatus = this.hasFilterSelectTarget ? this.filterSelectTarget.value : 'all' this.filteredVisits = this.visits.filter(visit => { // Apply search const matchesSearch = !searchTerm || visit.name?.toLowerCase().includes(searchTerm) || visit.place_name?.toLowerCase().includes(searchTerm) // Apply filter const matchesFilter = filterStatus === 'all' || visit.status === filterStatus return matchesSearch && matchesFilter }) this.renderVisits() } /** * Render visits list */ renderVisits() { if (!this.hasVisitsListTarget) return if (this.filteredVisits.length === 0) { this.showEmptyState() return } this.hideEmptyState() const html = this.filteredVisits.map(visit => this.renderVisitItem(visit)).join('') this.visitsListTarget.innerHTML = html } /** * Render single visit item * @param {Object} visit * @returns {string} HTML */ renderVisitItem(visit) { const duration = Math.round(visit.duration / 3600) return `
${visit.status === 'confirmed' ? 'โœ“' : '?'}
${visit.name || visit.place_name || 'Unknown'}
${duration}h โ€ข ${new Date(visit.started_at * 1000).toLocaleDateString()}
โ€บ
` } /** * Select a visit (zoom to it on map) */ selectVisit(event) { const visitId = event.currentTarget.dataset.visitId const visit = this.visits.find(v => v.id.toString() === visitId) if (visit && this.hasMapOutlet) { // Fly to visit location this.mapOutlet.map.flyTo({ center: [visit.longitude, visit.latitude], zoom: 15, duration: 1000 }) // Show popup const popup = new maplibregl.Popup() .setLngLat([visit.longitude, visit.latitude]) .setHTML(VisitPopupFactory.createVisitPopup(visit)) .addTo(this.mapOutlet.map) } } /** * Show empty state */ showEmptyState() { if (this.hasEmptyStateTarget) { this.emptyStateTarget.classList.remove('hidden') } if (this.hasVisitsListTarget) { this.visitsListTarget.innerHTML = '' } } /** * Hide empty state */ hideEmptyState() { if (this.hasEmptyStateTarget) { this.emptyStateTarget.classList.add('hidden') } } } ``` --- ## 4.6 Update Map Controller Add visits and photos layers. **File**: `app/javascript/maps_v2/controllers/map_controller.js` (add to loadMapData) ```javascript // Add imports import { VisitsLayer } from '../layers/visits_layer' import { PhotosLayer } from '../layers/photos_layer' import { VisitPopupFactory } from '../components/visit_popup' import { PhotoPopupFactory } from '../components/photo_popup' // In loadMapData(), after heatmap layer: // NEW: Load and add visits const visits = await this.api.fetchVisits({ start_at: this.startDateValue, end_at: this.endDateValue }) const visitsGeoJSON = this.visitsToGeoJSON(visits) if (!this.visitsLayer) { this.visitsLayer = new VisitsLayer(this.map, { visible: false }) if (this.map.loaded()) { this.visitsLayer.add(visitsGeoJSON) } else { this.map.on('load', () => { this.visitsLayer.add(visitsGeoJSON) }) } } else { this.visitsLayer.update(visitsGeoJSON) } // NEW: Load and add photos const photos = await this.api.fetchPhotos({ start_at: this.startDateValue, end_at: this.endDateValue }) const photosGeoJSON = this.photosToGeoJSON(photos) if (!this.photosLayer) { this.photosLayer = new PhotosLayer(this.map, { visible: false }) if (this.map.loaded()) { await this.photosLayer.add(photosGeoJSON) } else { this.map.on('load', async () => { await this.photosLayer.add(photosGeoJSON) }) } } else { await this.photosLayer.update(photosGeoJSON) } // Add click handlers this.map.on('click', 'visits', this.handleVisitClick.bind(this)) this.map.on('click', 'photos', this.handlePhotoClick.bind(this)) // Add new helper methods: /** * Convert visits to GeoJSON */ visitsToGeoJSON(visits) { return { type: 'FeatureCollection', features: visits.map(visit => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [visit.longitude, visit.latitude] }, properties: { id: visit.id, name: visit.name, place_name: visit.place_name, status: visit.status, started_at: visit.started_at, ended_at: visit.ended_at, duration: visit.duration } })) } } /** * Convert photos to GeoJSON */ photosToGeoJSON(photos) { return { type: 'FeatureCollection', features: photos.map(photo => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [photo.longitude, photo.latitude] }, properties: { id: photo.id, thumbnail_url: photo.thumbnail_url, url: photo.url, taken_at: photo.taken_at, camera: photo.camera, location_name: photo.location_name } })) } } /** * Handle visit click */ handleVisitClick(e) { const feature = e.features[0] const coordinates = feature.geometry.coordinates.slice() const properties = feature.properties new maplibregl.Popup() .setLngLat(coordinates) .setHTML(VisitPopupFactory.createVisitPopup(properties)) .addTo(this.map) } /** * Handle photo click */ handlePhotoClick(e) { const feature = e.features[0] const coordinates = feature.geometry.coordinates.slice() const properties = feature.properties new maplibregl.Popup() .setLngLat(coordinates) .setHTML(PhotoPopupFactory.createPhotoPopup(properties)) .addTo(this.map) } ``` --- ## 4.7 Update API Client Add visits and photos endpoints. **File**: `app/javascript/maps_v2/services/api_client.js` (add methods) ```javascript /** * Fetch visits for date range * @param {Object} options - { start_at, end_at } * @returns {Promise} Visits */ async fetchVisits({ start_at, end_at }) { const params = new URLSearchParams({ start_at, end_at }) const response = await fetch(`${this.baseURL}/visits?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch visits: ${response.statusText}`) } return response.json() } /** * Fetch photos for date range * @param {Object} options - { start_at, end_at } * @returns {Promise} Photos */ async fetchPhotos({ start_at, end_at }) { const params = new URLSearchParams({ start_at, end_at }) const response = await fetch(`${this.baseURL}/photos?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch photos: ${response.statusText}`) } return response.json() } ``` --- ## 4.8 Visits Drawer Partial **File**: `app/views/maps_v2/_visits_drawer.html.erb` ```erb

Visits

``` --- ## 4.9 Update View Template Add visits drawer and layer controls. **File**: `app/views/maps_v2/index.html.erb` (add to layer controls) ```erb <%= render 'maps_v2/visits_drawer' %> ``` --- ## ๐Ÿงช E2E Tests **File**: `e2e/v2/phase-4-visits.spec.ts` ```typescript import { test, expect } from '@playwright/test' import { login, waitForMap } from './helpers/setup' test.describe('Phase 4: Visits + Photos', () => { test.beforeEach(async ({ page }) => { await login(page) await page.goto('/maps_v2') await waitForMap(page) }) test.describe('Visits Layer', () => { test('visits layer exists', async ({ page }) => { const hasVisits = await page.evaluate(() => { const map = window.mapInstance return map?.getLayer('visits') !== undefined }) expect(hasVisits).toBe(true) }) test('visits toggle works', async ({ page }) => { const visitsButton = page.locator('button[data-layer="visits"]') if (await visitsButton.isVisible()) { await visitsButton.click() const isVisible = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('visits', 'visibility') === 'visible' }) expect(isVisible).toBe(true) } }) test('clicking visit shows popup', async ({ page }) => { // Enable visits layer const visitsButton = page.locator('button[data-layer="visits"]') if (await visitsButton.isVisible()) { await visitsButton.click() } // Click on map where visits might be const mapContainer = page.locator('[data-map-target="container"]') await mapContainer.click({ position: { x: 400, y: 300 } }) // Check for popup (may not appear if no visit clicked) try { await page.waitForSelector('.visit-popup', { timeout: 2000 }) const popup = page.locator('.visit-popup') await expect(popup).toBeVisible() } catch (e) { // No visit clicked, that's okay } }) }) test.describe('Photos Layer', () => { test('photos layer exists', async ({ page }) => { const hasPhotos = await page.evaluate(() => { const map = window.mapInstance return map?.getLayer('photos') !== undefined }) expect(hasPhotos).toBe(true) }) test('photos toggle works', async ({ page }) => { const photosButton = page.locator('button[data-layer="photos"]') if (await photosButton.isVisible()) { await photosButton.click() const isVisible = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('photos', 'visibility') === 'visible' }) expect(isVisible).toBe(true) } }) }) test.describe('Visits Drawer', () => { test('visits drawer opens and closes', async ({ page }) => { const toggleBtn = page.locator('.visits-toggle-btn') await toggleBtn.click() const drawer = page.locator('.visits-drawer-content') await expect(drawer).toHaveClass(/open/) const closeBtn = page.locator('.visits-drawer-content .close-btn') await closeBtn.click() await expect(drawer).not.toHaveClass(/open/) }) test('search visits works', async ({ page }) => { await page.click('.visits-toggle-btn') const searchInput = page.locator('[data-visits-drawer-target="searchInput"]') await searchInput.fill('test') // Wait for search to apply await page.waitForTimeout(300) }) test('filter visits works', async ({ page }) => { await page.click('.visits-toggle-btn') const filterSelect = page.locator('[data-visits-drawer-target="filterSelect"]') await filterSelect.selectOption('confirmed') // Wait for filter to apply await page.waitForTimeout(300) }) }) test.describe('Regression Tests', () => { test('all previous layers still work', async ({ page }) => { const layers = ['points', 'routes', 'heatmap'] for (const layer of layers) { const hasLayer = await page.evaluate((layerName) => { const map = window.mapInstance return map?.getSource(`${layerName}-source`) !== undefined }, layer) expect(hasLayer).toBe(true) } }) }) }) ``` --- ## โœ… Phase 4 Completion Checklist ### Implementation - [ ] Created visits_layer.js - [ ] Created photos_layer.js - [ ] Created visit_popup.js - [ ] Created photo_popup.js - [ ] Created visits_drawer_controller.js - [ ] Updated map_controller.js - [ ] Updated api_client.js - [ ] Created visits drawer partial - [ ] Updated view template ### Functionality - [ ] Visits render with correct colors - [ ] Photos display with camera icons - [ ] Visit popups show details - [ ] Photo popups show preview - [ ] Visits drawer opens/closes - [ ] Search works - [ ] Filter works - [ ] Clicking visit zooms to it ### Testing - [ ] All Phase 4 E2E tests pass - [ ] Phase 1-3 tests still pass (regression) - [ ] Manual testing complete --- ## ๐Ÿš€ Deployment ```bash git checkout -b maps-v2-phase-4 git add app/javascript/maps_v2/ app/views/maps_v2/ e2e/v2/ git commit -m "feat: Maps V2 Phase 4 - Visits and photos" # Run all tests (regression) npx playwright test e2e/v2/ # Deploy to staging git push origin maps-v2-phase-4 ``` --- ## ๐ŸŽ‰ What's Next? **Phase 5**: Add areas layer and drawing tools for creating/managing geographic areas.