* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
This commit is contained in:
Evgenii Burmakin 2025-12-26 14:57:55 +01:00 committed by GitHub
parent 2a1584e0b8
commit 3f0aaa09f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 733 additions and 26 deletions

View file

@ -1 +1 @@
0.36.3
0.36.4

View file

@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.36.4] - Unreleased
## Fixed
- Fixed a bug preventing the app to start if a composite index on stats table already exists. #2034 #2051 #2046
- New compiled assets will override old ones on app start to prevent serving stale assets.
- Number of points in stats should no longer go negative when points are deleted. #2054
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
# [0.36.3] - 2025-12-14
## Added

File diff suppressed because one or more lines are too long

View file

@ -357,4 +357,28 @@ export class RoutesManager {
SettingsManager.updateSetting('pointsVisible', visible)
}
/**
* Toggle family members layer
*/
async toggleFamily(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('familyEnabled', enabled)
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
if (enabled) {
familyLayer.show()
// Load family members data
await this.controller.loadFamilyMembers()
} else {
familyLayer.hide()
}
}
// Show/hide the family members list
if (this.controller.hasFamilyMembersListTarget) {
this.controller.familyMembersListTarget.style.display = enabled ? 'block' : 'none'
}
}
}

View file

@ -53,6 +53,7 @@ export class SettingsController {
placesToggle: 'placesEnabled',
fogToggle: 'fogEnabled',
scratchToggle: 'scratchEnabled',
familyToggle: 'familyEnabled',
speedColoredToggle: 'speedColoredRoutesEnabled'
}
@ -73,6 +74,11 @@ export class SettingsController {
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
}
// Show/hide family members list based on initial toggle state
if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget) {
controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none'
}
// Sync route opacity slider
if (controller.hasRouteOpacityRangeTarget) {
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100

View file

@ -58,11 +58,15 @@ export default class extends Controller {
'placesToggle',
'fogToggle',
'scratchToggle',
'familyToggle',
// Speed-colored routes
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput',
// Family members
'familyMembersList',
'familyMembersContainer',
// Area selection
'selectAreaButton',
'selectionActions',
@ -347,6 +351,103 @@ export default class extends Controller {
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
toggleFamily(event) { return this.routesManager.toggleFamily(event) }
// Family Members methods
async loadFamilyMembers() {
try {
const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
if (!response.ok) {
if (response.status === 403) {
console.warn('[Maps V2] Family feature not enabled or user not in family')
Toast.info('Family feature not available')
return
}
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
const locations = data.locations || []
// Update family layer with locations
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
familyLayer.loadMembers(locations)
}
// Render family members list
this.renderFamilyMembersList(locations)
Toast.success(`Loaded ${locations.length} family member(s)`)
} catch (error) {
console.error('[Maps V2] Failed to load family members:', error)
Toast.error('Failed to load family members')
}
}
renderFamilyMembersList(locations) {
if (!this.hasFamilyMembersContainerTarget) return
const container = this.familyMembersContainerTarget
if (locations.length === 0) {
container.innerHTML = '<p class="text-xs text-base-content/60">No family members sharing location</p>'
return
}
container.innerHTML = locations.map(location => {
const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?'
const color = this.getFamilyMemberColor(location.user_id)
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', {
timeZone: this.timezoneValue || 'UTC',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
return `
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer transition-colors"
data-action="click->maps--maplibre#centerOnFamilyMember"
data-member-id="${location.user_id}">
<div style="background-color: ${color}; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; flex-shrink: 0;">
${emailInitial}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">${location.email || 'Unknown'}</div>
<div class="text-xs text-base-content/60">${lastSeen}</div>
</div>
</div>
`
}).join('')
}
getFamilyMemberColor(userId) {
const colors = [
'#3b82f6', '#10b981', '#f59e0b',
'#ef4444', '#8b5cf6', '#ec4899'
]
// Use user ID to get consistent color
const hash = userId.toString().split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
centerOnFamilyMember(event) {
const memberId = event.currentTarget.dataset.memberId
if (!memberId) return
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
familyLayer.centerOnMember(parseInt(memberId))
Toast.success('Centered on family member')
}
}
// Info Display methods
showInfo(title, content, actions = []) {

View file

@ -148,4 +148,63 @@ export class FamilyLayer extends BaseLayer {
features: filtered
})
}
/**
* Load all family members from API
* @param {Object} locations - Array of family member locations
*/
loadMembers(locations) {
if (!Array.isArray(locations)) {
console.warn('[FamilyLayer] Invalid locations data:', locations)
return
}
const features = locations.map(location => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [location.longitude, location.latitude]
},
properties: {
id: location.user_id,
name: location.email || 'Unknown',
email: location.email,
color: location.color || this.getMemberColor(location.user_id),
lastUpdate: Date.now(),
battery: location.battery,
batteryStatus: location.battery_status,
updatedAt: location.updated_at
}
}))
this.update({
type: 'FeatureCollection',
features
})
}
/**
* Center map on specific family member
* @param {string} memberId - ID of the member to center on
*/
centerOnMember(memberId) {
const features = this.data?.features || []
const member = features.find(f => f.properties.id === memberId)
if (member && this.map) {
this.map.flyTo({
center: member.geometry.coordinates,
zoom: 15,
duration: 1500
})
}
}
/**
* Get all current family members
* @returns {Array} Array of member features
*/
getMembers() {
return this.data?.features || []
}
}

