feat: enhance LodgingCard and TransportationCard components with expandable details and improved layout

This commit is contained in:
Sean Morley
2026-01-03 14:48:53 -05:00
parent 6a4b965391
commit 84d176c028
5 changed files with 284 additions and 155 deletions

View File

@@ -133,61 +133,110 @@ def search_osm(query):
# -----------------
def extractIsoCode(user, data):
"""
Extract the ISO code from the response data.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
town_city_or_county = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = None
"""
Extract the ISO code from the response data.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = None
# town = None
# city = None
# county = None
if 'name' in data.keys():
location_name = data['name']
if 'name' in data.keys():
location_name = data['name']
if 'address' in data.keys():
keys = data['address'].keys()
for key in keys:
if key.find("ISO") != -1:
iso_code = data['address'][key]
address = data.get('address', {}) or {}
if not iso_code:
return {"error": "No region found"}
# Prefer the most specific ISO 3166-2 code available before falling back to country-level.
preferred_iso_keys = [
"ISO3166-2-lvl6",
"ISO3166-2-lvl5",
"ISO3166-2-lvl4",
"ISO3166-2-lvl3",
"ISO3166-2-lvl2",
"ISO3166-2-lvl1",
"ISO3166-2",
]
for key in preferred_iso_keys:
if key in address and address[key]:
iso_code = address[key]
break
# If no region-level code, fall back to country code only as a last resort.
if not iso_code and "ISO3166-1" in address:
iso_code = address.get("ISO3166-1")
# Capture country code early for matching by name if needed.
country_code = address.get("ISO3166-1")
state_name = address.get("state")
region = None
if iso_code and len(str(iso_code)) > 2:
region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user=user).first()
region_visited = False
city_visited = False
country_code = iso_code[:2]
if region:
if 'city' in keys:
city = City.objects.filter(name__contains=data['address']['city'], region=region).first()
if 'county' in keys and not city:
city = City.objects.filter(name__contains=data['address']['county'], region=region).first()
if 'town' in keys and not city:
city = City.objects.filter(name__contains=data['address']['town'], region=region).first()
if city:
display_name = f"{city.name}, {region.name}, {country_code}"
visited_city = VisitedCity.objects.filter(city=city, user=user).first()
if visited_region:
region_visited = True
if visited_city:
city_visited = True
# Fallback: attempt to resolve region by name and country code when no ISO match.
if not region and state_name:
region_queryset = Region.objects.filter(name__iexact=state_name)
if country_code:
region_queryset = region_queryset.filter(country__country_code=country_code)
region = region_queryset.first()
if region:
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "country_id": region.country.country_code, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
iso_code = region.id
if not country_code:
country_code = region.country.country_code
if not region:
return {"error": "No region found"}
visited_region = VisitedRegion.objects.filter(region=region, user=user).first()
region_visited = bool(visited_region)
city_visited = False
# ordered preference for best-effort locality matching
locality_keys = [
'suburb',
'city',
'town',
'village',
'hamlet',
'neighbourhood',
'neighborhood', # alternate spelling
'locality',
'county',
'municipality',
]
def match_locality(key_name):
value = address.get(key_name)
if not value:
return None
return City.objects.filter(name__icontains=value, region=region).first()
for key_name in locality_keys:
city = match_locality(key_name)
if city:
break
if city:
display_name = f"{city.name}, {region.name}, {country_code or region.country.country_code}"
visited_city = VisitedCity.objects.filter(city=city, user=user).first()
city_visited = bool(visited_city)
return {
"region_id": iso_code,
"region": region.name,
"country": region.country.name,
"country_id": region.country.country_code,
"region_visited": region_visited,
"display_name": display_name,
"city": city.name if city else None,
"city_id": city.id if city else None,
"city_visited": city_visited,
'location_name': location_name,
}
def is_host_resolvable(hostname: str) -> bool:
try:
socket.gethostbyname(hostname)
@@ -266,12 +315,22 @@ def _parse_google_address_components(components):
state_code = short_name
if "administrative_area_level_2" in types:
parsed["county"] = long_name
if "administrative_area_level_3" in types:
parsed["municipality"] = long_name
if "locality" in types:
parsed["city"] = long_name
if "postal_town" in types:
parsed.setdefault("city", long_name)
if "sublocality" in types:
parsed["town"] = long_name
if "neighborhood" in types:
parsed["neighbourhood"] = long_name
if "route" in types:
parsed["road"] = long_name
if "street_address" in types:
parsed["address"] = long_name
# Build composite ISO 3166-2 code like US-ME
# Build composite ISO 3166-2 code like US-ME (matches Region.id in DB)
if country_code and state_code:
parsed["ISO3166-2-lvl1"] = f"{country_code}-{state_code}"

View File

