From bd23c3fc9e89dbd2012c23925a60462848eca08b Mon Sep 17 00:00:00 2001 From: Luca Becker Date: Mon, 17 Aug 2026 20:18:31 +0200 Subject: [PATCH] fix(home): show last stop instead of live cues after trip ends GET /api/home still returns the most recent stop when nothing matches today, so the card kept "Currently At", the pulse dot, and an "On the Road" postmark after Vilnius had ended. Add current_stop_is_live and drive CurrentLocationCard from it, with stopLive.js as a fallback when older APIs omit the field so web-before-API deploys do not mark every trip ended. Open-ended stops now stay live through departure day, stale is_current on past stops is ignored, and trip/stop badges share the same rules. Postmark dates use the calendar portion of stop dates to avoid timezone drift. --- api/app/routers/journey.py | 24 ++++++- api/app/schemas/journey.py | 1 + api/tests/test_home_current_stop.py | 70 +++++++++++++++++++ .../components/ui/CurrentLocationCard.astro | 58 ++++++++++++--- web/src/lib/stopLive.js | 35 ++++++++++ web/src/pages/index.astro | 8 ++- web/src/pages/trips/[slug].astro | 14 ++-- web/src/pages/trips/[slug]/stops/[stop].astro | 4 +- 8 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 api/tests/test_home_current_stop.py create mode 100644 web/src/lib/stopLive.js diff --git a/api/app/routers/journey.py b/api/app/routers/journey.py index 119b05a..6da6cdd 100644 --- a/api/app/routers/journey.py +++ b/api/app/routers/journey.py @@ -120,14 +120,28 @@ def _is_visible_stop_status(stop: Stop) -> bool: def _contains_today(stop: Stop, today) -> bool: start = stop.start_date.date() if stop.start_date else None - end = stop.end_date.date() if stop.end_date else start if not start: return False - if end is None: - end = start + if not stop.end_date: + return start <= today + end = stop.end_date.date() return start <= today <= end +def _is_live_current_stop(stop: Stop, today) -> bool: + if _contains_today(stop, today): + return True + # Honor stale is_current only when the stop hasn't clearly ended. + if stop.is_current: + start = stop.start_date.date() if stop.start_date else None + if start and start > today: + return False + end = stop.end_date.date() if stop.end_date else None + if end is None or end >= today: + return True + return False + + async def _coordinates_for_stops(session: AsyncSession, stops) -> dict[uuid.UUID, tuple[float, float]]: stop_ids = [stop.id for stop in stops] if not stop_ids: @@ -351,9 +365,13 @@ async def get_home( has_more = has_more_posts or len(past_stops) > 5 weather = await _cached_weather_and_publish_coords(current_stop) + current_stop_is_live = ( + _is_live_current_stop(current_stop_model, today) if current_stop_model else False + ) return HomeOut( current_stop=current_stop, + current_stop_is_live=current_stop_is_live, next_stop=_stop_out(next_stop_model, coords, user), previous_stop=_stop_out(previous_stop_model, coords, user), recent_stops=[stop_out for s in recent_stop_models if (stop_out := _stop_out(s, coords, user))], diff --git a/api/app/schemas/journey.py b/api/app/schemas/journey.py index f2b23ec..e62e35e 100644 --- a/api/app/schemas/journey.py +++ b/api/app/schemas/journey.py @@ -138,6 +138,7 @@ class WeatherOut(BaseModel): class HomeOut(BaseModel): current_stop: Optional[PublicStopSummary] = None + current_stop_is_live: bool = False next_stop: Optional[PublicStopSummary] = None previous_stop: Optional[PublicStopSummary] = None recent_stops: List[PublicStopSummary] = [] diff --git a/api/tests/test_home_current_stop.py b/api/tests/test_home_current_stop.py new file mode 100644 index 0000000..ae7c2d8 --- /dev/null +++ b/api/tests/test_home_current_stop.py @@ -0,0 +1,70 @@ +from datetime import date, datetime, timezone +from types import SimpleNamespace + +from app.routers.journey import _contains_today, _is_live_current_stop + + +def _stop(*, start, end=None, is_current=False): + return SimpleNamespace( + start_date=datetime.combine(start, datetime.min.time(), tzinfo=timezone.utc), + end_date=datetime.combine(end, datetime.min.time(), tzinfo=timezone.utc) if end else None, + is_current=is_current, + ) + + +def test_live_when_stop_date_range_includes_today(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 15), end=date(2026, 8, 20)) + + assert _is_live_current_stop(stop, today) is True + + +def test_not_live_for_past_stop_without_is_current(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 9), end=date(2026, 8, 12)) + + assert _is_live_current_stop(stop, today) is False + + +def test_not_live_for_past_stop_with_stale_is_current(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 9), end=date(2026, 8, 12), is_current=True) + + assert _is_live_current_stop(stop, today) is False + + +def test_live_for_open_ended_stop_with_is_current(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 10), is_current=True) + + assert _is_live_current_stop(stop, today) is True + + +def test_live_for_open_ended_stop_without_is_current(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 10)) + + assert _contains_today(stop, today) is True + assert _is_live_current_stop(stop, today) is True + + +def test_not_live_for_future_stop(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 20), end=date(2026, 8, 25)) + + assert _is_live_current_stop(stop, today) is False + + +def test_not_live_for_future_stop_with_stale_is_current(): + today = date(2026, 8, 17) + stop = _stop(start=date(2026, 8, 20), end=date(2026, 8, 25), is_current=True) + + assert _is_live_current_stop(stop, today) is False + + +def test_live_on_end_date(): + today = date(2026, 8, 13) + stop = _stop(start=date(2026, 8, 9), end=date(2026, 8, 13)) + + assert _contains_today(stop, today) is True + assert _is_live_current_stop(stop, today) is True diff --git a/web/src/components/ui/CurrentLocationCard.astro b/web/src/components/ui/CurrentLocationCard.astro index 6f4abb0..f862a13 100644 --- a/web/src/components/ui/CurrentLocationCard.astro +++ b/web/src/components/ui/CurrentLocationCard.astro @@ -35,27 +35,53 @@ interface Props { nights?: number; note?: string; label?: string; + isLive?: boolean; + departureDate?: string; prevStop?: StopMention; nextStop?: StopMention; weather?: Weather | null; } -const { stopTitle, stopSlug, tripTitle, tripSlug, placeName, summary, arrivalDate, nights, note, label = 'Currently At', prevStop, nextStop, weather } = Astro.props; +const { + stopTitle, + stopSlug, + tripTitle, + tripSlug, + placeName, + summary, + arrivalDate, + nights, + note, + label = 'Currently At', + isLive = false, + departureDate, + prevStop, + nextStop, + weather, +} = Astro.props; function formatDate(dateStr?: string) { if (!dateStr) return ''; return fmtDate(dateStr, { weekday: 'short', month: 'short', day: 'numeric' }) || dateStr; } +function dateOnlyStampSource(dateStr?: string) { + if (!dateStr) return undefined; + const match = dateStr.match(/^(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : dateStr; +} + function stampDate(dateStr?: string) { - if (!dateStr) return 'ON ROAD'; - return fmtDate(dateStr, { month: 'short', day: 'numeric' }) || dateStr; + const normalized = dateOnlyStampSource(dateStr); + if (!normalized) return 'ON ROAD'; + return fmtDate(normalized, { month: 'short', day: 'numeric' }) || normalized; } function stampYear(dateStr?: string) { - if (!dateStr) return ''; + const normalized = dateOnlyStampSource(dateStr); + if (!normalized) return ''; try { - return String(new Date(dateStr).getFullYear()); + return normalized.slice(0, 4); } catch { return ''; } @@ -89,14 +115,16 @@ const primaryTitle = cityState?.label || stopTitle; const secondaryTitle = cityState ? stopTitle : placeName && placeName !== stopTitle ? placeName : ''; const postmarkCity = cityState?.city || stopTitle; const postmarkState = cityState?.state || ''; +const postmarkLandmark = isLive ? (postmarkState || 'On the Road') : 'Last Stop'; +const postmarkDateSource = isLive ? arrivalDate : (departureDate || arrivalDate); ---
-
- +
+ {isLive && } {label && {label}}
@@ -134,13 +162,13 @@ const postmarkState = cityState?.state || ''; )}
-
+
@@ -227,6 +255,14 @@ const postmarkState = cityState?.state || ''; margin-bottom: 14px; } + .current-status--past .eyebrow { + color: color-mix(in srgb, var(--muted) 82%, var(--paper) 18%); + } + + .current-postmark--past { + opacity: 0.72; + } + .current-title { margin: 0 0 10px; color: var(--paper); diff --git a/web/src/lib/stopLive.js b/web/src/lib/stopLive.js new file mode 100644 index 0000000..2f450be --- /dev/null +++ b/web/src/lib/stopLive.js @@ -0,0 +1,35 @@ +const DATE_PREFIX_RE = /^(\d{4}-\d{2}-\d{2})/; + +function parseStopDate(value) { + if (!value) return null; + const match = typeof value === 'string' && value.match(DATE_PREFIX_RE); + if (match) return match[1]; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toISOString().slice(0, 10); +} + +function todayUtcDate() { + return new Date().toISOString().slice(0, 10); +} + +/** Mirrors journey._contains_today using UTC calendar dates. */ +export function stopContainsToday(stop, today = todayUtcDate()) { + const start = parseStopDate(stop?.start_date); + if (!start) return false; + const end = parseStopDate(stop?.end_date); + if (!end) return start <= today; + return start <= today && today <= end; +} + +/** Mirrors journey._is_live_current_stop using UTC calendar dates. */ +export function stopIsLive(stop, today = todayUtcDate()) { + if (stopContainsToday(stop, today)) return true; + if (!stop?.is_current) return false; + + const start = parseStopDate(stop?.start_date); + if (start && start > today) return false; + + const end = parseStopDate(stop?.end_date); + return !end || end >= today; +} diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 43cf898..73fd2fe 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -6,6 +6,7 @@ import EmptyState from '../components/ui/EmptyState.astro'; import { apiUrl } from '../lib/api.js'; import { formatDate } from '../lib/dates.js'; import { loadSiteText, sectionText } from '../lib/siteText.js'; +import { stopIsLive } from '../lib/stopLive.js'; // Kick off the independent site-text fetch up front so it runs concurrently // with the home-data fetch instead of serially after it. @@ -30,6 +31,9 @@ try { } const current = homeData?.current_stop; +const currentStopIsLive = typeof homeData?.current_stop_is_live === 'boolean' + ? homeData.current_stop_is_live + : (current ? stopIsLive(current) : false); const activeTrip = homeData?.active_trip_segment; const nextStop = homeData?.next_stop; const previousStop = homeData?.previous_stop; @@ -156,9 +160,11 @@ function formatStopDate(dateStr) { placeName={current.place_name} summary={current.summary} arrivalDate={current.start_date} + departureDate={current.end_date} nights={current.nights} note={current.public_note} - label={currentText.label} + label={currentStopIsLive ? currentText.label : 'Last Stop'} + isLive={currentStopIsLive} prevStop={prevStopMention} nextStop={nextStopMention} weather={weather} diff --git a/web/src/pages/trips/[slug].astro b/web/src/pages/trips/[slug].astro index c667ad9..662d107 100644 --- a/web/src/pages/trips/[slug].astro +++ b/web/src/pages/trips/[slug].astro @@ -2,6 +2,7 @@ import Layout from '../../layouts/Layout.astro'; import { apiUrl } from '../../lib/api.js'; import { formatDateRange } from '../../lib/dates.js'; +import { stopContainsToday, stopIsLive } from '../../lib/stopLive.js'; import MapIsland from '../../components/MapIsland.jsx'; import StopIdentity from '../../components/ui/StopIdentity.astro'; import Breadcrumb from '../../components/ui/Breadcrumb.astro'; @@ -34,14 +35,7 @@ const stops = trip.stops || []; const posts = trip.posts || []; // Identify current stop by date range (mirrors home page API logic) -const todayMs = Date.now(); -function containsToday(stop) { - if (!stop.start_date) return false; - const start = new Date(stop.start_date).setHours(0, 0, 0, 0); - const end = stop.end_date ? new Date(stop.end_date).setHours(23, 59, 59, 999) : Infinity; - return start <= todayMs && todayMs <= end; -} -const todayStops = stops.filter(containsToday); +const todayStops = stops.filter(stopContainsToday); const currentStopId = todayStops.length === 1 ? todayStops[0].id : todayStops.length > 1 @@ -53,7 +47,7 @@ const tripOgImage = trip.cover_media?.id ? `/media/${trip.cover_media.id}/webp` const stopsWithCoords = stops .filter(s => s.latitude != null && s.longitude != null) - .map(s => ({ ...s, is_current: s.is_current || String(s.id) === String(currentStopId) })); + .map(s => ({ ...s, is_current: stopIsLive(s) || String(s.id) === String(currentStopId) })); const photoCount = posts.reduce((n, p) => n + (p.media?.length || 0), 0); const milesTotal = stops.reduce((n, s) => n + (s.miles_from_previous || 0), 0); @@ -176,7 +170,7 @@ const hasMapKey = Boolean(
{stop.start_date && {fmtRange(stop.start_date, stop.end_date)}} {stop.nights && {stop.nights} night{stop.nights !== 1 ? 's' : ''}} - {stop.is_current && currently here} + {stopIsLive(stop) && currently here}
diff --git a/web/src/pages/trips/[slug]/stops/[stop].astro b/web/src/pages/trips/[slug]/stops/[stop].astro index a6f37f0..b2b1175 100644 --- a/web/src/pages/trips/[slug]/stops/[stop].astro +++ b/web/src/pages/trips/[slug]/stops/[stop].astro @@ -2,6 +2,7 @@ import Layout from '../../../../layouts/Layout.astro'; import { apiUrl } from '../../../../lib/api.js'; import { formatDateRange } from '../../../../lib/dates.js'; +import { stopIsLive } from '../../../../lib/stopLive.js'; import { cityStateFromAddress } from '../../../../lib/location.js'; import { mediaOgImage, plainText } from '../../../../lib/og.js'; import CommentsIsland from '../../../../components/CommentsIsland.jsx'; @@ -61,6 +62,7 @@ function stopSiblingLabel(stop) { } const stopTitle = cityStateFromAddress(detail.address_label) || detail.place_name || detail.title; +const stopIsLiveNow = stopIsLive(detail); const stopDescription = plainText( detail.summary || detail.public_note @@ -96,7 +98,7 @@ const hasMapKey = Boolean( {/* ── Hero ─────────────────────────────────────────────── */}
- {detail.is_current && currently here} + {stopIsLiveNow && currently here} {detail.start_date && {fmtRange(detail.start_date, detail.end_date)}} {detail.nights && {detail.nights} night{detail.nights !== 1 ? 's' : ''}}