diff --git a/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py b/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py new file mode 100644 index 00000000..c67e928f --- /dev/null +++ b/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.8 on 2026-01-06 16:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0070_collectionitineraryday'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='collectionitineraryitem', + unique_together=set(), + ), + migrations.AddField( + model_name='collectionitineraryitem', + name='is_global', + field=models.BooleanField(default=False, help_text='Applies to the whole trip (no specific date)'), + ), + migrations.AddConstraint( + model_name='collectionitineraryitem', + constraint=models.UniqueConstraint(condition=models.Q(('is_global', False), ('date__isnull', False)), fields=('collection', 'date', 'order'), name='unique_order_per_collection_day'), + ), + migrations.AddConstraint( + model_name='collectionitineraryitem', + constraint=models.UniqueConstraint(condition=models.Q(('is_global', True)), fields=('collection', 'order'), name='unique_order_per_collection_global'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 2e20c3a8..bdeaeac9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -15,6 +15,7 @@ from adventures.utils.timezones import TIMEZONES from adventures.utils.sports_types import SPORT_TYPE_CHOICES from adventures.utils.get_is_visited import is_location_visited from django.contrib.contenttypes.fields import GenericForeignKey +from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericRelation @@ -721,17 +722,45 @@ class CollectionItineraryItem(models.Model): item = GenericForeignKey("content_type", "object_id") # Placement (planning concern, not content concern) + # Either a specific date or marked as trip-wide (global). Exactly one of these applies. date = models.DateField(blank=True, null=True) + is_global = models.BooleanField(default=False, help_text="Applies to the whole trip (no specific date)") order = models.PositiveIntegerField(help_text="Manual order within a day") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["date", "order"] - unique_together = ("collection", "date", "order") + constraints = [ + # Ensure unique order per day for dated items + models.UniqueConstraint( + fields=["collection", "date", "order"], + name="unique_order_per_collection_day", + condition=Q(is_global=False) & Q(date__isnull=False), + ), + # Ensure unique order within the global group for a collection + models.UniqueConstraint( + fields=["collection", "order"], + name="unique_order_per_collection_global", + condition=Q(is_global=True), + ), + ] def __str__(self): - return f"{self.collection.name} - {self.content_type.model} - {self.date} ({self.order})" + scope = "GLOBAL" if self.is_global else str(self.date) + return f"{self.collection.name} - {self.content_type.model} - {scope} ({self.order})" + + def clean(self): + # Enforce XOR between date and is_global + if self.is_global and self.date is not None: + raise ValidationError({ + "is_global": "Global items must not have a date.", + "date": "Provide either a date or set is_global, not both.", + }) + if (not self.is_global) and self.date is None: + raise ValidationError({ + "date": "Dated items must include a date. To create a trip-wide item, set is_global=true.", + }) @property def start_datetime(self): diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index ef241931..05435739 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1042,7 +1042,7 @@ class CollectionItineraryItemSerializer(CustomModelSerializer): class Meta: model = CollectionItineraryItem - fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name'] + fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'is_global', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name'] read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name'] def update(self, instance, validated_data): diff --git a/backend/server/adventures/utils/itinerary.py b/backend/server/adventures/utils/itinerary.py index 4769ded3..0c476f27 100644 --- a/backend/server/adventures/utils/itinerary.py +++ b/backend/server/adventures/utils/itinerary.py @@ -74,8 +74,14 @@ def reorder_itinerary_items(user, items_data: List[dict]): continue new_date = item_data.get('date') + new_is_global = item_data.get('is_global') new_order = item_data.get('order') - if new_date is not None: + # If is_global is explicitly provided, set it and reconcile date accordingly + if new_is_global is not None: + item.is_global = bool(new_is_global) + if item.is_global: + item.date = None + if (new_date is not None) and (not item.is_global): # validate date is within collection bounds (if collection has start/end) parsed = None try: @@ -104,6 +110,6 @@ def reorder_itinerary_items(user, items_data: List[dict]): updated_items.append(item) if updated_items: - CollectionItineraryItem.objects.bulk_update(updated_items, ['date', 'order']) + CollectionItineraryItem.objects.bulk_update(updated_items, ['date', 'is_global', 'order']) return updated_items diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 5fb4c0c7..191d118b 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -339,6 +339,7 @@ class BackupViewSet(viewsets.ViewSet): 'content_type': content_type_str, 'item_reference': item_reference, 'date': itinerary_item.date.isoformat() if itinerary_item.date else None, + 'is_global': itinerary_item.is_global, 'order': itinerary_item.order }) @@ -922,7 +923,8 @@ class BackupViewSet(viewsets.ViewSet): collection=collection, content_type=content_type, object_id=content_object.id, - date=itinerary_data.get('date'), + date=itinerary_data.get('date') if not itinerary_data.get('is_global') else None, + is_global=bool(itinerary_data.get('is_global', False)), order=itinerary_data['order'] ) summary['itinerary_items'] += 1 diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index e6676997..9ca0b127 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -49,6 +49,11 @@ class ItineraryViewSet(viewsets.ModelViewSet): object_id = data.get('object_id') update_item_date = data.get('update_item_date', False) target_date = data.get('date') + is_global = data.get('is_global', False) + # Normalize is_global to boolean + if isinstance(is_global, str): + is_global = is_global.lower() in ['1', 'true', 'yes'] + data['is_global'] = is_global # Support legacy field 'location' -> treat as content_type='location' if not content_type_val and data.get('location'): @@ -174,14 +179,20 @@ class ItineraryViewSet(viewsets.ModelViewSet): setattr(content_object, date_field, clean_date) content_object.save(update_fields=[date_field]) - # Ensure order is unique for this collection+date combination + # Ensure order is unique for this collection+group combination (day or global) collection_id = data.get('collection') item_date = data.get('date') item_order = data.get('order', 0) + # Basic XOR validation between date and is_global + if is_global and item_date: + return Response({'error': 'Global itinerary items must not include a date.'}, status=status.HTTP_400_BAD_REQUEST) + if (not is_global) and not item_date: + return Response({'error': 'Dated itinerary items must include a date.'}, status=status.HTTP_400_BAD_REQUEST) + # Validate that the itinerary date (if provided) falls within the # collection's start_date/end_date range (if those bounds are set). - if collection_id and item_date: + if collection_id and item_date and not is_global: # Try parse date or datetime-like values parsed_date = None try: @@ -207,17 +218,29 @@ class ItineraryViewSet(viewsets.ModelViewSet): if collection_obj.end_date and parsed_date > collection_obj.end_date: return Response({'error': 'Itinerary item date is after the collection end_date'}, status=status.HTTP_400_BAD_REQUEST) - if collection_id and item_date: - # Find the maximum order for this collection+date - existing_max = CollectionItineraryItem.objects.filter( - collection_id=collection_id, - date=item_date - ).aggregate(max_order=models.Max('order'))['max_order'] - - # Check if the requested order conflicts with existing items - if existing_max is not None and item_order <= existing_max: - # Assign next available order - data['order'] = existing_max + 1 + if collection_id: + if is_global: + # Max order within global group + existing_max = CollectionItineraryItem.objects.filter( + collection_id=collection_id, + is_global=True + ).aggregate(max_order=models.Max('order'))['max_order'] + if existing_max is None: + existing_max = -1 + if item_order is None or item_order <= existing_max: + data['order'] = existing_max + 1 + elif item_date: + # Find the maximum order for this collection+date + existing_max = CollectionItineraryItem.objects.filter( + collection_id=collection_id, + date=item_date, + is_global=False + ).aggregate(max_order=models.Max('order'))['max_order'] + + # Check if the requested order conflicts with existing items + if existing_max is not None and item_order <= existing_max: + # Assign next available order + data['order'] = existing_max + 1 # Proceed with normal serializer flow using modified data serializer = self.get_serializer(data=data) diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 29a1f8fc..871bd274 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -55,6 +55,11 @@ $: days = groupItemsByDay(collection); $: unscheduledItems = getUnscheduledItems(collection); + // Trip-wide (global) itinerary items + $: globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); // Auto-generate state let isAutoGenerating = false; @@ -605,6 +610,29 @@ days = [...days]; } + function handleDndConsiderGlobal(e: CustomEvent) { + const { items: newItems } = e.detail; + globalItems = newItems; + } + + async function handleDndFinalizeGlobal(e: CustomEvent) { + const { items: newItems, info } = e.detail; + globalItems = newItems; + if ( + info.trigger === TRIGGERS.DROPPED_INTO_ZONE || + info.trigger === TRIGGERS.DROPPED_INTO_ANOTHER + ) { + if (!isSavingOrder) { + isSavingOrder = true; + try { + await saveReorderedItems(); + } finally { + isSavingOrder = false; + } + } + } + } + async function handleDndFinalize(dayIndex: number, e: CustomEvent) { const { items: newItems, info } = e.detail; @@ -635,7 +663,7 @@ async function saveReorderedItems() { try { // Collect all items across all days with their new positions - const itemsToUpdate = days.flatMap((day) => + const dayUpdates = days.flatMap((day) => day.items .filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]) .map((item, index) => ({ @@ -645,6 +673,12 @@ })) ); + const globalUpdates = globalItems + .filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]) + .map((item, index) => ({ id: item.id, is_global: true, date: null, order: index })); + + const itemsToUpdate = [...dayUpdates, ...globalUpdates]; + if (itemsToUpdate.length === 0) { return; } @@ -672,6 +706,7 @@ return { ...it, date: updatedItem.date, + is_global: updatedItem.is_global ?? it.is_global, order: updatedItem.order }; } @@ -685,6 +720,69 @@ } } + // Add a trip-wide (global) itinerary item + async function addGlobalItineraryItemForObject(objectType: string, objectId: string) { + const tempId = `temp-global-${Date.now()}`; + const order = globalItems.length; + + const newIt = { + id: tempId, + collection: collection.id, + content_type: objectType, + object_id: objectId, + item: { id: objectId, type: objectType }, + date: null, + is_global: true, + order, + created_at: new Date().toISOString() + }; + + collection.itinerary = [...(collection.itinerary || []), newIt]; + // trigger reactive globals and days + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + + try { + const res = await fetch('/api/itineraries/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + collection: collection.id, + content_type: objectType, + object_id: objectId, + is_global: true, + order + }) + }); + + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.detail || 'Failed to add global itinerary item'); + } + + const created = await res.json(); + collection.itinerary = collection.itinerary.map((it) => (it.id === tempId ? created : it)); + // refresh + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + } catch (err) { + console.error('Error creating global itinerary item:', err); + alert('Failed to add item to trip-wide itinerary.'); + collection.itinerary = collection.itinerary.filter((it) => it.id !== tempId); + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + } + } + // Handle opening the day picker modal for an unscheduled item function handleOpenDayPickerForItem(type: string, item: any) { // Check if the item already has a date, and if so, add it directly @@ -1139,6 +1237,166 @@ {:else}
+ + {#if globalItems.length > 0 || canModify} +
+
+
+
+ +
+

+ {$t('itinerary.trip_wide_items') || 'Trip-wide Items'} +

+ {#if canModify} + + {/if} +
+ + {#if globalItems.length === 0} +
+
+ +

+ {$t('itinerary.no_trip_wide_items') || 'No trip-wide items yet'} +

+
+
+ {:else} +
+ {#each globalItems as item (item.id)} + {@const objectType = item.item?.type || ''} + {@const resolvedObj = item.resolvedObject} +
+ {#if resolvedObj} + {#if canModify} +
+
+ +
+
+ {/if} + {#if objectType === 'location'} + + {:else if objectType === 'transportation'} + + {:else if objectType === 'lodging'} + + {:else if objectType === 'note'} + + {:else if objectType === 'checklist'} + + {/if} + {:else} +
+ ⚠️ {$t('itinerary.item_not_found')} (ID: {item.object_id}) +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ {/if} {#each days as day, dayIndex} {@const dayNumber = dayIndex + 1} @@ -1617,26 +1875,47 @@ {#if canModify}
- + + + + + +
{/if} diff --git a/frontend/src/lib/components/collections/CollectionMap.svelte b/frontend/src/lib/components/collections/CollectionMap.svelte index f5fe4eb2..c935feee 100644 --- a/frontend/src/lib/components/collections/CollectionMap.svelte +++ b/frontend/src/lib/components/collections/CollectionMap.svelte @@ -222,9 +222,20 @@ return features; } + function getActivityDate(activity: any, visit?: any): string | null { + return ( + activity?.start_date || + activity?.start_date_local || + visit?.start_date || + visit?.end_date || + null + ); + } + // Merge attachments/activity geojson into a single feature collection function collectLinesGeojson( - coll: Collection + coll: Collection, + filters: { startDate: string; endDate: string } ): { type: 'FeatureCollection'; features: any[] } | null { if (!coll) return null; const features: any[] = []; @@ -246,6 +257,8 @@ for (const visit of loc.visits) { if (!visit.activities) continue; for (const activity of visit.activities) { + const activityDate = getActivityDate(activity, visit); + if (!isWithinDateRange(activityDate, filters.startDate, filters.endDate)) continue; if (activity && activity.geojson) { // normalize features and inject activity-type color const color = getActivityColor(activity.sport_type || (activity as any).type || ''); @@ -324,7 +337,10 @@ .filter(Boolean) as MarkerFeature[]; $: allFeatures = [...locationFeatures, ...lodgingFeatures, ...transportationFeatures]; - $: linesGeoJson = collectLinesGeojson(collection); + $: linesGeoJson = collectLinesGeojson(collection, { + startDate: startDateFilter || collection?.start_date || '', + endDate: endDateFilter || collection?.end_date || '' + }); function matchesFilters( feature: MarkerFeature, diff --git a/frontend/src/lib/components/collections/CollectionStats.svelte b/frontend/src/lib/components/collections/CollectionStats.svelte new file mode 100644 index 00000000..07c8d87d --- /dev/null +++ b/frontend/src/lib/components/collections/CollectionStats.svelte @@ -0,0 +1,738 @@ + + +
+ +
+
+
+
+ {#if countriesVisited.length} + {#each countriesVisited.slice(0, 3) as country} + {#if country.flag} + {country.name} + {/if} + {/each} + {/if} +

{collection.name}

+
+ + {#if windowLabel} +
+ πŸ“… {windowLabel} + {#if scopeLabel} + {scopeLabel} + {/if} +
+ {:else} +

πŸ“ Folder view - showing all data

+ {/if} +
+ + +
+
+
πŸ“
+
Footprints
+
{visitedLocations.length}
+
Locations visited
+
+ +
+
πŸ“Έ
+
Photos
+
+ {compactFormatter.format(imagesInRange)} +
+
Images captured
+
+ +
+
πŸ—ΊοΈ
+
Places
+
{countriesVisited.length}
+
+ {regionsVisited.length} regions, {citiesVisited.length} cities +
+
+ +
+
πŸ‘₯
+
Travelers
+
+ {collection.collaborators?.length || 0} +
+
On this trip
+
+
+
+
+ + +
+
+

+ 🌎 + Geographic Breakdown +

+ + {#if countriesVisited.length} +
+

+ 🏳️ Countries ({countriesVisited.length}) +

+
+ {#each countriesVisited as country} +
+ {#if country.flag} + {country.name} + {/if} + {country.name} +
+ {/each} +
+
+ {/if} + + {#if regionsVisited.length} +
+

+ πŸ—ΊοΈ Regions ({regionsVisited.length}) +

+
+ {#each regionsVisited.slice(0, 15) as region} + + {region.name}{#if region.country}, {region.country}{/if} + + {/each} + {#if regionsVisited.length > 15} + +{regionsVisited.length - 15} more + {/if} +
+
+ {/if} + + {#if citiesVisited.length} +
+

+ πŸ™οΈ Cities ({citiesVisited.length}) +

+
+ {#each citiesVisited.slice(0, 20) as city} + + {city.name} + + {/each} + {#if citiesVisited.length > 20} + +{citiesVisited.length - 20} more + {/if} +
+
+ {/if} +
+
+ + +
+
+

+ πŸ“† + Trip Timeline +

+
+
+
Total Days
+
{tripDurationDays ?? 'N/A'}
+
Trip window
+
+
+
Active Days
+
{activeDayCount}
+
With activities
+
+
+
Visits
+
{visitsInRange.length}
+
Total visits
+
+
+
Nights
+
{lodgingNights}
+
{lodgingStays.length} stays
+
+
+
+
+ + + {#if totalDistance > 0} +
+
+

+ πŸ›£οΈ + Distance Traveled +

+ +
+
+
+ {numberFormatter.format(totalDistance)} +
+
{getDistanceUnitLong()} traveled
+
+
+ + {#if distanceByTransportType.length > 0} +
+ {#each distanceByTransportType as [icon, distance]} +
+
{icon}
+
{numberFormatter.format(distance)}
+
{getDistanceUnitLong()}
+
+ {/each} +
+ {/if} +
+
+ {/if} + + {#if activitiesInRange.length > 0} +
+
+

+ πŸƒ + Physical Activities +

+ +
+
+
🎯
+
Activities
+
{activitiesInRange.length}
+
Total recorded
+
+
+
πŸ“
+
Distance
+
+ {distanceFormatter.format(totalActivityDistance)} +
+
{getDistanceUnitLong()}
+
+
+
⛰️
+
Elevation
+
+ {numberFormatter.format(totalActivityElevation)} +
+
{getElevationUnitLong()} gained
+
+
+
πŸ”₯
+
Calories
+
+ {compactFormatter.format(totalActivityCalories)} +
+
burned
+
+
+ + {#if sportTypes.length > 0} +
+

Sport Types

+
+ {#each sportTypes as [sport, count]} + + {sport} ({count}) + + {/each} +
+
+ {/if} +
+
+ {/if} + + +
+
+

+ πŸ“± + Content & Media +

+ +
+
+
πŸ“Έ
+
Photos
+
+ {numberFormatter.format(imagesInRange)} +
+
Images
+
+ +
+
πŸ“
+
Notes
+
{notesInRange.length}
+
Written
+
+ +
+
βœ…
+
Checklists
+
{checklistsInRange.length}
+
Lists
+
+ +
+
πŸš†
+
Transport
+
{transportSegments.length}
+
Segments
+
+ +
+
🏨
+
Lodging
+
{lodgingStays.length}
+
Places
+
+ +
+
πŸ“
+
Locations
+
{visitedLocations.length}
+
Visited
+
+ + {#if totalAttachments > 0} +
+
πŸ“Ž
+
Attachments
+
{totalAttachments}
+
Files
+
+ {/if} +
+ + + {#if averageLocationRating > 0 || checklistStats.total > 0 || lodgingTypeBreakdown.length > 0} +
More Details
+
+ {#if averageLocationRating > 0} +
+
⭐
+
Avg Rating
+
{averageLocationRating.toFixed(1)}
+
of locations
+
+ {/if} + + {#if checklistStats.total > 0} +
+
βœ“
+
Tasks Done
+
{checklistStats.percentage}%
+
+ {checklistStats.checked}/{checklistStats.total} items +
+
+ {/if} + + {#if lodgingTypeBreakdown.length > 0} +
+
πŸ›οΈ
+
Lodging Types
+
{lodgingTypeBreakdown.length}
+
+ {getLodgingIcon(lodgingTypeBreakdown[0][0])} + {capitalize(lodgingTypeBreakdown[0][0])} +
+
+ {/if} +
+ {/if} +
+
+ + + {#if categoriesWithIcons.length > 0} +
+
+

+ 🏷️ + Categories +

+
+ {#each categoriesWithIcons as category} +
+ {category.icon} + {category.name} + {category.count} +
+ {/each} +
+
+
+ {/if} + + + {#if lodgingTypeBreakdown.length > 1} +
+
+

+ 🏨 + Lodging Types +

+
+ {#each lodgingTypeBreakdown as [type, count]} +
+ {getLodgingIcon(type)} + {capitalize(type)} + {count} +
+ {/each} +
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/shared/LocationSearchMap.svelte b/frontend/src/lib/components/shared/LocationSearchMap.svelte index 9a1b9da6..c07d9e39 100644 --- a/frontend/src/lib/components/shared/LocationSearchMap.svelte +++ b/frontend/src/lib/components/shared/LocationSearchMap.svelte @@ -139,9 +139,14 @@ location: initialStartLocation.location }; startMarker = { lng: initialStartLocation.lng, lat: initialStartLocation.lat }; - startCode = - initialStartCode || deriveCode(initialStartLocation.name, initialStartLocation.name); - startSearchQuery = startCode || initialStartLocation.location || initialStartLocation.name; + if (airportMode) { + startCode = + initialStartCode || deriveCode(initialStartLocation.name, initialStartLocation.name); + startSearchQuery = startCode || initialStartLocation.location || initialStartLocation.name; + } else { + startCode = null; + startSearchQuery = initialStartLocation.location || initialStartLocation.name; + } await performDetailedReverseGeocode( initialStartLocation.lat, initialStartLocation.lng, @@ -157,8 +162,13 @@ location: initialEndLocation.location }; endMarker = { lng: initialEndLocation.lng, lat: initialEndLocation.lat }; - endCode = initialEndCode || deriveCode(initialEndLocation.name, initialEndLocation.name); - endSearchQuery = endCode || initialEndLocation.location || initialEndLocation.name; + if (airportMode) { + endCode = initialEndCode || deriveCode(initialEndLocation.name, initialEndLocation.name); + endSearchQuery = endCode || initialEndLocation.location || initialEndLocation.name; + } else { + endCode = null; + endSearchQuery = initialEndLocation.location || initialEndLocation.name; + } await performDetailedReverseGeocode(initialEndLocation.lat, initialEndLocation.lng, 'end'); } diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index b3717f4b..edf308ef 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.12.0-pre-dev-010526-2'; +export let appVersion = 'v0.12.0-pre-dev-010626'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2026'; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5131a8f6..096affeb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -585,6 +585,7 @@ export type CollectionItineraryItem = { object_id: string; // UUID of the referenced object item: Visit | Transportation | Lodging | Note | Checklist; // The actual referenced object date: string | null; // ISO 8601 date string + is_global?: boolean; // Trip-wide item (no specific date) order: number; // Manual order within a day created_at: string; // ISO 8601 date string start_datetime: string | null; // Computed property - ISO 8601 date string diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 838e8278..729aa05b 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -20,6 +20,7 @@ import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte'; import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte'; import CollectionMap from '$lib/components/collections/CollectionMap.svelte'; + import CollectionStats from '$lib/components/collections/CollectionStats.svelte'; import LocationLink from '$lib/components/LocationLink.svelte'; import { getBasemapUrl } from '$lib'; import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money'; @@ -28,6 +29,7 @@ import Timeline from '~icons/mdi/timeline'; import Map from '~icons/mdi/map'; import Lightbulb from '~icons/mdi/lightbulb'; + import ChartBar from '~icons/mdi/chart-bar'; import Plus from '~icons/mdi/plus'; import { addToast } from '$lib/toasts'; import NoteModal from '$lib/components/NoteModal.svelte'; @@ -88,7 +90,7 @@ } // View state from URL params - type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations'; + type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats'; let currentView: ViewType = 'itinerary'; // Determine if this is a folder view (no dates) or itinerary view (has dates) @@ -123,7 +125,8 @@ ) || false, calendar: !isFolderView, - recommendations: true // may be overridden by permission check below + recommendations: true, // may be overridden by permission check below + stats: true }; // Get default view based on available views @@ -135,7 +138,7 @@ const view = $page.url.searchParams.get('view') as ViewType; if ( view && - ['all', 'itinerary', 'map', 'calendar', 'recommendations'].includes(view) && + ['all', 'itinerary', 'map', 'calendar', 'recommendations', 'stats'].includes(view) && availableViews[view] ) { currentView = view; @@ -1019,6 +1022,16 @@ {/if} + {#if availableViews.stats} + + {/if} @@ -1056,6 +1069,11 @@ /> {/if} + + {#if currentView === 'stats'} + + {/if} + {#if currentView === 'map'}