mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47: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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
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
|
# [0.36.3] - 2025-12-14
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -357,4 +357,28 @@ export class RoutesManager {
|
||||||
|
|
||||||
SettingsManager.updateSetting('pointsVisible', visible)
|
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',
|
placesToggle: 'placesEnabled',
|
||||||
fogToggle: 'fogEnabled',
|
fogToggle: 'fogEnabled',
|
||||||
scratchToggle: 'scratchEnabled',
|
scratchToggle: 'scratchEnabled',
|
||||||
|
familyToggle: 'familyEnabled',
|
||||||
speedColoredToggle: 'speedColoredRoutesEnabled'
|
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +74,11 @@ export class SettingsController {
|
||||||
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
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
|
// Sync route opacity slider
|
||||||
if (controller.hasRouteOpacityRangeTarget) {
|
if (controller.hasRouteOpacityRangeTarget) {
|
||||||
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,15 @@ export default class extends Controller {
|
||||||
'placesToggle',
|
'placesToggle',
|
||||||
'fogToggle',
|
'fogToggle',
|
||||||
'scratchToggle',
|
'scratchToggle',
|
||||||
|
'familyToggle',
|
||||||
// Speed-colored routes
|
// Speed-colored routes
|
||||||
'routesOptions',
|
'routesOptions',
|
||||||
'speedColoredToggle',
|
'speedColoredToggle',
|
||||||
'speedColorScaleContainer',
|
'speedColorScaleContainer',
|
||||||
'speedColorScaleInput',
|
'speedColorScaleInput',
|
||||||
|
// Family members
|
||||||
|
'familyMembersList',
|
||||||
|
'familyMembersContainer',
|
||||||
// Area selection
|
// Area selection
|
||||||
'selectAreaButton',
|
'selectAreaButton',
|
||||||
'selectionActions',
|
'selectionActions',
|
||||||
|
|
@ -347,6 +351,103 @@ export default class extends Controller {
|
||||||
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
||||||
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
||||||
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
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
|
// Info Display methods
|
||||||
showInfo(title, content, actions = []) {
|
showInfo(title, content, actions = []) {
|
||||||
|
|
|
||||||
|
|
@ -148,4 +148,63 @@ export class FamilyLayer extends BaseLayer {
|
||||||
features: filtered
|
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>
|
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ export DATABASE_NAME
|
||||||
rm -f $APP_PATH/tmp/pids/server.pid
|
rm -f $APP_PATH/tmp/pids/server.pid
|
||||||
|
|
||||||
# Sync static assets from image to volume
|
# 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
|
if [ -d "/tmp/public_assets" ]; then
|
||||||
echo "📦 Syncing new static assets to public volume..."
|
echo "📦 Syncing static assets to public volume..."
|
||||||
cp -rn /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
|
cp -ru /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
|
||||||
echo "✅ Static assets synced!"
|
echo "✅ Static assets synced!"
|
||||||
fi
|
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
|
namespace :demo do
|
||||||
desc 'Seed demo data: user, points from GeoJSON, visits, and areas'
|
desc 'Seed demo data: user, points from GeoJSON, visits, and areas'
|
||||||
task :seed_data, [:geojson_path] => :environment do |_t, args|
|
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)
|
unless File.exist?(geojson_path)
|
||||||
puts "Error: GeoJSON file not found at #{geojson_path}"
|
puts "Error: GeoJSON file not found at #{geojson_path}"
|
||||||
puts "Usage: rake demo:seed_data[path/to/file.geojson]"
|
puts 'Usage: rake demo:seed_data[path/to/file.geojson]'
|
||||||
puts "Or place file at tmp/demo_data.geojson"
|
puts 'Or place file at tmp/demo_data.geojson'
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "🚀 Starting demo data generation..."
|
puts '🚀 Starting demo data generation...'
|
||||||
puts "=" * 60
|
puts '=' * 60
|
||||||
|
|
||||||
# 1. Create demo user
|
# 1. Create demo user
|
||||||
puts "\n📝 Creating demo user..."
|
puts "\n📝 Creating demo user..."
|
||||||
|
|
@ -25,7 +25,7 @@ namespace :demo do
|
||||||
user.save!
|
user.save!
|
||||||
user.update!(status: :active, active_until: 1000.years.from_now)
|
user.update!(status: :active, active_until: 1000.years.from_now)
|
||||||
puts "✅ User created: #{user.email}"
|
puts "✅ User created: #{user.email}"
|
||||||
puts " Password: password"
|
puts ' Password: password'
|
||||||
puts " API Key: #{user.api_key}"
|
puts " API Key: #{user.api_key}"
|
||||||
else
|
else
|
||||||
puts "ℹ️ User already exists: #{user.email}"
|
puts "ℹ️ User already exists: #{user.email}"
|
||||||
|
|
@ -53,7 +53,7 @@ namespace :demo do
|
||||||
points_count = Point.where(user_id: user.id).count
|
points_count = Point.where(user_id: user.id).count
|
||||||
|
|
||||||
if points_count.zero?
|
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
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -72,9 +72,14 @@ namespace :demo do
|
||||||
created_areas = create_areas(user, 10)
|
created_areas = create_areas(user, 10)
|
||||||
puts "✅ Created #{created_areas} areas"
|
puts "✅ Created #{created_areas} areas"
|
||||||
|
|
||||||
puts "\n" + "=" * 60
|
# 6. Create family with members
|
||||||
puts "🎉 Demo data generation complete!"
|
puts "\n👨👩👧👦 Creating demo family..."
|
||||||
puts "=" * 60
|
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 "\n📊 Summary:"
|
||||||
puts " User: #{user.email}"
|
puts " User: #{user.email}"
|
||||||
puts " Points: #{Point.where(user_id: user.id).count}"
|
puts " Points: #{Point.where(user_id: user.id).count}"
|
||||||
|
|
@ -82,9 +87,14 @@ namespace :demo do
|
||||||
puts " Suggested Visits: #{user.visits.suggested.count}"
|
puts " Suggested Visits: #{user.visits.suggested.count}"
|
||||||
puts " Confirmed Visits: #{user.visits.confirmed.count}"
|
puts " Confirmed Visits: #{user.visits.confirmed.count}"
|
||||||
puts " Areas: #{user.areas.count}"
|
puts " Areas: #{user.areas.count}"
|
||||||
|
puts " Family Members: #{family_members.count}"
|
||||||
puts "\n🔐 Login credentials:"
|
puts "\n🔐 Login credentials:"
|
||||||
puts " Email: demo@dawarich.app"
|
puts ' Email: demo@dawarich.app'
|
||||||
puts " Password: password"
|
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
|
end
|
||||||
|
|
||||||
def create_visits(user, count, status)
|
def create_visits(user, count, status)
|
||||||
|
|
@ -142,11 +152,11 @@ namespace :demo do
|
||||||
|
|
||||||
# Find nearby points within 100 meters and associate them
|
# Find nearby points within 100 meters and associate them
|
||||||
nearby_points = Point.where(user_id: user.id)
|
nearby_points = Point.where(user_id: user.id)
|
||||||
.where.not(id: point.id)
|
.where.not(id: point.id)
|
||||||
.where.not(id: used_point_ids)
|
.where.not(id: used_point_ids)
|
||||||
.where('timestamp BETWEEN ? AND ?', started_at.to_i, ended_at.to_i)
|
.where('timestamp BETWEEN ? AND ?', started_at.to_i, ended_at.to_i)
|
||||||
.select { |p| distance_between(point, p) < 100 }
|
.select { |p| distance_between(point, p) < 100 }
|
||||||
.first(10)
|
.first(10)
|
||||||
|
|
||||||
nearby_points.each do |nearby_point|
|
nearby_points.each do |nearby_point|
|
||||||
nearby_point.update!(visit: visit)
|
nearby_point.update!(visit: visit)
|
||||||
|
|
@ -154,10 +164,10 @@ namespace :demo do
|
||||||
end
|
end
|
||||||
|
|
||||||
created_count += 1
|
created_count += 1
|
||||||
print "." if (index + 1) % 10 == 0
|
print '.' if (index + 1) % 10 == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "" if created_count > 0
|
puts '' if created_count > 0
|
||||||
created_count
|
created_count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -192,8 +202,10 @@ namespace :demo do
|
||||||
|
|
||||||
def distance_between(point1, point2)
|
def distance_between(point1, point2)
|
||||||
# Haversine formula to calculate distance in meters
|
# Haversine formula to calculate distance in meters
|
||||||
lat1, lon1 = point1.lat, point1.lon
|
lat1 = point1.lat
|
||||||
lat2, lon2 = point2.lat, point2.lon
|
lon1 = point1.lon
|
||||||
|
lat2 = point2.lat
|
||||||
|
lon2 = point2.lon
|
||||||
|
|
||||||
rad_per_deg = Math::PI / 180
|
rad_per_deg = Math::PI / 180
|
||||||
rkm = 6371 # Earth radius in kilometers
|
rkm = 6371 # Earth radius in kilometers
|
||||||
|
|
@ -210,4 +222,103 @@ namespace :demo do
|
||||||
|
|
||||||
rm * c # Distance in meters
|
rm * c # Distance in meters
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue