From 9e075a289695a665d8e1ca0a4c011d3a6b7f2535 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 22:29:08 -0800 Subject: [PATCH] Make visible controls do what they look like View trends now opens the hourly pattern section (#trends) instead of reloading the same page; the card menu skips its focus return for that link so the page does not jump back. New pages start at the top via a path-change-only ScrollToTop that leaves Back/forward, #hash links and query changes alone. Stats and exchange-rate tiles drop hover lift. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JB1Z5HSRuMsFpDeoEX6spM --- CHANGELOG.md | 7 +++++++ src/App.jsx | 2 ++ src/components/ScrollToTop.jsx | 21 +++++++++++++++++++ .../dashboard/BorderCrossingCard.jsx | 9 +++++--- .../dashboard/ExchangeRateWidget.jsx | 4 ++-- src/components/dashboard/StatsOverview.jsx | 4 ++-- src/pages/CrossingDetail.jsx | 9 ++++---- 7 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 src/components/ScrollToTop.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d318fdaf..78f66f756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2026-11-07 - Make visible controls do what they look like (Phase 2) + +- "View trends" in a crossing card's menu now opens `#trends`, the hourly pattern section, and scrolls to it. Before, it reloaded the same crossing page at the top. The menu skips its focus return for this link so the page does not jump back up. +- New pages start at the top. A small `ScrollToTop` runs only when the path changes; Back/forward keeps the browser's restoration, `#hash` links scroll to their target, and query-only changes (lane, map selection) keep their position. +- Stats tiles and the exchange-rate card no longer lift on hover, since they have no action. +- Verified in headless Chrome at 500px: View trends lands on the section; a deep-scrolled crossing page opens Best time at the top. Back position restoration was not compared against the previous build. + ## 2026-11-07 - Stop overstating Walk or Drive, Analytics and the embed (Phase 1) - Walk or Drive: the result no longer says "Walk." / "You save". It states the comparison it actually made (standard car lane vs a pedestrian lane plus an assumed 20 min for parking and the bridge), notes it applies only if you can use that lane and your trip works on foot, and reminds that Ready Lane needs an RFID document. Heading changed to "Comparison from reported waits, not a personal recommendation." EN/ES. Logic unchanged. diff --git a/src/App.jsx b/src/App.jsx index 503bd481a..87aa43e02 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import React, { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import ScrollToTop from '@/components/ScrollToTop'; import Layout from '@/Layout'; import ErrorBoundary from '@/components/ErrorBoundary'; import Dashboard from '@/pages/Dashboard'; @@ -64,6 +65,7 @@ function LayoutRoutes() { export default function App() { return ( + }> diff --git a/src/components/ScrollToTop.jsx b/src/components/ScrollToTop.jsx new file mode 100644 index 000000000..c40f8b43c --- /dev/null +++ b/src/components/ScrollToTop.jsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react'; +import { useLocation, useNavigationType } from 'react-router-dom'; + +// New pages start at the top. Back/forward (POP) keeps the browser's own +// scroll restoration, and links with a #hash let the page scroll to it. +// Only a pathname change counts, so query-only changes (lane, map +// selection) keep their position. +export default function ScrollToTop() { + const { pathname, hash } = useLocation(); + const navigationType = useNavigationType(); + const previous = useRef(pathname); + + useEffect(() => { + if (previous.current === pathname) return; + previous.current = pathname; + if (navigationType === 'POP' || hash) return; + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + }, [pathname, hash, navigationType]); + + return null; +} diff --git a/src/components/dashboard/BorderCrossingCard.jsx b/src/components/dashboard/BorderCrossingCard.jsx index d0e3c1d42..8fda1b48f 100644 --- a/src/components/dashboard/BorderCrossingCard.jsx +++ b/src/components/dashboard/BorderCrossingCard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { DEFAULT_LANE, laneLabel, withLane } from '@/lib/laneContext'; import { availableLaneWait } from '@/lib/crossingAvailability'; @@ -121,6 +121,9 @@ export default function BorderCrossingCard({ selectedLane = DEFAULT_LANE, }) { const [showLanes, setShowLanes] = useState(false); + // Skip the menu's focus return when we are scrolling to #trends, or it + // pulls the page back up to the trigger. + const skipMenuFocusReturn = useRef(false); const [aggregate, setAggregate] = useState(null); const cardSlug = slug || crossing.slug || null; @@ -298,10 +301,10 @@ export default function BorderCrossingCard({ - + { if (skipMenuFocusReturn.current) { event.preventDefault(); skipMenuFocusReturn.current = false; } }}> {cardSlug ? ( - track('crossing-open', { slug: cardSlug, source: 'card', direction: selectedDirection })}> + { skipMenuFocusReturn.current = true; track('crossing-open', { slug: cardSlug, source: 'card', direction: selectedDirection }); }}> {language === 'en' ? 'View trends' : 'Ver tendencias'} diff --git a/src/components/dashboard/ExchangeRateWidget.jsx b/src/components/dashboard/ExchangeRateWidget.jsx index 4591fae39..85225f956 100644 --- a/src/components/dashboard/ExchangeRateWidget.jsx +++ b/src/components/dashboard/ExchangeRateWidget.jsx @@ -15,7 +15,7 @@ export default function ExchangeRateWidget({ exchangeRate, language, theme, comp theme === 'dark' ? 'bg-gray-800/60 border-gray-700' : 'bg-white border-slate-200' - } transition-all hover:shadow-md`}> + }`}>
@@ -72,7 +72,7 @@ export default function ExchangeRateWidget({ exchangeRate, language, theme, comp theme === 'dark' ? 'bg-linear-to-br from-gray-800/50 to-gray-900/50 border-gray-700' : 'bg-linear-to-br from-white/90 to-blue-50/50 border-slate-200' - } backdrop-blur-xs transition-all duration-300 hover:shadow-xl`}> + } backdrop-blur-xs`}>
diff --git a/src/components/dashboard/StatsOverview.jsx b/src/components/dashboard/StatsOverview.jsx index 8787c7281..719bc7e7b 100644 --- a/src/components/dashboard/StatsOverview.jsx +++ b/src/components/dashboard/StatsOverview.jsx @@ -116,7 +116,7 @@ export default function StatsOverview({ theme === 'dark' ? 'bg-gray-800/60 border-gray-700' : 'bg-white border-slate-200' - } hover:shadow-md transition-all`}> + }`}>
@@ -151,7 +151,7 @@ export default function StatsOverview({ theme === 'dark' ? 'bg-gray-800/60 border-gray-700' : 'bg-white/80 border-slate-200' - } backdrop-blur-xs hover:shadow-md transition-all`}> + } backdrop-blur-xs`}>
diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index 9591e2ecf..c6045971a 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -299,10 +299,11 @@ export default function CrossingDetail() { const { hash, search } = useLocation(); const loadedPort = crossing?.port_number; useEffect(() => { - if (hash !== '#cameras' || !loadedPort) return; - const frame = requestAnimationFrame(() => document.getElementById('cameras')?.scrollIntoView()); + const target = hash === '#cameras' || hash === '#trends' ? hash.slice(1) : null; + if (!target || !loadedPort) return; + const frame = requestAnimationFrame(() => document.getElementById(target)?.scrollIntoView()); return () => cancelAnimationFrame(frame); - }, [hash, loadedPort]); + }, [hash, loadedPort, aggregate]); // Aggregate buckets are port-local; evaluate "today"/"now" in the port's // timezone once the crossing is known. @@ -587,7 +588,7 @@ export default function CrossingDetail() { })()} {aggregate && aggregate.by_hour && aggregate.by_hour.length > 0 && ( -
+