mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-09 07:25:01 -04:00
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:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
738
frontend/src/lib/components/collections/CollectionStats.svelte
Normal file
738
frontend/src/lib/components/collections/CollectionStats.svelte
Normal 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>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user