View file

@ -317,6 +317,32 @@
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
</div>
<% if DawarichSettings.family_feature_enabled? %>
<div class="divider"></div>
<!-- Family Members Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="familyToggle"
data-action="change->maps--maplibre#toggleFamily" />
<span class="label-text font-medium">Family Members</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show family member locations</p>
</div>
<!-- Family Members List (conditionally shown) -->
<div class="ml-14 space-y-2" data-maps--maplibre-target="familyMembersList" style="display: none;">
<div class="text-xs text-base-content/60 mb-2">
Click to center on member
</div>
<div data-maps--maplibre-target="familyMembersContainer" class="space-y-1">
<!-- Family members will be dynamically inserted here -->
</div>
</div>
<% end %>
</div>
</div>

View file

@ -35,10 +35,10 @@ export DATABASE_NAME
rm -f $APP_PATH/tmp/pids/server.pid
# Sync static assets from image to volume
# This ensures new files (like maps_maplibre styles) are copied to the persistent volume
# This ensures new and updated files are copied to the persistent volume
if [ -d "/tmp/public_assets" ]; then
echo "📦 Syncing new static assets to public volume..."
cp -rn /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
echo "📦 Syncing static assets to public volume..."
cp -ru /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
echo "✅ Static assets synced!"
fi

View file

