# 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 `
`
}
}
```
---
## 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
```
---
## 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.