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' : ''}}