@ -0,0 +1,370 @@
import { test, expect } from '@playwright/test'
import { closeOnboardingModal } from '../../../helpers/navigation.js'
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete, getMapCenter } from '../../helpers/setup.js'
test.describe('Family Members Layer', () => {
test.beforeEach(async ({ page }) => {
await navigateToMapsV2(page)
await closeOnboardingModal(page)
await waitForMapLibre(page)
await waitForLoadingComplete(page)
await page.waitForTimeout(1500)
})
test.describe('Toggle', () => {
test('family members toggle exists in Layers tab', async ({ page }) => {
// Open settings panel
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
// Click Layers tab
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
// Check if Family Members toggle exists
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await expect(familyToggle).toBeVisible()
})
test('family members toggle is unchecked by default', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
const isChecked = await familyToggle.isChecked()
expect(isChecked).toBe(false)
})
test('can toggle family members layer on', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
// Toggle on
await familyToggle.check()
await page.waitForTimeout(1000) // Wait for API call and layer update
const isChecked = await familyToggle.isChecked()
expect(isChecked).toBe(true)
})
test('can toggle family members layer off', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
// Toggle on first
await familyToggle.check()
await page.waitForTimeout(1000)
// Then toggle off
await familyToggle.uncheck()
await page.waitForTimeout(500)
const isChecked = await familyToggle.isChecked()
expect(isChecked).toBe(false)
})
})
test.describe('Family Members List', () => {
test('family members list is hidden by default', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyMembersList = page.locator('[data-maps--maplibre-target="familyMembersList"]')
// Should be hidden initially
const isHidden = await familyMembersList.evaluate(el => el.style.display === 'none')
expect(isHidden).toBe(true)
})
test('family members list appears when toggle is enabled', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
const familyMembersList = page.locator('[data-maps--maplibre-target="familyMembersList"]')
// Toggle on
await familyToggle.check()
await page.waitForTimeout(1000)
// List should now be visible
const isVisible = await familyMembersList.evaluate(el => el.style.display === 'block')
expect(isVisible).toBe(true)
})
test('family members list shows members when data exists', async ({ page }) => {
// Skip if no family members exist
const hasFamilyMembers = await page.evaluate(async () => {
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
if (!apiKey) return false
try {
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
if (!response.ok) return false
const data = await response.json()
return data.locations && data.locations.length > 0
} catch (error) {
return false
}
})
if (!hasFamilyMembers) {
test.skip()
return
}
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
// Toggle on
await familyToggle.check()
await page.waitForTimeout(1500) // Wait for API call
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
// Should have at least one member
const memberItems = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]')
const count = await memberItems.count()
expect(count).toBeGreaterThan(0)
})
test('family member item displays email and timestamp', async ({ page }) => {
// Skip if no family members exist
const hasFamilyMembers = await page.evaluate(async () => {
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
if (!apiKey) return false
try {
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
if (!response.ok) return false
const data = await response.json()
return data.locations && data.locations.length > 0
} catch (error) {
return false
}
})
if (!hasFamilyMembers) {
test.skip()
return
}
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await familyToggle.check()
await page.waitForTimeout(1500)
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
// Should have email
const emailElement = firstMember.locator('.text-sm.font-medium')
await expect(emailElement).toBeVisible()
// Should have timestamp
const timestampElement = firstMember.locator('.text-xs.text-base-content\\/60')
await expect(timestampElement).toBeVisible()
})
})
test.describe('Center on Member', () => {
test('clicking family member centers map on their location', async ({ page }) => {
// Skip if no family members exist
const hasFamilyMembers = await page.evaluate(async () => {
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
if (!apiKey) return false
try {
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
if (!response.ok) return false
const data = await response.json()
return data.locations && data.locations.length > 0
} catch (error) {
return false
}
})
if (!hasFamilyMembers) {
test.skip()
return
}
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await familyToggle.check()
await page.waitForTimeout(1500)
// Get initial map center
const initialCenter = await getMapCenter(page)
// Click on first family member
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
await firstMember.click()
// Wait for map animation
await page.waitForTimeout(2000)
// Get new map center
const newCenter = await getMapCenter(page)
// Map should have moved (centers should be different)
const hasMoved = initialCenter.lat !== newCenter.lat || initialCenter.lng !== newCenter.lng
expect(hasMoved).toBe(true)
})
test('shows success toast when centering on member', async ({ page }) => {
// Skip if no family members exist
const hasFamilyMembers = await page.evaluate(async () => {
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
if (!apiKey) return false
try {
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
if (!response.ok) return false
const data = await response.json()
return data.locations && data.locations.length > 0
} catch (error) {
return false
}
})
if (!hasFamilyMembers) {
test.skip()
return
}
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await familyToggle.check()
await page.waitForTimeout(1500)
// Click on first family member
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
await firstMember.click()
// Wait for toast to appear
await page.waitForTimeout(500)
// Check for success toast
const toast = page.locator('.alert-success, .toast, [role="alert"]').filter({ hasText: 'Centered on family member' })
await expect(toast).toBeVisible({ timeout: 3000 })
})
})
test.describe('Family Layer on Map', () => {
test('family layer exists on map', async ({ page }) => {
const hasLayer = await page.evaluate(() => {
const element = document.querySelector('[data-controller*="maps--maplibre"]')
const app = window.Stimulus || window.Application
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
return controller?.map?.getLayer('family') !== undefined
})
expect(hasLayer).toBe(true)
})
test('family layer is hidden by default', async ({ page }) => {
const isVisible = await page.evaluate(() => {
const element = document.querySelector('[data-controller*="maps--maplibre"]')
const app = window.Stimulus || window.Application
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
const visibility = controller?.map?.getLayoutProperty('family', 'visibility')
return visibility === 'visible'
})
expect(isVisible).toBe(false)
})
test('family layer becomes visible when toggle is enabled', async ({ page }) => {
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await familyToggle.check()
await page.waitForTimeout(1500)
const isVisible = await page.evaluate(() => {
const element = document.querySelector('[data-controller*="maps--maplibre"]')
const app = window.Stimulus || window.Application
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
const visibility = controller?.map?.getLayoutProperty('family', 'visibility')
return visibility === 'visible' || visibility === undefined
})
expect(isVisible).toBe(true)
})
})
test.describe('No Family Members', () => {
test('shows appropriate message when no family members are sharing', async ({ page }) => {
// This test checks the message when API returns empty array
const hasFamilyMembers = await page.evaluate(async () => {
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
if (!apiKey) return false
try {
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
if (!response.ok) return false
const data = await response.json()
return data.locations && data.locations.length > 0
} catch (error) {
return false
}
})
// Only run this test if there are NO family members
if (hasFamilyMembers) {
test.skip()
return
}
await page.click('button[title="Open map settings"]')
await page.waitForTimeout(400)
await page.click('button[data-tab="layers"]')
await page.waitForTimeout(300)
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
await familyToggle.check()
await page.waitForTimeout(1500)
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
const noMembersMessage = familyMembersContainer.getByText('No family members sharing location')
await expect(noMembersMessage).toBeVisible()
})
})
})

