feat(frontend): apply itinerary UI and docs refinements

This commit is contained in:
2026-03-08 14:42:32 +00:00
parent 9eb0325c7a
commit c5939e2957
21 changed files with 239 additions and 604 deletions

View File

@@ -23,8 +23,6 @@
import DeleteWarning from '../DeleteWarning.svelte';
import CardCarousel from '../CardCarousel.svelte';
import { t } from 'svelte-i18n';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
@@ -112,16 +110,6 @@
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
: adventure.user?.username || 'Unknown User';
// Helper functions for display
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
function changeDay() {
dispatch('changeDay', { type: 'location', item: adventure, forcePicker: true });
}
@@ -577,7 +565,7 @@
</div>
</div>
<!-- Inline stats: location, rating, visits -->
<!-- Inline stats: location and price -->
<div
class="flex flex-wrap items-center text-base-content/70 min-w-0"
class:gap-2={compact}
@@ -592,21 +580,6 @@
</div>
{/if}
{#if adventure.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#each renderStars(adventure.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
{:else}
<StarOutline class="w-4 h-4 text-base-content/30" />
{/if}
{/each}
</div>
<span class="text-xs text-base-content/60">({adventure.rating}/5)</span>
</div>
{/if}
{#if adventurePriceLabel}
<span class="badge badge-ghost badge-sm whitespace-nowrap">💰 {adventurePriceLabel}</span>
{/if}

View File

@@ -14,8 +14,6 @@
import CardCarousel from '../CardCarousel.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import MapMarker from '~icons/mdi/map-marker';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
@@ -61,14 +59,6 @@
}
}
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
export let lodging: Lodging;
export let user: User | null = null;
export let collection: Collection | null = null;
@@ -532,23 +522,8 @@
</div>
{/if}
<!-- Rating & Info Badges -->
<!-- Info Badges -->
<div class="flex flex-wrap items-center gap-2 text-sm">
{#if lodging.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#each renderStars(lodging.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
{:else}
<StarOutline class="w-4 h-4 text-base-content/30" />
{/if}
{/each}
</div>
<span class="text-xs text-base-content/60">({lodging.rating}/5)</span>
</div>
{/if}
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if lodging.reservation_number}
<span class="badge badge-primary badge-sm font-medium">

View File