@@ -40,8 +40,6 @@
return stars;
}
const hasTimePortion = (date: string | null) => !!date && !isAllDay(date);
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
const getTimezoneLabel = (zone?: string | null) => zone ?? localTimeZone;
const getTimezoneTip = (zone?: string | null) => {
@@ -56,6 +54,14 @@
if (!zone) return false;
return getTimezoneLabel(zone) !== localTimeZone;
};
const hasTimePortion = (date: string | null) => !!date && !isAllDay(date);
const isTimedStay = (date: string | null) => hasTimePortion(date);
let showMoreDetails = false;
$: hasExpandableDetails = Boolean(
lodging.check_out && (isTimedStay(lodging.check_out) || isTimedStay(lodging.check_in))
);
$: if (!hasExpandableDetails) showMoreDetails = false;
export let lodging: Lodging;
export let user: User | null = null;
@@ -233,12 +239,12 @@
>
</div>
{:else}
<!-- Timed dates with mini cards -->
<div class="flex flex-col gap-1">
<!-- Check-in Card -->
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
<!-- Timed dates with tidy mini cards and toggle -->
<div class="flex flex-col gap-2">
<!-- Check-in Card (always shown) -->
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs text-base-content/60">Check-in</span>
<span class="text-sm font-semibold text-base-content">
{#if isAllDay(lodging.check_in)}
@@ -248,44 +254,67 @@
{/if}
</span>
</div>
{#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)}
</div>
{#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div class="tooltip" data-tip={getTimezoneTip(lodging.timezone) ?? undefined}>
<span class="badge badge-primary badge-sm">
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(lodging.timezone)}
</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Check-out Card -->
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-base-content/60">Check-out</span>
<span class="text-sm font-semibold text-base-content">
{#if isAllDay(lodging.check_out)}
{formatAllDayDate(lodging.check_out)}
{:else}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{/if}
</span>
</div>
{#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)}
<div class="tooltip" data-tip={getTimezoneTip(lodging.timezone) ?? undefined}>
<span class="badge badge-primary badge-sm">
{getTimezoneLabel(lodging.timezone)}
{#if hasExpandableDetails}
<div class="flex justify-end">
<button
class="btn btn-neutral-200 btn-xs"
aria-expanded={showMoreDetails}
on:click={() => (showMoreDetails = !showMoreDetails)}
type="button"
>
{showMoreDetails
? ($t('common.show_less') ?? 'Hide details')
: ($t('common.show_more') ?? 'Show more')}
</button>
</div>
{/if}
{#if showMoreDetails && hasExpandableDetails}
<!-- Check-out Card (expandable) -->
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs text-base-content/60">Check-out</span>
<span class="text-sm font-semibold text-base-content">
{#if isAllDay(lodging.check_out)}
{formatAllDayDate(lodging.check_out)}
{:else}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{/if}
</span>
</div>
</div>
{#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div class="tooltip" data-tip={getTimezoneTip(lodging.timezone) ?? undefined}>
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(lodging.timezone)}
</span>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
{:else if lodging.check_in}
<!-- Check-in only -->
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-base-content/60">Check-in</span>
<span class="text-sm font-semibold text-base-content">
@@ -296,19 +325,22 @@
{/if}
</span>
</div>
{#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)}
</div>
{#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div class="tooltip" data-tip={getTimezoneTip(lodging.timezone) ?? undefined}>
<span class="badge badge-primary badge-sm">
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(lodging.timezone)}
</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
{:else if lodging.check_out}
<!-- Check-out only -->
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-base-content/60">Check-out</span>
<span class="text-sm font-semibold text-base-content">
@@ -319,14 +351,17 @@
{/if}
</span>
</div>
{#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)}
</div>
{#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div class="tooltip" data-tip={getTimezoneTip(lodging.timezone) ?? undefined}>
<span class="badge badge-primary badge-sm">
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(lodging.timezone)}
</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -52,11 +52,6 @@
}: ${localTimeZone}.`;
};
const shouldShowTzBadge = (zone?: string | null) => {
if (!zone) return false;
return getTimezoneLabel(zone) !== localTimeZone;
};
export let transportation: Transportation;
export let user: User | null = null;
export let collection: Collection | null = null;
@@ -79,6 +74,18 @@
let travelDurationLabel: string | null = null;
$: travelDurationLabel = formatTravelDuration(transportation?.travel_duration_minutes ?? null);
let showMoreDetails = false;
$: hasCodePair = Boolean(transportation?.start_code && transportation?.end_code);
$: routeFromLabel = hasCodePair
? transportation.start_code
: (transportation.from_location ?? transportation.start_code ?? null);
$: routeToLabel = hasCodePair
? transportation.end_code
: (transportation.to_location ?? transportation.end_code ?? null);
$: hasExpandableDetails = Boolean(transportation?.end_date || travelDurationLabel);
$: if (!hasExpandableDetails) showMoreDetails = false;
$: routeGeojson =
transportation?.attachments?.find((attachment) => attachment?.geojson)?.geojson ?? null;
@@ -242,30 +249,26 @@
</div>
<!-- Route & Flight Info -->
{#if (transportation.start_code && transportation.end_code) || transportation.from_location || transportation.to_location}
{#if routeFromLabel || routeToLabel}
<div class="flex items-center gap-2 min-w-0">
{#if transportation.start_code && transportation.end_code}
<span class="text-base font-semibold text-base-content">{transportation.start_code}</span>
{#if routeFromLabel}
<span class="text-base font-semibold text-base-content truncate max-w-[10rem]"
>{routeFromLabel}</span
>
{/if}
{#if routeFromLabel && routeToLabel}
<span class="text-primary text-lg">→</span>
<span class="text-base font-semibold text-base-content">{transportation.end_code}</span>
{#if transportation.type === 'plane' && transportation.flight_number}
<div class="divider divider-horizontal mx-1"></div>
<span class="badge badge-primary badge-sm font-medium"
>{transportation.flight_number}</span
>
{/if}
{:else if transportation.from_location && transportation.to_location}
<span class="truncate max-w-[10rem] text-sm text-base-content/80"
>{transportation.from_location}</span
{/if}
{#if routeToLabel}
<span class="text-base font-semibold text-base-content truncate max-w-[10rem]"
>{routeToLabel}</span
>
<span class="text-primary">→</span>
<span class="truncate max-w-[10rem] text-sm text-base-content/80"
>{transportation.to_location}</span
{/if}
{#if hasCodePair && transportation.type === 'plane' && transportation.flight_number}
<div class="divider divider-horizontal mx-1"></div>
<span class="badge badge-primary badge-sm font-medium"
>{transportation.flight_number}</span
>
{:else if transportation.from_location}
<span class="truncate text-sm text-base-content/80">{transportation.from_location}</span>
{:else}
<span class="truncate text-sm text-base-content/80">{transportation.to_location}</span>
{/if}
</div>
{/if}
@@ -287,61 +290,89 @@
{/if}
</div>
{:else}
<!-- Timed events with mini cards -->
<div class="flex flex-col gap-1">
<!-- Departure Card -->
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-base-content/60">Departure</span>
<span class="text-sm font-semibold text-base-content">
{formatDateInTimezone(transportation.date, transportation.start_timezone)}
</span>
</div>
{#if shouldShowTzBadge(transportation.start_timezone)}
<div
class="tooltip"
data-tip={getTimezoneTip(transportation.start_timezone) ?? undefined}
>
<span class="badge badge-primary badge-sm">
{getTimezoneLabel(transportation.start_timezone)}
</span>
</div>
{/if}
<!-- Compact departure card with tidy layout -->
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs text-base-content/60">Departure</span>
<span class="text-sm font-semibold text-base-content">
{formatDateInTimezone(transportation.date, transportation.start_timezone)}
</span>
</div>
{#if hasCodePair}
<span class="badge badge-outline badge-sm font-medium whitespace-nowrap">
{transportation.start_code} → {transportation.end_code}
</span>
{/if}
</div>
<!-- Arrival Card -->
{#if transportation.end_date}
<div class="bg-base-200 rounded-lg px-3 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-base-content/60">Arrival</span>
<span class="text-sm font-semibold text-base-content">
{formatDateInTimezone(
transportation.end_date,
transportation.end_timezone ?? transportation.start_timezone
)}
</span>
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div
class="tooltip"
data-tip={getTimezoneTip(transportation.start_timezone) ?? undefined}
>
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(transportation.start_timezone)}
</span>
</div>
</div>
</div>
{#if hasExpandableDetails}
<div class="flex justify-end">
<button
class="btn btn-neutral-200 btn-xs"
aria-expanded={showMoreDetails}
on:click={() => (showMoreDetails = !showMoreDetails)}
type="button"
>
{showMoreDetails
? ($t('common.show_less') ?? 'Hide details')
: ($t('common.show_more') ?? 'Show more')}
</button>
</div>
{/if}
{#if showMoreDetails && hasExpandableDetails}
<div class="flex flex-col gap-1">
{#if transportation.end_date}
<div class="bg-base-200 rounded-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs text-base-content/60">Arrival</span>
<span class="text-sm font-semibold text-base-content">
{formatDateInTimezone(
transportation.end_date,
transportation.end_timezone ?? transportation.start_timezone
)}
</span>
</div>
</div>
{#if shouldShowTzBadge(transportation.end_timezone ?? transportation.start_timezone)}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<div
class="tooltip"
data-tip={getTimezoneTip(
transportation.end_timezone ?? transportation.start_timezone
) ?? undefined}
>
<span class="badge badge-primary badge-sm">
<span class="badge badge-ghost badge-sm">
{getTimezoneLabel(
transportation.end_timezone ?? transportation.start_timezone
)}
</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}
{#if travelDurationLabel}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<span class="badge badge-ghost badge-xs">⏱️ {travelDurationLabel}</span>
</div>
{/if}
</div>
{/if}
{/if}
</div>
{/if}

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.0-pre-dev-010226-7';
export let appVersion = 'v0.12.0-pre-dev-010326';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2026';

View File

@@ -1077,5 +1077,9 @@
"remove_from_itinerary": "Remove from Day",
"item_remove_success": "Item removed from itinerary",
"item_remove_error": "Error removing item from itinerary"
},
"common": {
"show_less": "Hide details",
"show_more": "Show more"
}
}