feat: add CollectionStats component for detailed trip statistics

- Implemented CollectionStats.svelte to display various statistics related to the collection, including distances, activities, and locations visited.
- Enhanced CollectionMap.svelte to filter activities based on date range using new getActivityDate function.
- Updated LocationSearchMap.svelte to handle airport mode for start and end locations.
- Modified types.ts to include is_global property in CollectionItineraryItem for trip-wide items.
- Updated +page.svelte to integrate the new stats view and manage view state accordingly.
This commit is contained in:
Sean Morley
2026-01-06 12:06:50 -05:00
parent 75b32d7c1d
commit e602639877
13 changed files with 1203 additions and 50 deletions

View File

@@ -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'),
),
]

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 @@
</div>
{:else}
<div class="space-y-6">
<!-- Trip-wide (Global) Items -->
{#if globalItems.length > 0 || canModify}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-base-300">
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary"
>
<CalendarBlank class="w-4 h-4" />
</div>
<h3 class="text-xl font-bold">
{$t('itinerary.trip_wide_items') || 'Trip-wide Items'}
</h3>
{#if canModify}
<div class="dropdown z-30 ml-auto">
<button
type="button"
class="btn btn-square btn-sm btn-outline p-1"
aria-haspopup="menu"
aria-expanded="false"
title={$t('adventures.add')}
>
<Plus class="w-5 h-5" />
</button>
<ul
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-56"
role="menu"
>
<li class="menu-title">{$t('itinerary.link_existing_item')}</li>
<li class="text-xs opacity-70 px-2 py-1 select-none">
Add items below (Unscheduled) to trip-wide
</li>
</ul>
</div>
{/if}
</div>
{#if globalItems.length === 0}
<div
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
>
<div class="card-body p-2">
<CalendarBlank class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="opacity-70">
{$t('itinerary.no_trip_wide_items') || 'No trip-wide items yet'}
</p>
</div>
</div>
{:else}
<div
use:dndzone={{
items: globalItems,
flipDurationMs,
dropTargetStyle: { outline: 'none', border: 'none' },
dragDisabled: isSavingOrder || !canModify,
dropFromOthersDisabled: true
}}
on:consider={handleDndConsiderGlobal}
on:finalize={handleDndFinalizeGlobal}
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"
>
{#each globalItems as item (item.id)}
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
<div
class="group relative transition-all duration-200 pointer-events-auto h-full"
animate:flip={{ duration: flipDurationMs }}
>
{#if resolvedObj}
{#if canModify}
<div
class="absolute left-2 top-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title="Drag to reorder"
>
<div
class="itinerary-drag-handle btn btn-circle btn-xs btn-ghost bg-base-100/80 backdrop-blur-sm shadow-sm hover:bg-base-200 cursor-grab active:cursor-grabbing"
aria-label="Drag to reorder"
role="button"
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/></svg
>
</div>
</div>
{/if}
{#if objectType === 'location'}
<LocationCard
adventure={resolvedObj}
on:edit={handleEditLocation}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
{user}
{collection}
compact={true}
/>
{:else if objectType === 'transportation'}
<TransportationCard
transportation={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditTransportation}
/>
{:else if objectType === 'lodging'}
<LodgingCard
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
/>
{:else if objectType === 'note'}
<NoteCard
note={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditNote}
/>
{:else if objectType === 'checklist'}
<ChecklistCard
checklist={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditChecklist}
/>
{/if}
{:else}
<div class="alert alert-warning">
<span>⚠️ {$t('itinerary.item_not_found')} (ID: {item.object_id})</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Scheduled Days -->
{#each days as day, dayIndex}
{@const dayNumber = dayIndex + 1}
@@ -1617,26 +1875,47 @@
<!-- "Add to itinerary" indicator -->
{#if canModify}
<div class="absolute -right-2 top-2 z-10">
<button
class="btn btn-circle btn-sm btn-primary"
title="Add to itinerary"
on:click={() => handleOpenDayPickerForItem(type, item)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<div class="join">
<button
class="btn btn-circle btn-sm btn-primary join-item"
title="Add to day"
on:click={() => handleOpenDayPickerForItem(type, item)}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button
class="btn btn-circle btn-sm btn-outline join-item"
title="Add to trip-wide"
on:click={() => addGlobalItineraryItemForObject(type, item.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 17l4 4 4-4m-4-13v17"
/></svg
>
</button>
</div>
</div>
{/if}

View File

@@ -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,

View File

@@ -0,0 +1,738 @@
<script lang="ts">
import type {
Checklist,
Collection,
Lodging,
Note,
Transportation,
Visit,
Activity,
User
} from '$lib/types';
// @ts-ignore
import { DateTime } from 'luxon';
// lodging icons and helpers
import { LODGING_TYPES_ICONS } from '$lib';
export let collection: Collection;
export let user: User | null = null;
function getLodgingIcon(type: string): string {
return (LODGING_TYPES_ICONS as Record<string, string>)[type] || '🏨';
}
function convertDistance(km: number): number {
if (user?.measurement_system === 'imperial') {
return km * 0.621371; // Convert km to miles
}
return km;
}
function convertElevation(meters: number): number {
if (user?.measurement_system === 'imperial') {
return meters * 3.28084; // Convert meters to feet
}
return meters;
}
function getDistanceUnit(): string {
return user?.measurement_system === 'imperial' ? 'mi' : 'km';
}
function getDistanceUnitLong(): string {
return user?.measurement_system === 'imperial' ? 'miles' : 'kilometers';
}
function getElevationUnit(): string {
return user?.measurement_system === 'imperial' ? 'ft' : 'm';
}
function getElevationUnitLong(): string {
return user?.measurement_system === 'imperial' ? 'feet' : 'meters';
}
const numberFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
const distanceFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1
});
const tripStart = collection.start_date ? DateTime.fromISO(collection.start_date) : null;
const tripEnd = collection.end_date ? DateTime.fromISO(collection.end_date) : null;
function overlapsCollectionRange(
startStr: string | null | undefined,
endStr: string | null | undefined
): boolean {
if (!tripStart || !tripEnd) return true; // If no collection window, include everything
if (!startStr) return false;
const start = DateTime.fromISO(startStr);
if (!start.isValid) return false;
const end = endStr ? DateTime.fromISO(endStr) : start;
if (!end.isValid) return false;
return end >= tripStart && start <= tripEnd;
}
function addRangeDays(
startStr: string | null | undefined,
endStr: string | null | undefined,
target: Set<string>
) {
if (!startStr) return;
const start = DateTime.fromISO(startStr);
const end = endStr ? DateTime.fromISO(endStr) : start;
if (!start.isValid || !end.isValid) return;
let cursor = start.startOf('day');
const finalDay = end.startOf('day');
while (cursor <= finalDay) {
target.add(cursor.toISODate());
cursor = cursor.plus({ days: 1 });
}
}
$: tripDurationDays =
tripStart && tripEnd ? Math.max(1, Math.floor(tripEnd.diff(tripStart, 'days').days) + 1) : null;
$: visitedLocations = (collection.locations || []).filter((loc) =>
loc.visits?.some((visit) => overlapsCollectionRange(visit.start_date, visit.end_date))
);
$: visitsInRange = visitedLocations.flatMap((loc) =>
(loc.visits || []).filter((visit) => overlapsCollectionRange(visit.start_date, visit.end_date))
);
$: countriesVisited = (() => {
const map = new Map<string, { name: string; code: string; flag?: string }>();
visitedLocations.forEach((loc) => {
const country = loc.country;
if (!country) return;
const key = country.country_code || String(country.id) || country.name;
if (!key) return;
if (!map.has(key))
map.set(key, {
name: country.name,
code: country.country_code || '',
flag: country.flag_url || ''
});
});
return Array.from(map.values());
})();
$: transportSegments = (collection.transportations || []).filter((segment) =>
overlapsCollectionRange(segment.date, segment.end_date)
);
$: totalDistance = convertDistance(
transportSegments.reduce((sum, segment) => sum + (segment.distance || 0), 0)
);
$: lodgingStays = (collection.lodging || []).filter((stay) =>
overlapsCollectionRange(stay.check_in, stay.check_out)
);
$: lodgingNights = lodgingStays.reduce((sum, stay) => {
if (!stay.check_in || !stay.check_out) return sum;
const start = DateTime.fromISO(stay.check_in);
const end = DateTime.fromISO(stay.check_out);
if (!start.isValid || !end.isValid) return sum;
const diff = Math.max(1, Math.floor(end.diff(start, 'days').days));
return sum + diff;
}, 0);
$: notesInRange = (collection.notes || []).filter((note: Note) =>
overlapsCollectionRange(note.date, note.date)
);
$: checklistsInRange = (collection.checklists || []).filter((list: Checklist) =>
overlapsCollectionRange(list.date, list.date)
);
$: imagesInRange = (() => {
let total = 0;
visitedLocations.forEach((loc) => (total += loc.images?.length || 0));
transportSegments.forEach((segment) => (total += segment.images?.length || 0));
lodgingStays.forEach((stay) => (total += stay.images?.length || 0));
return total;
})();
$: regionsVisited = (() => {
const map = new Map<string, { name: string; country: string }>();
visitedLocations.forEach((loc) => {
const region = loc.region;
if (!region) return;
const key = String(region.id || region.name);
if (!key) return;
if (!map.has(key)) map.set(key, { name: region.name, country: region.country_name || '' });
});
return Array.from(map.values());
})();
$: citiesVisited = (() => {
const map = new Map<string, { name: string; region: string }>();
visitedLocations.forEach((loc) => {
const city = loc.city;
if (!city) return;
const key = String(city.id || city.name);
if (!key) return;
if (!map.has(key)) map.set(key, { name: city.name, region: city.region_name || '' });
});
return Array.from(map.values());
})();
$: categoriesWithIcons = (() => {
const map = new Map<string, { name: string; icon: string; count: number }>();
visitedLocations.forEach((loc) => {
if (!loc.category) return;
const name = loc.category.display_name || loc.category.name;
const icon = loc.category.icon || '📍';
if (!name) return;
if (!map.has(name)) {
map.set(name, { name, icon, count: 0 });
}
const existing = map.get(name)!;
existing.count++;
});
return Array.from(map.values()).sort((a, b) => b.count - a.count);
})();
$: activitiesInRange = (() => {
const activities: Activity[] = [];
visitsInRange.forEach((visit: Visit) => {
if (visit.activities && visit.activities.length > 0) {
activities.push(...visit.activities);
}
});
return activities;
})();
$: totalActivityDistance = convertDistance(
activitiesInRange.reduce((sum, act) => sum + (act.distance || 0), 0) / 1000
);
$: totalActivityElevation = convertElevation(
activitiesInRange.reduce((sum, act) => sum + (act.elevation_gain || 0), 0)
);
$: totalActivityCalories = activitiesInRange.reduce((sum, act) => sum + (act.calories || 0), 0);
$: sportTypes = (() => {
const types = new Map<string, number>();
activitiesInRange.forEach((act) => {
const sport = act.sport_type || 'Other';
types.set(sport, (types.get(sport) || 0) + 1);
});
return Array.from(types.entries()).sort((a, b) => b[1] - a[1]);
})();
$: activeDayCount = (() => {
const days = new Set<string>();
visitsInRange.forEach((visit: Visit) => addRangeDays(visit.start_date, visit.end_date, days));
transportSegments.forEach((segment: Transportation) =>
addRangeDays(segment.date, segment.end_date, days)
);
lodgingStays.forEach((stay: Lodging) => addRangeDays(stay.check_in, stay.check_out, days));
return days.size;
})();
$: scopeLabel = (() => {
const dayPart = tripDurationDays
? `${tripDurationDays} ${tripDurationDays === 1 ? 'day' : 'days'}`
: '';
const countryPart = countriesVisited.length
? `${countriesVisited.length} ${countriesVisited.length === 1 ? 'country' : 'countries'}`
: '';
return [dayPart, countryPart].filter(Boolean).join(' in ');
})();
$: windowLabel =
tripStart && tripEnd
? `${tripStart.toLocaleString(DateTime.DATE_MED)} - ${tripEnd.toLocaleString(DateTime.DATE_MED)}`
: null;
function getTransportIcon(type?: string | null) {
const normalized = (type || '').toLowerCase();
if (normalized.includes('flight') || normalized.includes('plane') || normalized.includes('air'))
return '✈️';
if (normalized.includes('train') || normalized.includes('rail')) return '🚆';
if (normalized.includes('bus')) return '🚌';
if (normalized.includes('car') || normalized.includes('drive')) return '🚗';
if (normalized.includes('boat') || normalized.includes('ferry') || normalized.includes('ship'))
return '🚢';
return '🚗';
}
function capitalize(text?: string | null) {
if (!text) return '';
const s = String(text);
return s.charAt(0).toUpperCase() + s.slice(1);
}
$: distanceByTransportType = (() => {
const types = new Map<string, number>();
transportSegments.forEach((segment) => {
const icon = getTransportIcon(segment.type);
const distance = convertDistance(segment.distance || 0);
types.set(icon, (types.get(icon) || 0) + distance);
});
return Array.from(types.entries()).sort((a, b) => b[1] - a[1]);
})();
$: averageLocationRating = (() => {
const rated = visitedLocations.filter((loc) => loc.rating !== null && loc.rating !== undefined);
if (rated.length === 0) return 0;
return rated.reduce((sum, loc) => sum + (loc.rating || 0), 0) / rated.length;
})();
$: checklistStats = (() => {
let totalItems = 0;
let checkedItems = 0;
checklistsInRange.forEach((list) => {
if (list.items) {
totalItems += list.items.length;
checkedItems += list.items.filter((item) => item.is_checked).length;
}
});
return {
total: totalItems,
checked: checkedItems,
percentage: totalItems > 0 ? Math.round((checkedItems / totalItems) * 100) : 0
};
})();
$: lodgingTypeBreakdown = (() => {
const types = new Map<string, number>();
lodgingStays.forEach((stay) => {
const type = stay.type || 'Other';
types.set(type, (types.get(type) || 0) + 1);
});
return Array.from(types.entries()).sort((a, b) => b[1] - a[1]);
})();
$: totalAttachments = (() => {
let total = 0;
visitedLocations.forEach((loc) => (total += loc.attachments?.length || 0));
transportSegments.forEach((segment) => (total += segment.attachments?.length || 0));
lodgingStays.forEach((stay) => (total += stay.attachments?.length || 0));
return total;
})();
</script>
<div class="space-y-6">
<!-- Hero Overview Card -->
<div
class="card bg-gradient-to-br from-primary/10 to-secondary/10 shadow-xl border border-primary/20"
>
<div class="card-body space-y-4">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
{#if countriesVisited.length}
{#each countriesVisited.slice(0, 3) as country}
{#if country.flag}
<img src={country.flag} alt={country.name} class="w-8 h-6 rounded shadow-sm" />
{/if}
{/each}
{/if}
<h2 class="text-3xl font-bold">{collection.name}</h2>
</div>
{#if windowLabel}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="badge badge-lg badge-ghost">📅 {windowLabel}</span>
{#if scopeLabel}
<span class="badge badge-lg badge-primary">{scopeLabel}</span>
{/if}
</div>
{:else}
<p class="text-sm opacity-70">📁 Folder view - showing all data</p>
{/if}
</div>
<!-- Key Stats Row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="stat bg-base-100 rounded-lg shadow p-4 border border-info/20">
<div class="stat-figure text-info text-3xl">📍</div>
<div class="stat-title text-xs">Footprints</div>
<div class="stat-value text-info text-2xl">{visitedLocations.length}</div>
<div class="stat-desc">Locations visited</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow p-4 border border-success/20">
<div class="stat-figure text-success text-3xl">📸</div>
<div class="stat-title text-xs">Photos</div>
<div class="stat-value text-success text-2xl">
{compactFormatter.format(imagesInRange)}
</div>
<div class="stat-desc">Images captured</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow p-4 border border-warning/20">
<div class="stat-figure text-warning text-3xl">🗺️</div>
<div class="stat-title text-xs">Places</div>
<div class="stat-value text-warning text-2xl">{countriesVisited.length}</div>
<div class="stat-desc">
{regionsVisited.length} regions, {citiesVisited.length} cities
</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow p-4 border border-accent/20">
<div class="stat-figure text-accent text-3xl">👥</div>
<div class="stat-title text-xs">Travelers</div>
<div class="stat-value text-accent text-2xl">
{collection.collaborators?.length || 0}
</div>
<div class="stat-desc">On this trip</div>
</div>
</div>
</div>
</div>
<!-- Geographic Breakdown -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body space-y-4">
<h3 class="card-title text-xl flex items-center gap-2">
<span class="text-2xl">🌎</span>
Geographic Breakdown
</h3>
{#if countriesVisited.length}
<div>
<h4 class="font-semibold mb-2 flex items-center gap-2">
<span>🏳️</span> Countries ({countriesVisited.length})
</h4>
<div class="flex flex-wrap gap-2">
{#each countriesVisited as country}
<div class="flex items-center gap-2 badge badge-lg badge-primary badge-outline p-3">
{#if country.flag}
<img src={country.flag} alt={country.name} class="w-6 h-4 rounded" />
{/if}
<span class="font-medium">{country.name}</span>
</div>
{/each}
</div>
</div>
{/if}
{#if regionsVisited.length}
<div>
<h4 class="font-semibold mb-2 flex items-center gap-2">
<span>🗺️</span> Regions ({regionsVisited.length})
</h4>
<div class="flex flex-wrap gap-2">
{#each regionsVisited.slice(0, 15) as region}
<span class="badge badge-lg badge-secondary badge-outline">
{region.name}{#if region.country}, {region.country}{/if}
</span>
{/each}
{#if regionsVisited.length > 15}
<span class="badge badge-lg badge-ghost">+{regionsVisited.length - 15} more</span>
{/if}
</div>
</div>
{/if}
{#if citiesVisited.length}
<div>
<h4 class="font-semibold mb-2 flex items-center gap-2">
<span>🏙️</span> Cities ({citiesVisited.length})
</h4>
<div class="flex flex-wrap gap-2">
{#each citiesVisited.slice(0, 20) as city}
<span class="badge badge-accent badge-outline">
{city.name}
</span>
{/each}
{#if citiesVisited.length > 20}
<span class="badge badge-ghost">+{citiesVisited.length - 20} more</span>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Trip Timeline & Duration -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2">
<span class="text-2xl">📆</span>
Trip Timeline
</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="stat bg-primary/10 rounded-lg p-4">
<div class="stat-title text-xs">Total Days</div>
<div class="stat-value text-primary text-2xl">{tripDurationDays ?? 'N/A'}</div>
<div class="stat-desc">Trip window</div>
</div>
<div class="stat bg-success/10 rounded-lg p-4">
<div class="stat-title text-xs">Active Days</div>
<div class="stat-value text-success text-2xl">{activeDayCount}</div>
<div class="stat-desc">With activities</div>
</div>
<div class="stat bg-info/10 rounded-lg p-4">
<div class="stat-title text-xs">Visits</div>
<div class="stat-value text-info text-2xl">{visitsInRange.length}</div>
<div class="stat-desc">Total visits</div>
</div>
<div class="stat bg-warning/10 rounded-lg p-4">
<div class="stat-title text-xs">Nights</div>
<div class="stat-value text-warning text-2xl">{lodgingNights}</div>
<div class="stat-desc">{lodgingStays.length} stays</div>
</div>
</div>
</div>
</div>
<!-- Distance Breakdown -->
{#if totalDistance > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2 mb-2">
<span class="text-2xl">🛣️</span>
Distance Traveled
</h3>
<div
class="flex items-center justify-center p-6 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg mb-4"
>
<div class="text-center">
<div class="text-5xl font-bold text-primary">
{numberFormatter.format(totalDistance)}
</div>
<div class="text-sm opacity-70 mt-1">{getDistanceUnitLong()} traveled</div>
</div>
</div>
{#if distanceByTransportType.length > 0}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each distanceByTransportType as [icon, distance]}
<div class="stat bg-base-300 rounded-lg p-3">
<div class="stat-figure text-3xl">{icon}</div>
<div class="stat-value text-lg">{numberFormatter.format(distance)}</div>
<div class="stat-desc text-xs">{getDistanceUnitLong()}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Activities Stats -->
{#if activitiesInRange.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2 mb-2">
<span class="text-2xl">🏃</span>
Physical Activities
</h3>
<div class="stats stats-vertical sm:stats-horizontal shadow mb-4">
<div class="stat bg-accent/10">
<div class="stat-figure text-accent text-3xl">🎯</div>
<div class="stat-title">Activities</div>
<div class="stat-value text-accent">{activitiesInRange.length}</div>
<div class="stat-desc">Total recorded</div>
</div>
<div class="stat bg-info/10">
<div class="stat-figure text-info text-3xl">📏</div>
<div class="stat-title">Distance</div>
<div class="stat-value text-info">
{distanceFormatter.format(totalActivityDistance)}
</div>
<div class="stat-desc">{getDistanceUnitLong()}</div>
</div>
<div class="stat bg-success/10">
<div class="stat-figure text-success text-3xl">⛰️</div>
<div class="stat-title">Elevation</div>
<div class="stat-value text-success">
{numberFormatter.format(totalActivityElevation)}
</div>
<div class="stat-desc">{getElevationUnitLong()} gained</div>
</div>
<div class="stat bg-warning/10">
<div class="stat-figure text-warning text-3xl">🔥</div>
<div class="stat-title">Calories</div>
<div class="stat-value text-warning">
{compactFormatter.format(totalActivityCalories)}
</div>
<div class="stat-desc">burned</div>
</div>
</div>
{#if sportTypes.length > 0}
<div>
<h4 class="font-semibold mb-2">Sport Types</h4>
<div class="flex flex-wrap gap-2">
{#each sportTypes as [sport, count]}
<span class="badge badge-lg badge-primary badge-outline">
{sport} ({count})
</span>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Content & Media -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2 mb-2">
<span class="text-2xl">📱</span>
Content & Media
</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
class="stat bg-gradient-to-br from-primary/20 to-primary/5 rounded-lg p-4 border border-primary/30"
>
<div class="stat-figure text-primary text-3xl">📸</div>
<div class="stat-title text-xs">Photos</div>
<div class="stat-value text-primary text-2xl">
{numberFormatter.format(imagesInRange)}
</div>
<div class="stat-desc">Images</div>
</div>
<div
class="stat bg-gradient-to-br from-secondary/20 to-secondary/5 rounded-lg p-4 border border-secondary/30"
>
<div class="stat-figure text-secondary text-3xl">📝</div>
<div class="stat-title text-xs">Notes</div>
<div class="stat-value text-secondary text-2xl">{notesInRange.length}</div>
<div class="stat-desc">Written</div>
</div>
<div
class="stat bg-gradient-to-br from-accent/20 to-accent/5 rounded-lg p-4 border border-accent/30"
>
<div class="stat-figure text-accent text-3xl"></div>
<div class="stat-title text-xs">Checklists</div>
<div class="stat-value text-accent text-2xl">{checklistsInRange.length}</div>
<div class="stat-desc">Lists</div>
</div>
<div
class="stat bg-gradient-to-br from-info/20 to-info/5 rounded-lg p-4 border border-info/30"
>
<div class="stat-figure text-info text-3xl">🚆</div>
<div class="stat-title text-xs">Transport</div>
<div class="stat-value text-info text-2xl">{transportSegments.length}</div>
<div class="stat-desc">Segments</div>
</div>
<div
class="stat bg-gradient-to-br from-success/20 to-success/5 rounded-lg p-4 border border-success/30"
>
<div class="stat-figure text-success text-3xl">🏨</div>
<div class="stat-title text-xs">Lodging</div>
<div class="stat-value text-success text-2xl">{lodgingStays.length}</div>
<div class="stat-desc">Places</div>
</div>
<div
class="stat bg-gradient-to-br from-warning/20 to-warning/5 rounded-lg p-4 border border-warning/30"
>
<div class="stat-figure text-warning text-3xl">📍</div>
<div class="stat-title text-xs">Locations</div>
<div class="stat-value text-warning text-2xl">{visitedLocations.length}</div>
<div class="stat-desc">Visited</div>
</div>
{#if totalAttachments > 0}
<div
class="stat bg-gradient-to-br from-error/20 to-error/5 rounded-lg p-4 border border-error/30"
>
<div class="stat-figure text-error text-3xl">📎</div>
<div class="stat-title text-xs">Attachments</div>
<div class="stat-value text-error text-2xl">{totalAttachments}</div>
<div class="stat-desc">Files</div>
</div>
{/if}
</div>
<!-- Additional Stats Row -->
{#if averageLocationRating > 0 || checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
<div class="divider">More Details</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{#if averageLocationRating > 0}
<div class="stat bg-base-300 rounded-lg p-4">
<div class="stat-figure text-2xl"></div>
<div class="stat-title text-xs">Avg Rating</div>
<div class="stat-value text-lg">{averageLocationRating.toFixed(1)}</div>
<div class="stat-desc text-xs">of locations</div>
</div>
{/if}
{#if checklistStats.total > 0}
<div class="stat bg-base-300 rounded-lg p-4">
<div class="stat-figure text-2xl"></div>
<div class="stat-title text-xs">Tasks Done</div>
<div class="stat-value text-lg">{checklistStats.percentage}%</div>
<div class="stat-desc text-xs">
{checklistStats.checked}/{checklistStats.total} items
</div>
</div>
{/if}
{#if lodgingTypeBreakdown.length > 0}
<div class="stat bg-base-300 rounded-lg p-4">
<div class="stat-figure text-2xl">🛏️</div>
<div class="stat-title text-xs">Lodging Types</div>
<div class="stat-value text-lg">{lodgingTypeBreakdown.length}</div>
<div class="stat-desc text-xs truncate">
{getLodgingIcon(lodgingTypeBreakdown[0][0])}
{capitalize(lodgingTypeBreakdown[0][0])}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Categories -->
{#if categoriesWithIcons.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2">
<span class="text-2xl">🏷️</span>
Categories
</h3>
<div class="flex flex-wrap gap-2">
{#each categoriesWithIcons as category}
<div class="badge badge-lg badge-primary badge-outline p-3 flex items-center gap-2">
<span class="text-lg">{category.icon}</span>
<span class="font-medium">{category.name}</span>
<span class="badge badge-xs badge-primary">{category.count}</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Lodging Types Breakdown -->
{#if lodgingTypeBreakdown.length > 1}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-xl flex items-center gap-2">
<span class="text-2xl">🏨</span>
Lodging Types
</h3>
<div class="flex flex-wrap gap-2">
{#each lodgingTypeBreakdown as [type, count]}
<div class="badge badge-lg badge-success badge-outline p-3 flex items-center gap-2">
<span class="text-lg">{getLodgingIcon(type)}</span>
<span class="font-medium">{capitalize(type)}</span>
<span class="badge badge-xs badge-success ml-2">{count}</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -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');
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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 @@
<span class="hidden sm:inline">{$t('recomendations.recommendations')}</span>
</button>
{/if}
{#if availableViews.stats}
<button
class="btn join-item"
class:btn-active={currentView === 'stats'}
on:click={() => switchView('stats')}
>
<ChartBar class="w-5 h-5 sm:mr-2" aria-hidden="true" />
<span class="hidden sm:inline">{$t('collections.statistics')}</span>
</button>
{/if}
</div>
</div>
@@ -1056,6 +1069,11 @@
/>
{/if}
<!-- Stats View -->
{#if currentView === 'stats'}
<CollectionStats {collection} user={data.user} />
{/if}
<!-- Map View -->
{#if currentView === 'map'}
<div class="card bg-base-200 shadow-xl">