View file

@ -3,17 +3,17 @@
namespace :demo do
desc 'Seed demo data: user, points from GeoJSON, visits, and areas'
task :seed_data, [:geojson_path] => :environment do |_t, args|
geojson_path = args[:geojson_path] || Rails.root.join('tmp', 'demo_data.geojson').to_s
geojson_path = args[:geojson_path] || Rails.root.join('tmp/demo_data.geojson').to_s
unless File.exist?(geojson_path)
puts "Error: GeoJSON file not found at #{geojson_path}"
puts "Usage: rake demo:seed_data[path/to/file.geojson]"
puts "Or place file at tmp/demo_data.geojson"
puts 'Usage: rake demo:seed_data[path/to/file.geojson]'
puts 'Or place file at tmp/demo_data.geojson'
exit 1
end
puts "🚀 Starting demo data generation..."
puts "=" * 60
puts '🚀 Starting demo data generation...'
puts '=' * 60
# 1. Create demo user
puts "\n📝 Creating demo user..."
@ -25,7 +25,7 @@ namespace :demo do
user.save!
user.update!(status: :active, active_until: 1000.years.from_now)
puts "✅ User created: #{user.email}"
puts " Password: password"
puts ' Password: password'
puts " API Key: #{user.api_key}"
else
puts " User already exists: #{user.email}"
@ -53,7 +53,7 @@ namespace :demo do
points_count = Point.where(user_id: user.id).count
if points_count.zero?
puts "❌ No points found after import. Cannot create visits and areas."
puts '❌ No points found after import. Cannot create visits and areas.'
exit 1
end
@ -72,9 +72,14 @@ namespace :demo do
created_areas = create_areas(user, 10)
puts "✅ Created #{created_areas} areas"
puts "\n" + "=" * 60
puts "🎉 Demo data generation complete!"
puts "=" * 60
# 6. Create family with members
puts "\n👨‍👩‍👧‍👦 Creating demo family..."
family_members = create_family_with_members(user)
puts "✅ Created family with #{family_members.count} members"
puts "\n" + '=' * 60
puts '🎉 Demo data generation complete!'
puts '=' * 60
puts "\n📊 Summary:"
puts " User: #{user.email}"
puts " Points: #{Point.where(user_id: user.id).count}"
@ -82,9 +87,14 @@ namespace :demo do
puts " Suggested Visits: #{user.visits.suggested.count}"
puts " Confirmed Visits: #{user.visits.confirmed.count}"
puts " Areas: #{user.areas.count}"
puts " Family Members: #{family_members.count}"
puts "\n🔐 Login credentials:"
puts " Email: demo@dawarich.app"
puts " Password: password"
puts ' Email: demo@dawarich.app'
puts ' Password: password'
puts "\n👨‍👩‍👧‍👦 Family member credentials:"
family_members.each_with_index do |member, index|
puts " Member #{index + 1}: #{member.email} / password"
end
end
def create_visits(user, count, status)
@ -142,11 +152,11 @@ namespace :demo do
# Find nearby points within 100 meters and associate them
nearby_points = Point.where(user_id: user.id)
.where.not(id: point.id)
.where.not(id: used_point_ids)
.where('timestamp BETWEEN ? AND ?', started_at.to_i, ended_at.to_i)
.select { |p| distance_between(point, p) < 100 }
.first(10)
.where.not(id: point.id)
.where.not(id: used_point_ids)
.where('timestamp BETWEEN ? AND ?', started_at.to_i, ended_at.to_i)
.select { |p| distance_between(point, p) < 100 }
.first(10)
nearby_points.each do |nearby_point|
nearby_point.update!(visit: visit)
@ -154,10 +164,10 @@ namespace :demo do
end
created_count += 1
print "." if (index + 1) % 10 == 0
print '.' if (index + 1) % 10 == 0
end
puts "" if created_count > 0
puts '' if created_count > 0
created_count
end
@ -192,8 +202,10 @@ namespace :demo do
def distance_between(point1, point2)
# Haversine formula to calculate distance in meters
lat1, lon1 = point1.lat, point1.lon
lat2, lon2 = point2.lat, point2.lon
lat1 = point1.lat
lon1 = point1.lon
lat2 = point2.lat
lon2 = point2.lon
rad_per_deg = Math::PI / 180
rkm = 6371 # Earth radius in kilometers
@ -210,4 +222,103 @@ namespace :demo do
rm * c # Distance in meters
end
def create_family_with_members(owner)
# Create or find family
family = Family.find_or_initialize_by(creator: owner)
if family.new_record?
family.name = 'Demo Family'
family.save!
puts " Created family: #{family.name}"
else
puts " Family already exists: #{family.name}"
end
# Create or find owner membership
owner_membership = Family::Membership.find_or_create_by!(
family: family,
user: owner,
role: :owner
)
# Create 3 family members with location data
member_emails = [
'family.member1@dawarich.app',
'family.member2@dawarich.app',
'family.member3@dawarich.app'
]
family_members = []
# Get some sample points from the owner's data to create realistic locations
sample_points = Point.where(user_id: owner.id).order('RANDOM()').limit(10)
member_emails.each_with_index do |email, index|
# Create or find family member user
member = User.find_or_initialize_by(email: email)
if member.new_record?
member.password = 'password'
member.password_confirmation = 'password'
member.save!
member.update!(status: :active, active_until: 1000.years.from_now)
puts " Created family member: #{member.email}"
else
puts " Family member already exists: #{member.email}"
end
# Add member to family
Family::Membership.find_or_create_by!(
family: family,
user: member,
role: :member
)
# Enable location sharing for this member (permanent)
member.update_family_location_sharing!(true, duration: 'permanent')
# Create some points for this family member near owner's locations
if sample_points.any?
# Get a different sample point for each member
base_point = sample_points[index % sample_points.length]
# Create 3-5 recent points for this member within 1km of base location
points_count = rand(3..5)
points_count.times do |point_index|
# Add random offset (within ~1km)
lat_offset = (rand(-0.01..0.01) * 100) / 100.0
lon_offset = (rand(-0.01..0.01) * 100) / 100.0
# Calculate new coordinates
lat = base_point.lat + lat_offset
lon = base_point.lon + lon_offset
# Create point with recent timestamp (last 24 hours)
timestamp = (Time.current - rand(0..24).hours).to_i
Point.create!(
user: member,
latitude: lat,
longitude: lon,
lonlat: "POINT(#{lon} #{lat})",
timestamp: timestamp,
altitude: base_point.altitude || 0,
velocity: rand(0..50),
battery: rand(20..100),
battery_status: %w[charging connected_not_charging full].sample,
tracker_id: "demo_tracker_#{member.id}",
import_id: nil
)
end
puts " Created #{points_count} location points for #{member.email}"
end
family_members << member
end
family_members
end
end