mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-08 16:27:11 -05:00
0.36.4 (#2062)
* 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:
parent
2a1584e0b8
commit
3f0aaa09f5
11 changed files with 733 additions and 26 deletions
|
|
@ -1 +1 @@
|
|||
0.36.3
|
||||
0.36.4
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []) {
|
||||
|
|
|
|||
|
|
@ -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 || []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
370
e2e/v2/map/layers/family.spec.js
Normal file
370
e2e/v2/map/layers/family.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue