From 84d176c028851ce1be4fe5b29372c2ce773e49e5 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 3 Jan 2026 14:48:53 -0500 Subject: [PATCH] feat: enhance LodgingCard and TransportationCard components with expandable details and improved layout --- backend/server/adventures/geocoding.py | 155 +++++++++++------ .../lib/components/cards/LodgingCard.svelte | 119 ++++++++----- .../cards/TransportationCard.svelte | 159 +++++++++++------- frontend/src/lib/config.ts | 2 +- frontend/src/locales/en.json | 4 + 5 files changed, 284 insertions(+), 155 deletions(-) diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py index 90e95ebf..2dfa5cf5 100644 --- a/backend/server/adventures/geocoding.py +++ b/backend/server/adventures/geocoding.py @@ -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}" diff --git a/frontend/src/lib/components/cards/LodgingCard.svelte b/frontend/src/lib/components/cards/LodgingCard.svelte index 158c346e..80a8964b 100644 --- a/frontend/src/lib/components/cards/LodgingCard.svelte +++ b/frontend/src/lib/components/cards/LodgingCard.svelte @@ -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 @@ > {:else} - -
- -
-
-
+ +
+ +
+
+
Check-in {#if isAllDay(lodging.check_in)} @@ -248,44 +254,67 @@ {/if}
- {#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)} +
+ + {#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)} +
- + {getTimezoneLabel(lodging.timezone)}
- {/if} -
+
+ {/if}
- -
-
-
- Check-out - - {#if isAllDay(lodging.check_out)} - {formatAllDayDate(lodging.check_out)} - {:else} - {formatDateInTimezone(lodging.check_out, lodging.timezone)} - {/if} - -
- {#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)} -
- - {getTimezoneLabel(lodging.timezone)} + {#if hasExpandableDetails} +
+ +
+ {/if} + + {#if showMoreDetails && hasExpandableDetails} + +
+
+
+ Check-out + + {#if isAllDay(lodging.check_out)} + {formatAllDayDate(lodging.check_out)} + {:else} + {formatDateInTimezone(lodging.check_out, lodging.timezone)} + {/if}
+
+ + {#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)} +
+
+ + {getTimezoneLabel(lodging.timezone)} + +
+
{/if}
-
+ {/if}
{/if} {:else if lodging.check_in} -
-
+
+
Check-in @@ -296,19 +325,22 @@ {/if}
- {#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)} +
+ + {#if hasTimePortion(lodging.check_in) && shouldShowTzBadge(lodging.timezone)} +
- + {getTimezoneLabel(lodging.timezone)}
- {/if} -
+
+ {/if}
{:else if lodging.check_out} -
-
+
+
Check-out @@ -319,14 +351,17 @@ {/if}
- {#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)} +
+ + {#if hasTimePortion(lodging.check_out) && shouldShowTzBadge(lodging.timezone)} +
- + {getTimezoneLabel(lodging.timezone)}
- {/if} -
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/components/cards/TransportationCard.svelte b/frontend/src/lib/components/cards/TransportationCard.svelte index 1204de9d..3dc16f4a 100644 --- a/frontend/src/lib/components/cards/TransportationCard.svelte +++ b/frontend/src/lib/components/cards/TransportationCard.svelte @@ -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 @@
- {#if (transportation.start_code && transportation.end_code) || transportation.from_location || transportation.to_location} + {#if routeFromLabel || routeToLabel}
- {#if transportation.start_code && transportation.end_code} - {transportation.start_code} + {#if routeFromLabel} + {routeFromLabel} + {/if} + {#if routeFromLabel && routeToLabel} - {transportation.end_code} - {#if transportation.type === 'plane' && transportation.flight_number} -
- {transportation.flight_number} - {/if} - {:else if transportation.from_location && transportation.to_location} - {transportation.from_location}{routeToLabel} - - {transportation.to_location}
+ {transportation.flight_number} - {:else if transportation.from_location} - {transportation.from_location} - {:else} - {transportation.to_location} {/if}
{/if} @@ -287,61 +290,89 @@ {/if}
{:else} - -
- -
-
-
- Departure - - {formatDateInTimezone(transportation.date, transportation.start_timezone)} - -
- {#if shouldShowTzBadge(transportation.start_timezone)} -
- - {getTimezoneLabel(transportation.start_timezone)} - -
- {/if} + +
+
+
+ Departure + + {formatDateInTimezone(transportation.date, transportation.start_timezone)} +
+ {#if hasCodePair} + + {transportation.start_code} → {transportation.end_code} + + {/if}
- - {#if transportation.end_date} -
-
-
- Arrival - - {formatDateInTimezone( - transportation.end_date, - transportation.end_timezone ?? transportation.start_timezone - )} - +
+
+ + {getTimezoneLabel(transportation.start_timezone)} + +
+
+
+ + {#if hasExpandableDetails} +
+ +
+ {/if} + + {#if showMoreDetails && hasExpandableDetails} +
+ {#if transportation.end_date} +
+
+
+ Arrival + + {formatDateInTimezone( + transportation.end_date, + transportation.end_timezone ?? transportation.start_timezone + )} + +
- {#if shouldShowTzBadge(transportation.end_timezone ?? transportation.start_timezone)} + +
- + {getTimezoneLabel( transportation.end_timezone ?? transportation.start_timezone )}
- {/if} +
-
- {/if} -
+ {/if} + + {#if travelDurationLabel} +
+ ⏱️ {travelDurationLabel} +
+ {/if} +
+ {/if} {/if}
{/if} diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index ee7fd2f7..bac53742 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -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'; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 98d0322a..2c8552a4 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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" } }