feat: enhance itinerary management with deduplication and initial visit date handling

This commit is contained in:
Sean Morley
2025-12-22 13:56:39 -05:00
parent 09f8cd4a8c
commit 6753c840f8
5 changed files with 151 additions and 25 deletions

View File

@@ -8,8 +8,7 @@
<!-- iOS / Safari PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- Apple touch icons (place files in frontend/static/) -->
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link

View File

@@ -170,8 +170,16 @@
return;
}
// Store results to display inline
wikiImageResults = data.images;
// Store results to display inline (deduplicated by source)
{
const seen = new Set();
wikiImageResults = (data.images || []).filter((img: { source: unknown }) => {
if (!img || !img.source) return false;
if (seen.has(img.source)) return false;
seen.add(img.source);
return true;
});
}
} catch (error) {
wikiImageError = $t('adventures.wiki_image_error');
addToast('error', $t('adventures.image_upload_error'));
@@ -366,7 +374,7 @@
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-96 overflow-y-auto">
{#each wikiImageResults as result (result.source)}
{#each wikiImageResults as result, i (result.source + '-' + i)}
<button
type="button"
class="card bg-base-100 border border-base-300 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer group"

View File

@@ -11,9 +11,13 @@
export let user: User | null = null;
export let collection: Collection | null = null;
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
const dispatch = createEventDispatcher();
// Store the initial visit date internally so it persists even if parent clears it
let storedInitialVisitDate: string | null = initialVisitDate;
let modal: HTMLDialogElement;
let steps = [
@@ -185,7 +189,7 @@
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5-5z"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-0.089l4-5-5z"
clip-rule="evenodd"
/>
</svg>
@@ -321,6 +325,7 @@
on:close={() => close()}
measurementSystem={user?.measurement_system || 'metric'}
{collection}
initialVisitDate={storedInitialVisitDate}
/>
{/if}
</div>

View File

@@ -119,13 +119,69 @@
isLocationModalOpen = true;
}
function handleDeleteLocation(event: CustomEvent<Location>) {
// remove locally deleted location from itinerary view and list
const deletedLocation = event.detail;
collection.locations = collection.locations?.filter((loc) => loc.id !== deletedLocation.id);
collection.itinerary = collection.itinerary?.filter(
(it) => !(it.item?.type === 'location' && it.object_id === deletedLocation.id)
);
function handleItemDelete(event: CustomEvent<CollectionItineraryItem | string | number>) {
const payload = event.detail;
// Support both cases:
// 1) Card components dispatch a primitive id (string/number) when deleting the underlying object
// 2) Some callers may dispatch a full itinerary item object
if (typeof payload === 'string' || typeof payload === 'number') {
const objectId = payload;
// Remove any itinerary entries that reference this object
collection.itinerary = collection.itinerary?.filter(
(it) => String(it.object_id) !== String(objectId)
);
// Remove the object from all possible collections (location/transportation/lodging/note/checklist)
if (collection.locations) {
collection.locations = collection.locations.filter(
(loc) => String(loc.id) !== String(objectId)
);
}
if (collection.transportations) {
collection.transportations = collection.transportations.filter(
(t) => String(t.id) !== String(objectId)
);
}
if (collection.lodging) {
collection.lodging = collection.lodging.filter((l) => String(l.id) !== String(objectId));
}
if (collection.notes) {
collection.notes = collection.notes.filter((n) => String(n.id) !== String(objectId));
}
if (collection.checklists) {
collection.checklists = collection.checklists.filter(
(c) => String(c.id) !== String(objectId)
);
}
// Re-group days and return
days = groupItemsByDay(collection);
return;
}
// Otherwise expect a full itinerary-like object
const itemToDelete = payload as CollectionItineraryItem;
collection.itinerary = collection.itinerary?.filter((it) => it.id !== itemToDelete.id);
// Also remove the associated object from the collection
const objectType = itemToDelete.item?.type || '';
if (objectType === 'location') {
collection.locations = collection.locations?.filter(
(loc) => loc.id !== itemToDelete.object_id
);
} else if (objectType === 'transportation') {
collection.transportations = collection.transportations?.filter(
(t) => t.id !== itemToDelete.object_id
);
} else if (objectType === 'lodging') {
collection.lodging = collection.lodging?.filter((l) => l.id !== itemToDelete.object_id);
} else if (objectType === 'note') {
collection.notes = collection.notes?.filter((n) => n.id !== itemToDelete.object_id);
} else if (objectType === 'checklist') {
collection.checklists = collection.checklists?.filter((c) => c.id !== itemToDelete.object_id);
}
days = groupItemsByDay(collection);
}
let locationBeingUpdated: Location | null = null;
@@ -142,6 +198,8 @@
// When opening a "create new item" modal we store the target date here
let pendingAddDate: string | null = null;
// Track if we've already added this location to the itinerary
let addedToItinerary: Set<string> = new Set();
// Sync the locationBeingUpdated with the collection.locations array
$: if (locationBeingUpdated && locationBeingUpdated.id && collection) {
@@ -168,9 +226,15 @@
// If a new location was just created and we have a pending add-date,
// attach it to that date in the itinerary.
$: if (locationBeingUpdated?.id && pendingAddDate) {
$: if (
locationBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(locationBeingUpdated.id)
) {
addItineraryItemForObject('location', locationBeingUpdated.id, pendingAddDate);
pendingAddDate = null;
// Mark this location as added to prevent duplicates
addedToItinerary.add(locationBeingUpdated.id);
addedToItinerary = addedToItinerary; // trigger reactivity
}
/**
@@ -543,11 +607,17 @@
{#if isLocationModalOpen}
<NewLocationModal
on:close={() => (isLocationModalOpen = false)}
on:close={() => {
isLocationModalOpen = false;
pendingAddDate = null;
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
{user}
{locationToEdit}
bind:location={locationBeingUpdated}
{collection}
initialVisitDate={pendingAddDate}
/>
{/if}
@@ -898,7 +968,7 @@
<LocationCard
adventure={resolvedObj}
on:edit={handleEditLocation}
on:delete={handleDeleteLocation}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
{user}
@@ -910,6 +980,7 @@
transportation={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
@@ -919,6 +990,7 @@
{user}
{collection}
itineraryItem={item}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
{:else if objectType === 'note'}
@@ -927,6 +999,7 @@
note={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
@@ -936,6 +1009,7 @@
checklist={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>

View File

@@ -45,6 +45,7 @@
export let objectId: string;
export let trails: Trail[] = [];
export let measurementSystem: 'metric' | 'imperial' = 'metric';
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
const dispatch = createEventDispatcher();
@@ -228,7 +229,7 @@
}).localDate;
}
async function addVisit() {
async function addVisit(isAuto: boolean = false) {
// If editing an existing visit, patch instead of creating new
if (visitIdEditing) {
const response = await fetch(`/api/visits/${visitIdEditing}/`, {
@@ -279,12 +280,14 @@
}
}
// Reset form fields
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
// Reset form fields. If this call was an auto-generated visit, allow clearing even if initialVisitDate is set
if (!initialVisitDate || isAuto) {
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}
}
// Activity management functions
@@ -694,6 +697,43 @@
} catch {
stravaEnabled = false;
}
// If initialVisitDate is provided and a visit on that date doesn't exist, create and upload a new all day visit on that date
if (initialVisitDate) {
const targetDate = initialVisitDate.split('T')[0]; // Ensure we have just YYYY-MM-DD
// Check if any visit already exists for this date
const visitExists = visits?.some((visit) => {
const visitStart = visit.start_date.split('T')[0];
const visitEnd = visit.end_date.split('T')[0];
return targetDate >= visitStart && targetDate <= visitEnd;
});
if (!visitExists) {
// Set up an all-day visit for this date
allDay = true;
localStartDate = targetDate;
localEndDate = targetDate;
// Convert to UTC dates
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay: true
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: selectedStartTimezone,
allDay: true
}).utcDate;
// Automatically save the visit
await addVisit(true);
// Clear the initialVisitDate after successful creation
initialVisitDate = null;
}
}
});
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
@@ -852,7 +892,7 @@
class="btn btn-{typeConfig.color} btn-sm gap-2"
type="button"
disabled={!localStartDate || !isDateValid}
on:click={addVisit}
on:click={() => addVisit(false)}
>
<PlusIcon class="w-4 h-4" />
{visitIdEditing ? $t('adventures.update_visit') : $t('adventures.add_visit')}