diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index a728df0..db01245 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -7,6 +7,8 @@ from users.serializers import CustomUserDetailsSerializer from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer from geopy.distance import geodesic from integrations.models import ImmichIntegration +import gpxpy +import geojson class ContentImageSerializer(CustomModelSerializer): @@ -107,6 +109,7 @@ class TrailSerializer(CustomModelSerializer): return 'External Link' class ActivitySerializer(CustomModelSerializer): + geojson = serializers.SerializerMethodField() class Meta: model = Activity @@ -115,7 +118,7 @@ class ActivitySerializer(CustomModelSerializer): 'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain', 'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local', 'timezone', 'average_speed', 'max_speed', 'average_cadence', 'calories', - 'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id' + 'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id', 'geojson' ] read_only_fields = ['id', 'user'] @@ -125,9 +128,33 @@ class ActivitySerializer(CustomModelSerializer): public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "") representation['gpx_file'] = f"{public_url}/media/{instance.gpx_file.name}" return representation - + def get_geojson(self, obj): + if not obj.gpx_file: + return None + + try: + with obj.gpx_file.open('r') as f: + gpx = gpxpy.parse(f) + + features = [] + for track in gpx.tracks: + for segment in track.segments: + coords = [(pt.longitude, pt.latitude) for pt in segment.points] + if not coords: + continue + features.append(geojson.Feature( + geometry=geojson.LineString(coords), + properties={"name": track.name or "GPX Track"} + )) + + return geojson.FeatureCollection(features) + + except Exception as e: + return {"error": str(e)} + class VisitSerializer(serializers.ModelSerializer): + activities = ActivitySerializer(many=True, read_only=True, required=False) class Meta: diff --git a/frontend/src/lib/components/ActivityCard.svelte b/frontend/src/lib/components/ActivityCard.svelte index 70eb01e..7b36c3c 100644 --- a/frontend/src/lib/components/ActivityCard.svelte +++ b/frontend/src/lib/components/ActivityCard.svelte @@ -11,6 +11,8 @@ export let trails: Trail[]; export let visit: Visit | TransportationVisit; + export let readOnly: boolean = false; + $: trail = activity.trail ? trails.find((t) => t.id === activity.trail) : null; console.log(activity.trail, trails, trail); @@ -75,12 +77,14 @@ - + {#if !readOnly} + + {/if} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 48d7ea9..d2ca0c2 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -389,6 +389,7 @@ export type Activity = { visit: string; trail: string | null; gpx_file: string | null; + geojson: any | undefined; // GeoJSON representation of the activity name: string; type: string; sport_type: string | null; diff --git a/frontend/src/routes/locations/[id]/+page.svelte b/frontend/src/routes/locations/[id]/+page.svelte index f7f0178..ccbf96b 100644 --- a/frontend/src/routes/locations/[id]/+page.svelte +++ b/frontend/src/routes/locations/[id]/+page.svelte @@ -20,6 +20,7 @@ import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import AttachmentCard from '$lib/components/AttachmentCard.svelte'; import { getBasemapUrl, isAllDay } from '$lib'; + import ActivityCard from '$lib/components/ActivityCard.svelte'; let geojson: any; @@ -74,6 +75,46 @@ } }); } + + // for every location.visit there is an array of activitites which might have a field .geojson with a geojson object + if (adventure.visits && adventure.visits.length > 0) { + adventure.visits.forEach((visit) => { + if (visit.activities && visit.activities.length > 0) { + visit.activities.forEach((activity) => { + if (activity.geojson) { + // Ensure geojson object exists and has features array + if (!geojson || !geojson.features) { + geojson = { + type: 'FeatureCollection', + features: [] + }; + } + + // Extract features from the activity's FeatureCollection + if (activity.geojson.features && Array.isArray(activity.geojson.features)) { + activity.geojson.features.forEach((feature: { properties: any }) => { + // Add activity metadata to each feature's properties + const enhancedFeature = { + ...feature, + properties: { + ...feature.properties, + name: activity.name || 'Activity', + description: activity.type || '', + activityType: activity.type || 'unknown', + visitId: visit.id, + visitStartDate: visit.start_date, + visitEndDate: visit.end_date, + visitNotes: visit.notes || '' + } + }; + geojson.features.push(enhancedFeature); + }); + } + } + }); + } + }); + } } export let data: PageData; @@ -110,6 +151,50 @@ await getGpxFiles(); }); + function hasActivityGeojson(adventure: AdditionalLocation) { + return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson)); + } + + function getActivityColor(activityType: string) { + const colors: Record = { + Hike: '#10B981', + Run: '#F59E0B', + Bike: '#3B82F6', + Walk: '#8B5CF6', + default: '#6B7280' + }; + return colors[activityType] || colors.default; + } + + function getTotalActivities(adventure: AdditionalLocation) { + return adventure.visits.reduce( + (total, visit) => total + (visit.activities ? visit.activities.length : 0), + 0 + ); + } + + function getTotalDistance(adventure: AdditionalLocation) { + return adventure.visits.reduce( + (total, visit) => + total + + (visit.activities + ? visit.activities.reduce((sum, activity) => sum + (activity.distance || 0), 0) + : 0), + 0 + ); + } + + function getTotalElevationGain(adventure: AdditionalLocation) { + return adventure.visits.reduce( + (total, visit) => + total + + (visit.activities + ? visit.activities.reduce((sum, activity) => sum + (activity.elevation_gain || 0), 0) + : 0), + 0 + ); + } + async function saveEdit(event: CustomEvent) { adventure = event.detail; isEditModalOpen = false; @@ -157,7 +242,7 @@ {#if isImageModalOpen} @@ -242,6 +327,11 @@ {adventure.visits.length === 1 ? $t('adventures.visit') : $t('adventures.visits')} {/if} + {#if adventure.trails && adventure.trails.length > 0} +
+ πŸ₯Ύ {adventure.trails.length} Trail{adventure.trails.length === 1 ? '' : 's'} +
+ {/if} @@ -382,13 +472,6 @@ ? `🌍 ${$t('adventures.public')}` : `πŸ”’ ${$t('adventures.private')}`} - {#if adventure.collections && adventure.collections.length > 0}
πŸ“š @@ -414,6 +497,46 @@
{/if} + + {#if adventure.trails && adventure.trails.length > 0} +
+
+

πŸ₯Ύ {$t('adventures.trails')}

+
+ {#each adventure.trails as trail} +
+
+
+
+

{trail.name}

+
+ {#if trail.provider} + {trail.provider} + {/if} + + Created: {new Date(trail.created_at).toLocaleDateString()} + +
+
+ {#if trail.link} + + πŸ”— View Trail + + {/if} +
+
+
+ {/each} +
+
+
+ {/if} + {#if adventure.visits.length > 0}
@@ -429,7 +552,7 @@ {/if}
-
+
{#if isAllDay(visit.start_date)}
@@ -476,6 +599,25 @@

"{visit.notes}"

{/if} + + + {#if visit.activities && visit.activities.length > 0} +
+

+ πŸƒβ€β™‚οΈ Activities ({visit.activities.length}) +

+
+ {#each visit.activities as activity} + + {/each} +
+
+ {/if}
@@ -487,7 +629,7 @@ {/if} - {#if (adventure.longitude && adventure.latitude) || geojson} + {#if (adventure.longitude && adventure.latitude) || geojson || hasActivityGeojson(adventure)}

πŸ—ΊοΈ {$t('adventures.location')}

@@ -561,7 +703,16 @@ class="btn btn-xs btn-outline hover:btn-success" on:click={() => goto(`/worldtravel/${adventure.country?.country_code}`)} > - 🌎 {adventure.country.name} + {#if adventure.country.flag_url} + {adventure.country.name} + {:else} + 🌎 + {/if} + {adventure.country.name} {/if}
@@ -639,6 +790,23 @@ {/if} + + {#each adventure.visits as visit} + {#each visit.activities as activity} + {#if activity.geojson} + + + + {/if} + {/each} + {/each} + {#if adventure.longitude && adventure.latitude} @@ -699,6 +867,37 @@
+ + {#if getTotalActivities(adventure) > 0} +
+
+

πŸƒβ€β™‚οΈ Activity Summary

+
+
+
Total Activities
+
{getTotalActivities(adventure)}
+
+ {#if getTotalDistance(adventure) > 0} +
+
Total Distance
+
+ {getTotalDistance(adventure).toFixed(1)} km +
+
+ {/if} + {#if getTotalElevationGain(adventure) > 0} +
+
Total Elevation
+
+ {getTotalElevationGain(adventure).toFixed(0)} m +
+
+ {/if} +
+
+
+ {/if} + {#if adventure.sun_times && adventure.sun_times.length > 0}