From 1395efa389ed0d851ad4a327075d2ada2b4ca564 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 7 Aug 2025 11:52:31 -0400 Subject: [PATCH] Refactor Location and Visit types: Replace visits structure in Location with Visit type and add location, created_at, and updated_at fields to Visit --- backend/server/adventures/permissions.py | 10 + backend/server/adventures/serializers.py | 36 +- backend/server/adventures/urls.py | 1 + backend/server/adventures/views/__init__.py | 3 +- .../server/adventures/views/activity_view.py | 31 +- backend/server/adventures/views/trail_view.py | 34 +- backend/server/adventures/views/visit_view.py | 66 + .../lib/components/CategoryDropdown.svelte | 3 + .../src/lib/components/LocationModal.svelte | 1219 ------------- .../lib/components/NewLocationModal.svelte | 1 - .../locations/LocationVisits.svelte | 1543 ++++++++--------- frontend/src/lib/types.ts | 12 +- 12 files changed, 881 insertions(+), 2078 deletions(-) create mode 100644 backend/server/adventures/views/visit_view.py delete mode 100644 frontend/src/lib/components/LocationModal.svelte diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 11aec80..a54e8dd 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -109,6 +109,9 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): is_safe_method = request.method in permissions.SAFE_METHODS # If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment. + + print("Checking permissions for object", obj, "of type", type(obj).__name__) + if type(obj).__name__ == 'Trail': obj = obj.location @@ -117,6 +120,13 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): if hasattr(obj, 'visit') and hasattr(obj.visit, 'location'): obj = obj.visit.location + + if type(obj).__name__ == 'Visit': + print("Checking permissions for Visit object", obj) + # If the object is a Visit, get its location + if hasattr(obj, 'location'): + obj = obj.location + # Anonymous users only get read access to public objects if not user or not user.is_authenticated: return is_safe_method and getattr(obj, 'is_public', False) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 5f9073d..e2ba426 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -215,8 +215,13 @@ class VisitSerializer(serializers.ModelSerializer): class Meta: model = Visit - fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities'] - read_only_fields = ['id'] + fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities','location', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def create(self, validated_data): + if not validated_data.get('end_date') and validated_data.get('start_date'): + validated_data['end_date'] = validated_data['start_date'] + return super().create(validated_data) class LocationSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() @@ -358,16 +363,11 @@ class LocationSerializer(CustomModelSerializer): return obj.is_visited_status() def create(self, validated_data): - visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) collections_data = validated_data.pop('collections', []) print(category_data) location = Location.objects.create(**validated_data) - - # Handle visits - for visit_data in visits_data: - Visit.objects.create(location=location, **visit_data) # Handle category if category_data: @@ -384,7 +384,6 @@ class LocationSerializer(CustomModelSerializer): def update(self, instance, validated_data): has_visits = 'visits' in validated_data - visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) collections_data = validated_data.pop('collections', None) @@ -405,27 +404,6 @@ class LocationSerializer(CustomModelSerializer): if collections_data is not None: instance.collections.set(collections_data) - # Handle visits - if has_visits: - current_visits = instance.visits.all() - current_visit_ids = set(current_visits.values_list('id', flat=True)) - - updated_visit_ids = set() - for visit_data in visits_data: - visit_id = visit_data.get('id') - if visit_id and visit_id in current_visit_ids: - visit = current_visits.get(id=visit_id) - for attr, value in visit_data.items(): - setattr(visit, attr, value) - visit.save() - updated_visit_ids.add(visit_id) - else: - new_visit = Visit.objects.create(location=instance, **visit_data) - updated_visit_ids.add(new_visit.id) - - visits_to_delete = current_visit_ids - updated_visit_ids - instance.visits.filter(id__in=visits_to_delete).delete() - # call save on the location to update the updated_at field and trigger any geocoding instance.save() diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 71a117b..fdd680d 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -22,6 +22,7 @@ router.register(r'recommendations', RecommendationsViewSet, basename='recommenda router.register(r'backup', BackupViewSet, basename='backup') router.register(r'trails', TrailViewSet, basename='trails') router.register(r'activities', ActivityViewSet, basename='activities') +router.register(r'visits', VisitViewSet, basename='visits') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 8f5e4ab..c13d73d 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -16,4 +16,5 @@ from .lodging_view import * from .recommendations_view import * from .import_export_view import * from .trail_view import * -from .activity_view import * \ No newline at end of file +from .activity_view import * +from .visit_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/activity_view.py b/backend/server/adventures/views/activity_view.py index 24acedb..01a84ab 100644 --- a/backend/server/adventures/views/activity_view.py +++ b/backend/server/adventures/views/activity_view.py @@ -3,6 +3,7 @@ from django.db.models import Q from adventures.models import Location, Activity from adventures.serializers import ActivitySerializer from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied class ActivityViewSet(viewsets.ModelViewSet): serializer_class = ActivitySerializer @@ -37,4 +38,32 @@ class ActivityViewSet(viewsets.ModelViewSet): """ Set the user when creating an activity. """ - serializer.save(user=self.request.user) \ No newline at end of file + visit = serializer.validated_data.get('visit') + location = visit.location if visit else None + + if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to add an activity to this location.") + + serializer.save(user=self.request.user) + + def perform_update(self, serializer): + instance = serializer.instance + new_visit = serializer.validated_data.get('visit') + + # Prevent changing visit/location after creation + if new_visit and new_visit != instance.visit: + raise PermissionDenied("Cannot change activity visit after creation. Create a new activity instead.") + + # Check permission for updates to the existing location + location = instance.visit.location if instance.visit else None + if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to update this activity.") + + serializer.save() + + def perform_destroy(self, instance): + location = instance.visit.location if instance.visit else None + if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to delete this activity.") + + instance.delete() \ No newline at end of file diff --git a/backend/server/adventures/views/trail_view.py b/backend/server/adventures/views/trail_view.py index c1d2383..65e573f 100644 --- a/backend/server/adventures/views/trail_view.py +++ b/backend/server/adventures/views/trail_view.py @@ -3,6 +3,7 @@ from django.db.models import Q from adventures.models import Location, Trail from adventures.serializers import TrailSerializer from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied class TrailViewSet(viewsets.ModelViewSet): serializer_class = TrailSerializer @@ -34,7 +35,32 @@ class TrailViewSet(viewsets.ModelViewSet): return Trail.objects.filter(location_filter).distinct() def perform_create(self, serializer): - """ - Set the user when creating a trail. - """ - serializer.save(user=self.request.user) \ No newline at end of file + location = serializer.validated_data.get('location') + + # Optional: import this if not in the same file + # from adventures.permissions import IsOwnerOrSharedWithFullAccess + + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to add a trail to this location.") + + serializer.save(user=self.request.user) + + def perform_update(self, serializer): + instance = serializer.instance + new_location = serializer.validated_data.get('location') + + # Prevent changing location after creation + if new_location and new_location != instance.location: + raise PermissionDenied("Cannot change trail location after creation. Create a new trail instead.") + + # Check permission for updates to the existing location + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to update this trail.") + + serializer.save() + + def perform_destroy(self, instance): + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to delete this trail.") + + instance.delete() \ No newline at end of file diff --git a/backend/server/adventures/views/visit_view.py b/backend/server/adventures/views/visit_view.py new file mode 100644 index 0000000..c0ae171 --- /dev/null +++ b/backend/server/adventures/views/visit_view.py @@ -0,0 +1,66 @@ +from rest_framework import viewsets +from django.db.models import Q +from adventures.models import Location, Visit +from adventures.serializers import VisitSerializer +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied + +class VisitViewSet(viewsets.ModelViewSet): + serializer_class = VisitSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def get_queryset(self): + """ + Returns visits based on location permissions. + Users can only see visits in locations they have access to for editing/updating/deleting. + This means they are either: + - The owner of the location + - The location is in a collection that is shared with the user + - The location is in a collection that the user owns + """ + user = self.request.user + + if not user or not user.is_authenticated: + return Visit.objects.none() + + # Build the filter for accessible locations + location_filter = Q(location__user=user) # User owns the location + + # Location is in collections (many-to-many) that are shared with user + location_filter |= Q(location__collections__shared_with=user) + + # Location is in collections (many-to-many) that user owns + location_filter |= Q(location__collections__user=user) + + return Visit.objects.filter(location_filter).distinct() + + def perform_create(self, serializer): + """ + Set the user when creating a visit and check permissions. + """ + location = serializer.validated_data.get('location') + + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to add a visit to this location.") + + serializer.save() + + def perform_update(self, serializer): + instance = serializer.instance + new_location = serializer.validated_data.get('location') + + # Prevent changing location after creation + if new_location and new_location != instance.location: + raise PermissionDenied("Cannot change visit location after creation. Create a new visit instead.") + + # Check permission for updates to the existing location + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to update this visit.") + + serializer.save() + + def perform_destroy(self, instance): + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to delete this visit.") + + instance.delete() \ No newline at end of file diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index 381db3f..67a4db9 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -39,6 +39,9 @@ function custom_category() { new_category.name = new_category.display_name.toLowerCase().replace(/ /g, '_'); + if (!new_category.icon) { + new_category.icon = '🌎'; // Default icon if none selected + } selectCategory(new_category); } diff --git a/frontend/src/lib/components/LocationModal.svelte b/frontend/src/lib/components/LocationModal.svelte deleted file mode 100644 index 9be16e8..0000000 --- a/frontend/src/lib/components/LocationModal.svelte +++ /dev/null @@ -1,1219 +0,0 @@ - - - - - - - - diff --git a/frontend/src/lib/components/NewLocationModal.svelte b/frontend/src/lib/components/NewLocationModal.svelte index f83f4a9..d5a14a0 100644 --- a/frontend/src/lib/components/NewLocationModal.svelte +++ b/frontend/src/lib/components/NewLocationModal.svelte @@ -298,7 +298,6 @@ bind:visits={location.visits} bind:trails={location.trails} objectId={location.id} - type="location" on:back={() => { steps[3].selected = false; steps[2].selected = true; diff --git a/frontend/src/lib/components/locations/LocationVisits.svelte b/frontend/src/lib/components/locations/LocationVisits.svelte index f7e9711..1b699cb 100644 --- a/frontend/src/lib/components/locations/LocationVisits.svelte +++ b/frontend/src/lib/components/locations/LocationVisits.svelte @@ -36,13 +36,12 @@ // Props export let collection: Collection | null = null; - export let type: 'location' | 'transportation' | 'lodging' = 'location'; export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; export let selectedEndTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; export let utcStartDate: string | null = null; export let utcEndDate: string | null = null; export let note: string | null = null; - export let visits: (Visit | TransportationVisit)[] | null = null; + export let visits: Visit[] | null = null; export let objectId: string; export let trails: Trail[] = []; export let measurementSystem: 'metric' | 'imperial' = 'metric'; @@ -59,6 +58,7 @@ let fullEndDate: string = ''; let constrainDates: boolean = false; let isEditing = false; + let visitIdEditing: string | null = null; // Activity management state let stravaEnabled: boolean = false; @@ -116,6 +116,15 @@ 'Other' ]; + function getTypeConfig() { + return { + startLabel: 'Start Date', + endLabel: 'End Date', + icon: CalendarIcon, + color: 'primary' + }; + } + // Reactive constraints $: constraintStartDate = allDay ? fullStartDate && fullStartDate.includes('T') @@ -147,7 +156,7 @@ const end = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; localStartDate = start; @@ -193,32 +202,6 @@ return 0; } - function getTypeConfig() { - switch (type) { - case 'transportation': - return { - startLabel: 'Departure Date', - endLabel: 'Arrival Date', - icon: MapMarkerIcon, - color: 'accent' - }; - case 'lodging': - return { - startLabel: 'Check In', - endLabel: 'Check Out', - icon: CalendarIcon, - color: 'secondary' - }; - default: - return { - startLabel: 'Start Date', - endLabel: 'End Date', - icon: CalendarIcon, - color: 'primary' - }; - } - } - // Event handlers function handleLocalDateChange() { utcStartDate = updateUTCDate({ @@ -229,7 +212,7 @@ utcEndDate = updateUTCDate({ localDate: localEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone, + timezone: selectedEndTimezone, allDay }).utcDate; } @@ -251,7 +234,7 @@ utcEndDate = updateUTCDate({ localDate: localEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone, + timezone: selectedEndTimezone, allDay }).utcDate; @@ -262,74 +245,59 @@ localEndDate = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; } - function createVisitObject(): Visit | TransportationVisit { - const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2); - - if (type === 'transportation') { - const transportVisit: TransportationVisit = { - id: uniqueId, - start_date: utcStartDate ?? '', - end_date: utcEndDate ?? utcStartDate ?? '', - notes: note ?? '', - start_timezone: selectedStartTimezone, - end_timezone: selectedEndTimezone, - activities: [] - }; - return transportVisit; - } else { - const regularVisit: Visit = { - id: uniqueId, - start_date: utcStartDate ?? '', - end_date: utcEndDate ?? utcStartDate ?? '', - notes: note ?? '', - timezone: selectedStartTimezone, - activities: [] - }; - return regularVisit; - } - } - async function addVisit() { - const newVisit = createVisitObject(); + // If editing an existing visit, patch instead of creating new + if (visitIdEditing) { + const response = await fetch(`/api/visits/${visitIdEditing}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + start_date: utcStartDate, + end_date: utcEndDate, + notes: note, + timezone: selectedStartTimezone + }) + }); - // Patch updated visits array to location and get the response with actual IDs - console.log('Adding new visit:', newVisit); - console.log(objectId); - if (type === 'location' && objectId) { - try { - const updatedVisits = visits ? [...visits, newVisit] : [newVisit]; - console.log('Patching visits:', updatedVisits); - - const response = await fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits: updatedVisits }) - }); - - if (response.ok) { - const updatedLocation = await response.json(); - // Update visits with the response data that contains actual IDs - visits = updatedLocation.visits; - } else { - console.error('Failed to patch visits:', await response.text()); - return; // Don't update local state if API call failed - } - } catch (error) { - console.error('Error patching visits:', error); - return; // Don't update local state if API call failed + if (response.ok) { + const updatedVisit: Visit = await response.json(); + visits = visits ? [...visits, updatedVisit] : [updatedVisit]; + dispatch('visitAdded', updatedVisit); + visitIdEditing = null; + } else { + const errorText = await response.text(); + alert(`Failed to update visit: ${errorText}`); } } else { - // Fallback for non-location types - add new visit to the visits array - if (visits) { - visits = [...visits, newVisit]; + // post to /api/visits for new visit + const response = await fetch('/api/visits/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + object_id: objectId, + start_date: utcStartDate, + end_date: utcEndDate, + notes: note, + timezone: selectedStartTimezone, + location: objectId + }) + }); + + if (response.ok) { + const newVisit: Visit = await response.json(); + visits = visits ? [...visits, newVisit] : [newVisit]; + dispatch('visitAdded', newVisit); } else { - visits = [newVisit]; + const errorText = await response.text(); + alert(`Failed to add visit: ${errorText}`); } } @@ -581,27 +549,13 @@ if (response.ok) { // Refetch the location data to get the updated visits with correct IDs - if (type === 'location' && objectId) { - const locationResponse = await fetch(`/api/locations/${objectId}/`); - if (locationResponse.ok) { - const updatedLocation = await locationResponse.json(); - visits = updatedLocation.visits; - } else { - console.error('Failed to refetch location data:', await locationResponse.text()); - } + + const locationResponse = await fetch(`/api/locations/${objectId}/`); + if (locationResponse.ok) { + const updatedLocation = await locationResponse.json(); + visits = updatedLocation.visits; } else { - // Fallback: Update the visit's activities array locally - if (visits) { - visits = visits.map((visit) => { - if (visit.id === visitId) { - return { - ...visit, - activities: (visit.activities || []).filter((a) => a.id !== activityId) - }; - } - return visit; - }); - } + console.error('Failed to refetch location data:', await locationResponse.text()); } } else { console.error('Failed to delete activity:', await response.text()); @@ -661,14 +615,17 @@ } } - function editVisit(visit: Visit | TransportationVisit) { + function editVisit(visit: Visit) { isEditing = true; + visitIdEditing = visit.id; const isAllDayEvent = isAllDay(visit.start_date); allDay = isAllDayEvent; - if ('start_timezone' in visit) { + if ('start_timezone' in visit && typeof visit.start_timezone === 'string') { selectedStartTimezone = visit.start_timezone; - selectedEndTimezone = visit.end_timezone; + if ('end_timezone' in visit && typeof visit.end_timezone === 'string') { + selectedEndTimezone = visit.end_timezone; + } } else if (visit.timezone) { selectedStartTimezone = visit.timezone; } @@ -684,10 +641,14 @@ localEndDate = updateLocalDate({ utcDate: visit.end_date, - timezone: 'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone + timezone: + 'end_timezone' in visit && typeof visit.end_timezone === 'string' + ? visit.end_timezone + : selectedStartTimezone }).localDate; } + // Remove the visit from the array temporarily for editing if (visits) { visits = visits.filter((v) => v.id !== visit.id); } @@ -706,17 +667,6 @@ setTimeout(() => { isEditing = false; }, 0); - - // Update the visits array in the parent component - if (type === 'location' && objectId) { - fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits }) - }); - } } function removeVisit(visitId: string) { @@ -730,16 +680,17 @@ delete loadingActivities[visitId]; delete showActivityUpload[visitId]; - // Patch updated visits array to location - if (type === 'location' && objectId) { - fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits }) - }); - } + // make the DELETE request + fetch(`/api/visits/${visitId}/`, { + method: 'DELETE' + }).then((response) => { + if (!response.ok) { + alert('Failed to delete visit. Please try again.'); + } else { + // remove the visit from the local state + visits = visits?.filter((v) => v.id !== visitId) ?? null; + } + }); } function handleBack() { @@ -752,10 +703,6 @@ // Lifecycle onMount(async () => { - if ((type === 'transportation' || type === 'lodging') && utcStartDate) { - allDay = isAllDay(utcStartDate); - } - localStartDate = updateLocalDate({ utcDate: utcStartDate, timezone: selectedStartTimezone @@ -763,7 +710,7 @@ localEndDate = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; if (!selectedStartTimezone) { @@ -787,8 +734,8 @@ } }); - $: typeConfig = getTypeConfig(); $: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid; + $: typeConfig = getTypeConfig();
@@ -814,35 +761,13 @@
- {#if type === 'transportation'} -
-
- -
- -
-
-
- -
- -
-
+ +
+ +
+
- {:else} -
- -
- -
-
- {/if} +
@@ -941,31 +866,30 @@
- {#if type === 'location'} -
- - -
- -
- -
- {/if} +
+ + +
+ + +
+ +
@@ -977,668 +901,657 @@ {/if} - {#if type === 'location'} -
-

- Visits ({visits?.length || 0}) -

- {#if !visits || visits.length === 0} -
- -

No visits added yet

-

- Create your first visit by selecting dates above -

-
- {:else} -
- {#each visits as visit (visit.id)} -
-
-
-
+
+

+ Visits ({visits?.length || 0}) +

+ + {#if !visits || visits.length === 0} +
+ +

No visits added yet

+

+ Create your first visit by selecting dates above +

+
+ {:else} +
+ {#each visits as visit (visit.id)} +
+
+
+
+ {#if isAllDay(visit.start_date)} + All Day + {:else} + + {/if} +
{#if isAllDay(visit.start_date)} - All Day + {visit.start_date && typeof visit.start_date === 'string' + ? visit.start_date.split('T')[0] + : ''} + – {visit.end_date && typeof visit.end_date === 'string' + ? visit.end_date.split('T')[0] + : ''} + {:else if 'start_timezone' in visit && visit.timezone} + {formatDateInTimezone(visit.start_date, visit.timezone)} + – {formatDateInTimezone(visit.end_date, visit.timezone)} + {:else if visit.timezone} + {formatDateInTimezone(visit.start_date, visit.timezone)} + – {formatDateInTimezone(visit.end_date, visit.timezone)} {:else} - + {new Date(visit.start_date).toLocaleString()} + – {new Date(visit.end_date).toLocaleString()} {/if} -
- {#if isAllDay(visit.start_date)} - {visit.start_date && typeof visit.start_date === 'string' - ? visit.start_date.split('T')[0] - : ''} - – {visit.end_date && typeof visit.end_date === 'string' - ? visit.end_date.split('T')[0] - : ''} - {:else if 'start_timezone' in visit} - {formatDateInTimezone(visit.start_date, visit.start_timezone)} - – {formatDateInTimezone(visit.end_date, visit.end_timezone)} - {:else if visit.timezone} - {formatDateInTimezone(visit.start_date, visit.timezone)} - – {formatDateInTimezone(visit.end_date, visit.timezone)} - {:else} - {new Date(visit.start_date).toLocaleString()} - – {new Date(visit.end_date).toLocaleString()} - {/if} -
- - {#if visit.notes} -

- "{visit.notes}" -

- {/if} - - {#if visit.activities && visit.activities.length > 0} -
- - - {visit.activities.length} saved activities - -
- {/if}
- -
- - {#if stravaEnabled} - - {/if} + {#if visit.notes} +

+ "{visit.notes}" +

+ {/if} - - - - - -
+ {#if visit.activities && visit.activities.length > 0} +
+ + + {visit.activities.length} saved activities + +
+ {/if}
- - {#if showActivityUpload[visit.id]} -
-
-
- -

- {#if pendingStravaImport[visit.id]} - Complete Strava Import - {:else} - Add New Activity - {/if} -

-
- -
+ +
+ + {#if stravaEnabled} + + {/if} - {#if pendingStravaImport[visit.id]} -
-
- -
-
Strava Activity Ready
-
- GPX file downloaded. Please upload it below to complete the - import. -
+ + + + + +
+
+ + + {#if showActivityUpload[visit.id]} +
+
+
+ +

+ {#if pendingStravaImport[visit.id]} + Complete Strava Import + {:else} + Add New Activity + {/if} +

+
+ +
+ + {#if pendingStravaImport[visit.id]} +
+
+ +
+
Strava Activity Ready
+
+ GPX file downloaded. Please upload it below to complete the import.
+
+ {/if} + +
+ {#if pendingStravaImport[visit.id]} + +
+
+ + +
+
+ + +
+
+ Upload the GPX file that was just downloaded to complete the Strava + import +
+
{/if} -
- {#if pendingStravaImport[visit.id]} - -
+ +
+ -
- - -
-
- - -
-
- Upload the GPX file that was just downloaded to complete the Strava - import -
-
- {/if} + +
-
- + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + {#if trails && trails.length > 0}
- -
- - -
- Trail
+ {/if} - -
+ + {#if !pendingStravaImport[visit.id]} +
GPX File
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - - {#if type === 'location' && trails && trails.length > 0} -
- - -
- {/if} - - - {#if !pendingStravaImport[visit.id]} -
- - -
- {/if} -
- -
- - -
-
-
- {/if} - - - {#if visit.activities && visit.activities.length > 0} -
-
- -

- Saved Activities ({visit.activities.length}) -

-
- -
- {#each visit.activities as activity (activity.id)} - - deleteActivity(event.detail.visitId, event.detail.activityId)} - /> - {/each} -
-
- {/if} - - - {#if stravaEnabled && expandedVisits[visit.id]} -
-
- -

Strava Activities During Visit

- {#if loadingActivities[visit.id]} - {/if}
+
+ + +
+
+
+ {/if} + + + {#if visit.activities && visit.activities.length > 0} +
+
+ +

+ Saved Activities ({visit.activities.length}) +

+
+ +
+ {#each visit.activities as activity (activity.id)} + + deleteActivity(event.detail.visitId, event.detail.activityId)} + /> + {/each} +
+
+ {/if} + + + {#if stravaEnabled && expandedVisits[visit.id]} +
+
+ +

Strava Activities During Visit

{#if loadingActivities[visit.id]} -
-
-

Loading activities...

-
- {:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0} -
- {#each visitActivities[visit.id] as activity (activity.id)} -
- handleStravaActivityImport(event, visit.id)} - {measurementSystem} - /> -
- {/each} -
- {:else} -
-
🏃‍♂️
-

No Strava activities found during this visit

-
+ {/if}
- {/if} -
- {/each} -
- {/if} -
- {/if} + + {#if loadingActivities[visit.id]} +
+
+

Loading activities...

+
+ {:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0} +
+ {#each visitActivities[visit.id] as activity (activity.id)} +
+ handleStravaActivityImport(event, visit.id)} + {measurementSystem} + /> +
+ {/each} +
+ {:else} +
+
🏃‍♂️
+

No Strava activities found during this visit

+
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6a131ab..87e5431 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -31,14 +31,7 @@ export type Location = { rating?: number | null; link?: string | null; images: ContentImage[]; - visits: { - id: string; - start_date: string; - end_date: string; - timezone: string | null; - notes: string; - activities: Activity[]; // Array of activities associated with the visit - }[]; + visits: Visit[]; collections?: string[] | null; latitude: number | null; longitude: number | null; @@ -425,6 +418,9 @@ export type Visit = { notes: string; timezone: string | null; activities?: Activity[]; + location: string; + created_at: string; + updated_at: string; }; export type TransportationVisit = {