Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# CHANGELOG

## 2026-11-07 - Keep the selected lane when opening Best time and returning (draft PR)

- Fix: choosing SENTRI, Ready Lane or Pedestrian and then opening "Plan your crossing" (or the card menu's "Best time to cross") dropped the lane. "See current wait time" then returned to Standard. In the audit's sampled test the displayed wait changed from 25 to 185 minutes.
- The crossing-share link, both card best-time links and the best-time page's return link now carry the validated `?lane=` key through `withLane()`. Standard stays query-free.
- Best time shows a short EN/ES note when a non-Standard lane is selected: the pattern is standard-passenger history and does not describe that lane. The history itself is unchanged.
- Test: the share test previously asserted a lane-free best-time link for every lane, which encoded the bug. It now expects the lane key; added a `withLane` contract test.
- Not changed: the "View trends" menu item, Walk or Drive wording, Analytics empty state, scroll position and the paused collector backup repair.

## 2026-11-03 — Prepare the reviewed fixes as an ordered branch stack

- Preserve current main data while reconciling the Spanish/snapshot, duplicate-lane and Lane Flip Watch branches in that order. Keep both endpoint regression groups, metadata imports and reviewed source-clock safeguards.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# DECISIONS

## 2026-11-07 · Every link between lane-aware pages carries the lane

Extends the 2026-10-27 rule that a chosen lane is navigation context. Links from a crossing, card or share section to Best time, and Best time's return link, pass the validated lane key through `withLane()`. The Best time series stays Standard and is labeled as such when another lane is selected; it is not filtered or relabeled by lane.

## 2026-11-02 · Lane comparisons describe retained fetches

Do not infer a usual lane advantage from repeated fetches or call values current just because retrieval is fresh. Display both source clocks and actual historical coverage; mark an archive stale after 24 hours without a retained fetch. Earlier snapshots were normalized before duplicate-row repair and raw responses are unavailable: retain an explicit incompleteness caveat rather than claim they were reconstructed. Counts and percentages describe those stored fetches only.
Expand Down
12 changes: 6 additions & 6 deletions scripts/test-crossing-share.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,10 @@ test('EN/ES rendering, WhatsApp payload, and best-time link formatting across al
const root = createRoot(container);

const testCases = [
{ lane: 'passenger_standard', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/', labelEn: 'Standard passenger', labelEs: 'Auto estándar' },
{ lane: 'passenger_ready', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=passenger_ready', labelEn: 'Ready Lane', labelEs: 'Ready Lane' },
{ lane: 'passenger_sentri', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=passenger_sentri', labelEn: 'SENTRI', labelEs: 'SENTRI' },
{ lane: 'pedestrian_standard', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=pedestrian_standard', labelEn: 'Pedestrian', labelEs: 'Peatones' },
{ lane: 'passenger_standard', expectedBestTime: '/best-time/san-ysidro/', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/', labelEn: 'Standard passenger', labelEs: 'Auto estándar' },
{ lane: 'passenger_ready', expectedBestTime: '/best-time/san-ysidro/?lane=passenger_ready', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=passenger_ready', labelEn: 'Ready Lane', labelEs: 'Ready Lane' },
{ lane: 'passenger_sentri', expectedBestTime: '/best-time/san-ysidro/?lane=passenger_sentri', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=passenger_sentri', labelEn: 'SENTRI', labelEs: 'SENTRI' },
{ lane: 'pedestrian_standard', expectedBestTime: '/best-time/san-ysidro/?lane=pedestrian_standard', expectedUrl: 'https://borderpulse.com/crossing/san-ysidro/?lane=pedestrian_standard', labelEn: 'Pedestrian', labelEs: 'Peatones' },
];

for (const tc of testCases) {
Expand All @@ -304,7 +304,7 @@ test('EN/ES rendering, WhatsApp payload, and best-time link formatting across al
});

const linksEn = getLinks(container);
assert.equal(linksEn.bestTimeLink.href, '/best-time/san-ysidro/');
assert.equal(linksEn.bestTimeLink.href, tc.expectedBestTime, 'best-time link keeps the selected lane');
assert.equal(
linksEn.whatsappLink.href,
'https://wa.me/?text=' + encodeURIComponent(`Check San Ysidro (${tc.labelEn}) wait times and crossing hours on BorderPulse.\n${tc.expectedUrl}`)
Expand All @@ -320,7 +320,7 @@ test('EN/ES rendering, WhatsApp payload, and best-time link formatting across al
});

const linksEs = getLinks(container);
assert.equal(linksEs.bestTimeLink.href, '/best-time/san-ysidro/');
assert.equal(linksEs.bestTimeLink.href, tc.expectedBestTime, 'best-time link keeps the selected lane');
assert.equal(
linksEs.whatsappLink.href,
'https://wa.me/?text=' + encodeURIComponent(`Consulta los tiempos de espera y horarios de San Ysidro (${tc.labelEs}) en BorderPulse.\n${tc.expectedUrl}`)
Expand Down
7 changes: 7 additions & 0 deletions scripts/test-lane-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ test('selected lane never falls back to a Standard or summary wait; zero remains
}
assert.equal(availableLaneWait({ ...crossing, port_status: 'Closed' }, 'passenger_sentri'), null);
});

test('best-time and crossing links carry a selected lane and drop the default', () => {
assert.equal(withLane('/best-time/san-ysidro/', 'passenger_sentri'), '/best-time/san-ysidro/?lane=passenger_sentri');
assert.equal(withLane('/crossing/san-ysidro/', 'passenger_sentri'), '/crossing/san-ysidro/?lane=passenger_sentri');
assert.equal(withLane('/best-time/san-ysidro/', DEFAULT_LANE), '/best-time/san-ysidro/');
assert.equal(withLane('/best-time/san-ysidro/?lane=passenger_sentri', 'not_a_lane'), '/best-time/san-ysidro/');
});
4 changes: 2 additions & 2 deletions src/components/dashboard/BorderCrossingCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export default function BorderCrossingCard({
)}
{cardSlug && (
<DropdownMenuItem asChild>
<Link to={`/best-time/${cardSlug}/`} className="cursor-pointer" onClick={() => track('best-time-open', { slug: cardSlug, source: 'card' })}>
<Link to={withLane(`/best-time/${cardSlug}/`, selectedLane)} className="cursor-pointer" onClick={() => track('best-time-open', { slug: cardSlug, source: 'card' })}>
{language === 'en' ? 'Best time to cross' : 'Mejor hora para cruzar'}
</Link>
</DropdownMenuItem>
Expand Down Expand Up @@ -414,7 +414,7 @@ export default function BorderCrossingCard({

{showBestTimePill && cardSlug && (
<Link
to={`/best-time/${cardSlug}/`}
to={withLane(`/best-time/${cardSlug}/`, selectedLane)}
className="inline-flex min-h-11 items-center gap-1 rounded-full border border-emerald-200 bg-white px-3 py-1 text-xs font-semibold text-emerald-800 transition-colors hover:bg-emerald-50 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-200 dark:hover:bg-emerald-900"
title={language === 'en'
? `Historical lowest median for the full local weekday; this hour may have passed. Opens the full pattern.`
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard/CrossingShare.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export default function CrossingShare({ name, slug, language = 'en', lane = DEFA
<Button onClick={copy} variant="ghost" className="min-h-11 gap-2"><Copy aria-hidden="true" className="h-4 w-4" />{es ? 'Copiar enlace' : 'Copy link'}</Button>
</div>
<p role="status" className="mt-2 text-xs text-slate-600 dark:text-slate-300">{status}</p>
<Link to={`/best-time/${slug}/`} onClick={() => track('best-time-open', { slug, source: 'crossing-share' })} className="mt-2 inline-flex min-h-11 items-center gap-2 text-sm font-medium text-blue-700 underline underline-offset-4 dark:text-blue-300">
<Link to={withLane(`/best-time/${slug}/`, lane)} onClick={() => track('best-time-open', { slug, source: 'crossing-share' })} className="mt-2 inline-flex min-h-11 items-center gap-2 text-sm font-medium text-blue-700 underline underline-offset-4 dark:text-blue-300">
{es ? `Planea tu cruce por ${name}` : `Plan your crossing at ${name}`}<ArrowRight aria-hidden="true" className="h-4 w-4 shrink-0" />
</Link>
</section>
Expand Down
14 changes: 12 additions & 2 deletions src/pages/BestTime.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, Link, Navigate } from 'react-router-dom';
import { useParams, useSearchParams, Link, Navigate } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Clock, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { subscribeToBorderData } from '@/lib/liveDataSubscription';
Expand All @@ -10,6 +10,7 @@ import { usePersistentLanguage } from '@/lib/useLanguage';
import { nowInPortTz } from '@/components/utils/crossingMeta';
import { isSparseCell } from '@/lib/aggregates';
import { pickLightestHour } from '@/lib/recommendations';
import { DEFAULT_LANE, laneLabel, normalizeLane, withLane } from '@/lib/laneContext';

const DAY_LABELS = {
en: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
Expand Down Expand Up @@ -268,6 +269,8 @@ export function BestTimeIndex() {

export default function BestTime() {
const { slug } = useParams();
const [searchParams] = useSearchParams();
const lane = normalizeLane(searchParams.get('lane'));
const language = usePersistentLanguage();
const [crossings, setCrossings] = useState([]);
const [aggregate, setAggregate] = useState(null);
Expand Down Expand Up @@ -417,9 +420,16 @@ export default function BestTime() {
? 'Full-day historical pattern in this crossing’s local time. Full-day patterns include hours already passed; this is not a recommendation for the hours remaining today or a forecast.'
: 'Patrón histórico de todo el día, en la hora local de este cruce. Los patrones de todo el día incluyen horas que ya pasaron; esto no es una recomendación para lo que queda de hoy ni un pronóstico.'}
</p>
{lane !== DEFAULT_LANE && (
<p className="text-xs text-emerald-900 dark:text-emerald-100 mt-2" data-testid="best-time-lane-note">
{language === 'en'
? `You selected ${laneLabel(lane, 'en')}. These patterns are standard-passenger history and do not describe that lane.`
: `Elegiste ${laneLabel(lane, 'es')}. Estos patrones son del historial de auto estándar y no describen ese carril.`}
</p>
)}
<div className="mt-3">
<Link
to={`/crossing/${canonicalSlug}/`}
to={withLane(`/crossing/${canonicalSlug}/`, lane)}
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 px-4 py-3 text-sm font-medium text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
>
<Clock className="w-4 h-4 text-emerald-600" />
Expand Down
Loading