@@ -15,8 +15,6 @@
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import Calendar from '~icons/mdi/calendar';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
@@ -59,14 +57,6 @@
}
}
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
const dispatch = createEventDispatcher();
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
@@ -86,6 +76,8 @@
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
export let compact: boolean = false;
export let showImage: boolean = true;
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
@@ -173,62 +165,77 @@
{/if}
<div
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
class="card w-full bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
class:max-w-md={!compact}
aria-label="transportation-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
{#if routeGeojson}
<TransportationRoutePreview
geojson={routeGeojson}
name={transportation.name}
images={transportation.images}
/>
{:else}
<CardCarousel
images={transportation.images}
icon={getTransportationIcon(transportation.type)}
name={transportation.name}
/>
{/if}
{#if showImage}
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
{#if routeGeojson}
<TransportationRoutePreview
geojson={routeGeojson}
name={transportation.name}
images={transportation.images}
/>
{:else}
<CardCarousel
images={transportation.images}
icon={getTransportationIcon(transportation.type)}
name={transportation.name}
/>
{/if}
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={transportation.is_public
? $t('adventures.public')
: $t('adventures.private')}
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
</div>
{/if}
</div>
{/if}
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-4 space-y-3 min-w-0">
<div
class="card-body min-w-0"
class:p-3={compact}
class:p-4={!compact}
class:space-y-2={compact}
class:space-y-3={!compact}
>
<!-- Header -->
<div class="flex items-start justify-between gap-3">
<a
href="/transportations/{transportation.id}"
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-lg font-semibold"
class="hover:text-primary transition-colors duration-200 line-clamp-2"
class:text-base={compact}
class:text-lg={!compact}
class:font-medium={compact}
class:font-semibold={!compact}
>
{transportation.name}
</a>
@@ -343,6 +350,33 @@
</div>
</div>
{#if !showImage}
<div class="flex items-center gap-2 text-xs text-base-content/70 min-w-0">
<div
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
{#if transportation.type}
<div class="badge badge-primary badge-sm font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
{/if}
</div>
{/if}
<!-- Route & Flight Info -->
{#if routeFromLabel || routeToLabel}
<div class="flex items-center gap-2 min-w-0">
@@ -466,7 +500,7 @@
</div>
{/if}
<!-- Stats & Rating -->
<!-- Stats -->
<div class="flex flex-wrap items-center gap-2 text-sm">
{#if transportationPriceLabel}
<span class="badge badge-ghost badge-sm">💰 {transportationPriceLabel}</span>
@@ -483,20 +517,6 @@
<span class="badge badge-ghost badge-sm">⏱️ {travelDurationLabel}</span>
{/if}
{#if transportation.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#each renderStars(transportation.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
{:else}
<StarOutline class="w-4 h-4 text-base-content/30" />
{/if}
{/each}
</div>
<span class="text-xs text-base-content/60">({transportation.rating}/5)</span>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -408,17 +408,6 @@
return '🚗';
}
function formatTransportationDuration(minutes: number | null | undefined): string | null {
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return null;
const safeMinutes = Math.max(0, Math.floor(minutes));
const hours = Math.floor(safeMinutes / 60);
const mins = safeMinutes % 60;
const parts = [] as string[];
if (hours) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
function formatTransportationDistance(distanceKm: number | null | undefined): string | null {
if (distanceKm === null || distanceKm === undefined || Number.isNaN(distanceKm)) return null;
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
@@ -509,7 +498,8 @@
};
};
type ConnectableItemType = 'location' | 'lodging';
type ConnectableItemType = 'location' | 'lodging' | 'transportation';
type TransportationCoordinateRole = 'origin' | 'destination';
type RouteMetricResult = {
distance_label?: string;
@@ -523,17 +513,37 @@
let activeConnectorFetchVersion = 0;
function isConnectableItemType(type: string): type is ConnectableItemType {
return type === 'location' || type === 'lodging';
return type === 'location' || type === 'lodging' || type === 'transportation';
}
function getCoordinatesFromItineraryItem(
item: ResolvedItineraryItem | null
item: ResolvedItineraryItem | null,
transportationRole: TransportationCoordinateRole = 'origin'
): { latitude: number; longitude: number } | null {
if (!item) return null;
const itemType = item.item?.type || '';
if (!isConnectableItemType(itemType)) return null;
if (itemType === 'transportation') {
const transportation = item.resolvedObject as Transportation | null;
if (!transportation) return null;
const latitude = normalizeCoordinate(
transportationRole === 'origin'
? transportation.origin_latitude
: transportation.destination_latitude
);
const longitude = normalizeCoordinate(
transportationRole === 'origin'
? transportation.origin_longitude
: transportation.destination_longitude
);
if (latitude === null || longitude === null) return null;
return { latitude, longitude };
}
const resolvedObj = item.resolvedObject as Location | Lodging | null;
if (!resolvedObj) return null;
@@ -544,20 +554,20 @@
return { latitude, longitude };
}
function getFirstLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
function getFirstConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (const item of items) {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if ((item.item?.type || '') === 'location') return item;
if (isConnectableItemType(item.item?.type || '')) return item;
}
return null;
}
function getLastLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
function getLastConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if ((item.item?.type || '') === 'location') return item;
if (isConnectableItemType(item.item?.type || '')) return item;
}
return null;
@@ -691,8 +701,14 @@
const nextType = nextItem.item?.type || '';
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const fromCoordinates = getCoordinatesFromItineraryItem(currentItem);
const toCoordinates = getCoordinatesFromItineraryItem(nextItem);
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return null;
const key = getLocationConnectorKey(currentItem, nextItem);
@@ -705,7 +721,7 @@
};
}
function findNextLocationItem(
function findNextConnectableItem(
items: ResolvedItineraryItem[],
currentIndex: number
): ResolvedItineraryItem | null {
@@ -714,7 +730,7 @@
if (candidate?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
continue;
}
if ((candidate?.item?.type || '') === 'location') {
if (isConnectableItemType(candidate?.item?.type || '')) {
return candidate;
}
}
@@ -734,11 +750,11 @@
for (const dayGroup of dayGroups) {
const dayTimelineItems = getDayTimelineItems(dayGroup);
const firstLocationItem = getFirstLocationItem(dayGroup.items);
const lastLocationItem = getLastLocationItem(dayGroup.items);
const firstConnectableItem = getFirstConnectableItem(dayGroup.items);
const lastConnectableItem = getLastConnectableItem(dayGroup.items);
if (dayGroup.preTimelineLodging && firstLocationItem) {
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstLocationItem));
if (dayGroup.preTimelineLodging && firstConnectableItem) {
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstConnectableItem));
}
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
@@ -746,12 +762,12 @@
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
continue;
}
const nextLocationItem = findNextLocationItem(dayTimelineItems, index);
pushPair(getConnectorPair(currentItem, nextLocationItem));
const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index);
pushPair(getConnectorPair(currentItem, nextConnectableItem));
}
if (dayGroup.postTimelineLodging && lastLocationItem) {
pushPair(getConnectorPair(lastLocationItem, dayGroup.postTimelineLodging));
if (dayGroup.postTimelineLodging && lastConnectableItem) {
pushPair(getConnectorPair(lastConnectableItem, dayGroup.postTimelineLodging));
}
}
@@ -885,11 +901,20 @@
unavailable: true
};
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
if (!currentLocation || !nextLocation) return unavailableConnector;
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return unavailableConnector;
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
const distanceKm = haversineDistanceKm(
{ latitude: fromCoordinates.latitude, longitude: fromCoordinates.longitude } as Location,
{ latitude: toCoordinates.latitude, longitude: toCoordinates.longitude } as Location
);
if (distanceKm === null) return unavailableConnector;
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
@@ -926,14 +951,20 @@
const nextType = nextItem.item?.type || '';
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
if (!currentLocation || !nextLocation) return null;
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return null;
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
const fromLongitude = normalizeCoordinate(currentLocation.longitude);
const toLatitude = normalizeCoordinate(nextLocation.latitude);
const toLongitude = normalizeCoordinate(nextLocation.longitude);
const fromLatitude = fromCoordinates.latitude;
const fromLongitude = fromCoordinates.longitude;
const toLatitude = toCoordinates.latitude;
const toLongitude = toCoordinates.longitude;
if (
fromLatitude === null ||
@@ -955,50 +986,6 @@
return translated && translated !== key ? translated : fallback;
}
function editTransportationInline(transportation: Transportation) {
handleEditTransportation({ detail: transportation } as CustomEvent<Transportation>);
}
async function removeItineraryEntry(item: CollectionItineraryItem) {
if (!item?.id) return;
try {
const res = await fetch(`/api/itineraries/${item.id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to remove itinerary item');
handleRemoveItineraryItem(new CustomEvent('removeFromItinerary', { detail: item }) as any);
addToast('info', $t('itinerary.item_remove_success'));
} catch (error) {
console.error('Error removing itinerary item:', error);
addToast('error', $t('itinerary.item_remove_error'));
}
}
async function deleteTransportationFromItinerary(
item: CollectionItineraryItem,
transportation: Transportation
) {
const confirmed = window.confirm($t('adventures.transportation_delete_confirm'));
if (!confirmed) return;
try {
const res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) throw new Error('Failed to delete transportation');
addToast('info', $t('transportation.transportation_deleted'));
handleItemDelete(new CustomEvent('delete', { detail: transportation.id }) as any);
} catch (error) {
console.error('Failed to delete transportation:', error);
addToast('error', $t('transportation.transportation_delete_error'));
}
}
function upsertNote(note: Note) {
const notes = collection.notes ? [...collection.notes] : [];
const idx = notes.findIndex((n) => n.id === note.id);
@@ -2399,6 +2386,9 @@
transportation={resolvedObj}
{user}
{collection}
readOnly={!canModify}
compact={true}
showImage={false}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
@@ -2463,34 +2453,34 @@
{@const preTimelineLodging = day.preTimelineLodging}
{@const postTimelineLodging = day.postTimelineLodging}
{@const dayTimelineItems = getDayTimelineItems(day)}
{@const firstLocationItem = getFirstLocationItem(day.items)}
{@const lastLocationItem = getLastLocationItem(day.items)}
{@const noLocationsInDay = !firstLocationItem && !lastLocationItem}
{@const firstConnectableItem = getFirstConnectableItem(day.items)}
{@const lastConnectableItem = getLastConnectableItem(day.items)}
{@const noLocationsInDay = !firstConnectableItem && !lastConnectableItem}
{@const shouldCollapseBoundaryLodging =
noLocationsInDay &&
preTimelineLodging?.id &&
postTimelineLodging?.id &&
preTimelineLodging.id === postTimelineLodging.id}
{@const startBoundaryConnector =
preTimelineLodging && firstLocationItem
? getLocationConnector(preTimelineLodging, firstLocationItem)
preTimelineLodging && firstConnectableItem
? getLocationConnector(preTimelineLodging, firstConnectableItem)
: null}
{@const startBoundaryDirectionsUrl =
preTimelineLodging && firstLocationItem
preTimelineLodging && firstConnectableItem
? buildDirectionsUrl(
preTimelineLodging,
firstLocationItem,
firstConnectableItem,
startBoundaryConnector?.mode || 'walking'
)
: null}
{@const endBoundaryConnector =
postTimelineLodging && lastLocationItem
? getLocationConnector(lastLocationItem, postTimelineLodging)
postTimelineLodging && lastConnectableItem
? getLocationConnector(lastConnectableItem, postTimelineLodging)
: null}
{@const endBoundaryDirectionsUrl =
postTimelineLodging && lastLocationItem
postTimelineLodging && lastConnectableItem
? buildDirectionsUrl(
lastLocationItem,
lastConnectableItem,
postTimelineLodging,
endBoundaryConnector?.mode || 'walking'
)
@@ -2737,11 +2727,11 @@
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const nextLocationItem = findNextLocationItem(dayTimelineItems, index)}
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
{@const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index)}
{@const locationConnector = getLocationConnector(item, nextConnectableItem)}
{@const directionsUrl = buildDirectionsUrl(
item,
nextLocationItem,
nextConnectableItem,
locationConnector?.mode || 'walking'
)}
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
@@ -2797,84 +2787,25 @@
{/if}
{#if objectType === 'transportation'}
<div class="rounded-xl border border-base-300 bg-base-100 px-4 py-3">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg"
>{getTransportationIcon(resolvedObj.type)}</span
>
<p class="font-semibold truncate">{resolvedObj.name}</p>
<span class="badge badge-outline badge-sm truncate">
{$t(`transportation.modes.${resolvedObj.type}`) ||
resolvedObj.type}
</span>
</div>
<div class="text-xs opacity-70 flex items-center gap-2 shrink-0">
{#if formatTransportationDuration(resolvedObj.travel_duration_minutes)}
<span
>{formatTransportationDuration(
resolvedObj.travel_duration_minutes
)}</span
>
{/if}
{#if formatTransportationDistance(resolvedObj.distance)}
<span>{formatTransportationDistance(resolvedObj.distance)}</span
>
{/if}
</div>
</div>
<div class="text-sm opacity-80 truncate">
{resolvedObj.from_location || '—'} → {resolvedObj.to_location ||
'—'}
</div>
{#if canModify}
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="btn btn-xs btn-ghost"
on:click={() => editTransportationInline(resolvedObj)}
>
{$t('transportation.edit')}
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
on:click={() =>
handleOpenDayPickerForItem(
'transportation',
resolvedObj,
true,
day.date
)}
>
{$t('itinerary.change_day')}
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
on:click={() =>
moveItemToGlobal('transportation', resolvedObj.id)}
>
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
on:click={() => removeItineraryEntry(item)}
>
{$t('itinerary.remove_from_itinerary')}
</button>
<button
type="button"
class="btn btn-xs btn-error btn-outline"
on:click={() =>
deleteTransportationFromItinerary(item, resolvedObj)}
>
{$t('adventures.delete')}
</button>
</div>
{/if}
</div>
<TransportationCard
transportation={resolvedObj}
{user}
{collection}
itineraryItem={item}
compact={true}
showImage={false}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditTransportation}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
on:changeDay={(e) =>
handleOpenDayPickerForItem(
e.detail.type,
e.detail.item,
e.detail.forcePicker,
day.date
)}
/>
{:else}
{#if multiDay && objectType === 'lodging'}
<div class="mb-2">
@@ -3443,6 +3374,9 @@
transportation={item}
{user}
{collection}
readOnly={!canModify}
compact={true}
showImage={false}
on:delete={handleItemDelete}
on:edit={handleEditTransportation}
/>

View File

@@ -355,12 +355,6 @@
return Array.from(types.values()).sort((a, b) => b.distance - a.distance);
})();
$: 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;
@@ -743,18 +737,9 @@
</div>
<!-- Additional Stats Row -->
{#if averageLocationRating > 0 || checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
{#if checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
<div class="divider">{$t('adventures.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">{$t('adventures.avg_rating')}</div>
<div class="stat-value text-lg">{averageLocationRating.toFixed(1)}</div>
<div class="stat-desc text-xs">{$t('adventures.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>

View File

@@ -15,7 +15,6 @@
import GenerateIcon from '~icons/mdi/lightning-bolt';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
import SaveIcon from '~icons/mdi/content-save';
import ClearIcon from '~icons/mdi/close-circle';
const dispatch = createEventDispatcher();
@@ -34,7 +33,6 @@
let location: {
name: string;
category: Category | null;
rating: number;
price: number | null;
price_currency: string | null;
is_public: boolean;
@@ -48,7 +46,6 @@
} = {
name: '',
category: null,
rating: NaN,
price: null,
price_currency: DEFAULT_CURRENCY,
is_public: false,
@@ -239,7 +236,6 @@
if (!location.name) location.name = initialLocation.name || '';
if (!location.link) location.link = initialLocation.link || '';
if (!location.description) location.description = initialLocation.description || '';
if (Number.isNaN(location.rating)) location.rating = initialLocation.rating || NaN;
if (location.price === null || location.price === undefined) {
const money = toMoneyValue(
initialLocation.price,
@@ -359,44 +355,6 @@
}}
/>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<div
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
<div class="rating">
<input
type="radio"
name="rating"
id="rating"
class="rating-hidden"
checked={Number.isNaN(location.rating)}
/>
{#each [1, 2, 3, 4, 5] as star}
<input
type="radio"
name="rating"
class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = star)}
checked={location.rating === star}
/>
{/each}
</div>
{#if !Number.isNaN(location.rating)}
<button
type="button"
class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (location.rating = NaN)}
>
<ClearIcon class="w-4 h-4" />
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->

View File

@@ -53,7 +53,6 @@
link: null,
description: null,
tags: [],
rating: NaN,
price: null,
price_currency: null,
is_public: false,
@@ -81,7 +80,6 @@
link: locationToEdit?.link || null,
description: locationToEdit?.description || null,
tags: locationToEdit?.tags || [],
rating: locationToEdit?.rating || NaN,
price: locationToEdit?.price ?? null,
price_currency: locationToEdit?.price_currency ?? null,
is_public: locationToEdit?.is_public || false,
@@ -295,7 +293,6 @@
on:save={(e) => {
location.name = e.detail.name;
location.category = e.detail.category;
location.rating = e.detail.rating;
location.is_public = e.detail.is_public;
location.link = e.detail.link;
location.description = e.detail.description;

View File

@@ -7,7 +7,6 @@
// Icons
import MapIcon from '~icons/mdi/map';
import ClearIcon from '~icons/mdi/close';
import InfoIcon from '~icons/mdi/information';
import GenerateIcon from '~icons/mdi/lightning-bolt';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
@@ -49,7 +48,6 @@
name: string;
type: string;
description: string;
rating: number;
link: string;
check_in: string | null;
check_out: string | null;
@@ -67,7 +65,6 @@
name: '',
type: '',
description: '',
rating: NaN,
link: '',
check_in: null,
check_out: null,
@@ -442,7 +439,6 @@
lodging.type = initialLodging.type || '';
lodging.link = initialLodging.link || '';
lodging.description = initialLodging.description || '';
lodging.rating = initialLodging.rating ?? NaN;
lodging.is_public = initialLodging.is_public ?? true;
lodging.reservation_number = initialLodging.reservation_number || null;
const money = toMoneyValue(
@@ -565,45 +561,6 @@
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<div
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
<div class="rating">
<input
type="radio"
name="rating"
id="rating"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
{#each [1, 2, 3, 4, 5] as star}
<input
type="radio"
name="rating"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = star)}
checked={lodging.rating === star}
/>
{/each}
</div>
{#if !Number.isNaN(lodging.rating)}
<button
type="button"
class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (lodging.rating = NaN)}
>
<ClearIcon class="w-4 h-4" />
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
<!-- Reservation Number -->
<div class="form-control">
<label class="label" for="reservation">

View File

@@ -41,7 +41,6 @@
name: '',
type: '',
description: null,
rating: null,
link: null,
check_in: null,
check_out: null,
@@ -84,7 +83,6 @@
name: lodgingToEdit.name || '',
type: lodgingToEdit.type || '',
description: lodgingToEdit.description || null,
rating: lodgingToEdit.rating || null,
link: lodgingToEdit.link || null,
check_in: lodgingToEdit.check_in || null,
check_out: lodgingToEdit.check_out || null,

View File

@@ -7,7 +7,6 @@
// Icons
import MapIcon from '~icons/mdi/map';
import ClearIcon from '~icons/mdi/close';
import InfoIcon from '~icons/mdi/information';
import GenerateIcon from '~icons/mdi/lightning-bolt';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
@@ -50,7 +49,6 @@
name: '',
type: '',
description: '',
rating: NaN,
link: '',
date: null,
end_date: null,
@@ -516,7 +514,6 @@
transportation.type = initialTransportation.type || '';
transportation.link = initialTransportation.link || '';
transportation.description = initialTransportation.description || '';
transportation.rating = initialTransportation.rating ?? NaN;
transportation.is_public = initialTransportation.is_public ?? true;
transportation.flight_number = initialTransportation.flight_number || null;
transportation.start_code = initialTransportation.start_code || null;
@@ -714,44 +711,6 @@
</div>
{/if}
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<div
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
<div class="rating">
<input
type="radio"
name="rating"
id="rating"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
{#each [1, 2, 3, 4, 5] as star}
<input
type="radio"
name="rating"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = star)}
checked={transportation.rating === star}
/>
{/each}
</div>
{#if !Number.isNaN(transportation.rating)}
<button
type="button"
class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (transportation.rating = NaN)}
>
<ClearIcon class="w-4 h-4" />
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->

View File

@@ -41,7 +41,6 @@
name: '',
type: '',
description: null,
rating: null,
link: null,
date: null,
end_date: null,
@@ -91,7 +90,6 @@
name: transportationToEdit.name || '',
type: transportationToEdit.type || '',
description: transportationToEdit.description || null,
rating: transportationToEdit.rating || null,
link: transportationToEdit.link || null,
date: transportationToEdit.date || null,
end_date: transportationToEdit.end_date || null,

View File

@@ -45,7 +45,6 @@ export type Location = {
location?: string | null;
tags?: string[] | null;
description?: string | null;
rating?: number | null;
price?: number | null;
price_currency?: string | null;
link?: string | null;
@@ -202,7 +201,6 @@ export type Transportation = {
type: string;
name: string;
description: string | null;
rating: number | null;
price: number | null;
price_currency: string | null;
link: string | null;
@@ -341,7 +339,6 @@ export type Lodging = {
name: string;
type: string;
description: string | null;
rating: number | null;
link: string | null;
check_in: string | null; // ISO 8601 date string
check_out: string | null; // ISO 8601 date string