Skip to content

feat: CVSR live train tracker with GPS tracking#530

Merged
fatherlinux merged 2 commits into
masterfrom
feature/038-cvsr-train-tracker
Jun 25, 2026
Merged

feat: CVSR live train tracker with GPS tracking#530
fatherlinux merged 2 commits into
masterfrom
feature/038-cvsr-train-tracker

Conversation

@fatherlinux

Copy link
Copy Markdown
Member

Summary

  • Adds live GPS tracking for the CVSR train on the ROTV map
  • Backend service (trainTrackerService.js) polls US Fleet Tracking API every 5s with JWT auth
  • Frontend polls backend every 5s, snaps train icon to 704-point OSM track geometry
  • Train icon rotates to follow track bearing, sized 64x64 for visibility
  • ?feature=CVSR URL flag gates visibility for partner demos before public launch
  • Permalink /cuyahoga-valley-scenic-railroad zooms to train's GPS position and opens sidebar
  • Click the track line or train icon to open CVSR sidebar (same UX as Harbor Hopper)
  • Renamed "Train Station" to "Train" in legend
  • Added railroad as a new linear feature type with brown dashed styling
  • Migration 085 adds admin setting, track geometry, and renames icon label

Closes #529

Environment Variables (production)

  • USFT_SHARING_TOKEN — required, the USFT shared-view token from Alan at CVSR
  • USFT_API_URL — optional, defaults to https://hades.usft.com
  • USFT_POLL_INTERVAL_MS — optional, defaults to 5000

Test plan

  • Visit without flag — no train marker or track visible
  • Visit with ?feature=CVSR — track line and train icon appear
  • Refresh — feature persists via localStorage
  • Click train icon — CVSR sidebar opens
  • Click track line — CVSR sidebar opens
  • Visit /cuyahoga-valley-scenic-railroad — zooms to train position, sidebar opens
  • curl /api/train/position — returns JSON with cvsr position
  • Toggle "Train" in legend — hides/shows track and train marker
  • Visit ?feature=CVSR&off=true — clears flag, train disappears

🤖 Generated with Claude Code

fatherlinux and others added 2 commits June 24, 2026 18:26
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Polls the US Fleet Tracking API for the Cuyahoga Valley Scenic Railroad
train's GPS position and renders it as a moving icon on the map. The train
snaps to the OSM railroad track geometry and rotates to follow the track
bearing.

Key features:
- Backend service polls USFT API every 5s with JWT auth
- Frontend polls backend every 5s for near-real-time position updates
- 704-point track geometry from OSM (Akron to Rockside)
- Train icon snaps to nearest point on track with computed bearing
- ?feature=CVSR URL flag gates visibility for partner demos
- Permalink /cuyahoga-valley-scenic-railroad zooms to train position
- Click track or train icon to open CVSR sidebar
- Renamed "Train Station" legend entry to "Train"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fatherlinux fatherlinux merged commit b291df9 into master Jun 25, 2026
1 of 3 checks passed
@fatherlinux fatherlinux deleted the feature/038-cvsr-train-tracker branch June 25, 2026 02:19

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces live GPS tracking for the Cuyahoga Valley Scenic Railroad (CVSR) train, adding a backend polling service to fetch coordinates from the US Fleet Tracking API and a frontend map marker that snaps to the railroad tracks. The feature is gated behind a 'CVSR' feature flag. The review feedback highlights several critical robustness and performance improvements, such as conditionally enabling the frontend polling hook only when the feature flag is active, defensively validating coordinates to prevent Leaflet map crashes, implementing concurrency locks for backend polling, handling initial API authentication failures gracefully, and wrapping localStorage calls in try-catch blocks to prevent crashes in restricted environments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +5 to +29
export default function useTrainPosition() {
const [trainPosition, setTrainPosition] = useState(null);

useEffect(() => {
let mounted = true;

const fetchPosition = () => {
fetch('/api/train/position')
.then(res => res.json())
.then(positions => {
if (mounted) setTrainPosition(positions.cvsr || null);
})
.catch(() => {
if (mounted) setTrainPosition(null);
});
};

fetchPosition();
const interval = setInterval(fetchPosition, POLL_INTERVAL_MS);

return () => {
mounted = false;
clearInterval(interval);
};
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The useTrainPosition hook currently polls the /api/train/position endpoint unconditionally every 5 seconds for all users, even when the CVSR feature flag is disabled. This creates unnecessary network traffic and server load for 99%+ of users before the feature is publicly launched. We should pass an enabled parameter to the hook and only start polling if it is true.

export default function useTrainPosition(enabled = true) {
  const [trainPosition, setTrainPosition] = useState(null);

  useEffect(() => {
    if (!enabled) {
      setTrainPosition(null);
      return;
    }

    let mounted = true;

    const fetchPosition = () => {
      fetch('/api/train/position')
        .then(res => res.json())
        .then(positions => {
          if (mounted) setTrainPosition(positions.cvsr || null);
        })
        .catch(() => {
          if (mounted) setTrainPosition(null);
        });
    };

    fetchPosition();
    const interval = setInterval(fetchPosition, POLL_INTERVAL_MS);

    return () => {
      mounted = false;
      clearInterval(interval);
    };
  }, [enabled]);

Comment thread frontend/src/App.jsx
const [showWaterTaxis, setShowWaterTaxis] = useState(true);
const cvsrEnabled = useMemo(() => isFeatureEnabled('CVSR'), []);
const boatPosition = useBoatPosition();
const trainPosition = useTrainPosition();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass cvsrEnabled to useTrainPosition to prevent unnecessary background polling when the feature flag is disabled.

Suggested change
const trainPosition = useTrainPosition();
const trainPosition = useTrainPosition(cvsrEnabled);

Comment on lines +1511 to +1517
const snappedTrain = useMemo(() => {
if (!trainPosition || !trainFeature?.geometry?.coordinates) return null;
return snapToLine(
[trainPosition.latitude, trainPosition.longitude],
trainFeature.geometry.coordinates
);
}, [trainPosition, trainFeature]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the API returns invalid or malformed coordinates (e.g., NaN or undefined), passing them to Leaflet's <Marker> component will cause a React render crash. We should defensively verify that both latitude and longitude are valid numbers before attempting to snap and render the marker.

Suggested change
const snappedTrain = useMemo(() => {
if (!trainPosition || !trainFeature?.geometry?.coordinates) return null;
return snapToLine(
[trainPosition.latitude, trainPosition.longitude],
trainFeature.geometry.coordinates
);
}, [trainPosition, trainFeature]);
const snappedTrain = useMemo(() => {
if (!trainPosition || typeof trainPosition.latitude !== 'number' || typeof trainPosition.longitude !== 'number' || !trainFeature?.geometry?.coordinates) return null;
return snapToLine(
[trainPosition.latitude, trainPosition.longitude],
trainFeature.geometry.coordinates
);
}, [trainPosition, trainFeature]);

Comment on lines +50 to +92
async function pollDevices() {
try {
const res = await fetch(`${USFT_API_URL}/map/devices`, {
headers: {
'Authorization': `Bearer ${jwt}`,
'User-Agent': 'RootsOfTheValley/1.0 (+https://rootsofthevalley.org)',
},
});

if (res.status === 401) {
console.log('[TrainTracker] JWT expired, re-authenticating');
await authenticate();
return;
}

if (!res.ok) {
console.warn(`[TrainTracker] Poll failed: ${res.status}`);
return;
}

const devices = await res.json();
if (!Array.isArray(devices) || devices.length === 0) return;

const device = devices[0];
const loc = device.location;
if (!loc) return;

const lat = parseFloat(loc.latitude);
const lng = parseFloat(loc.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;

position = {
latitude: lat,
longitude: lng,
heading: parseInt(loc.heading, 10) || 0,
speed: parseInt(loc.velocity, 10) || 0,
status: deriveStatus(device),
updatedAt: loc.lastUpdated || new Date().toISOString(),
};
} catch (err) {
console.warn(`[TrainTracker] Poll error: ${err.message}`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using setInterval for asynchronous polling can lead to concurrent executions of pollDevices if a request hangs or takes longer than POLL_INTERVAL_MS. This can cause a race condition or multiple concurrent authentication requests to the USFT API. We should use a boolean flag to prevent concurrent runs. Additionally, we should defensively check if device is valid before accessing its properties.

let isPolling = false;

async function pollDevices() {
  if (isPolling) return;
  isPolling = true;
  try {
    const res = await fetch(`${USFT_API_URL}/map/devices`, {
      headers: {
        'Authorization': `Bearer ${jwt}`,
        'User-Agent': 'RootsOfTheValley/1.0 (+https://rootsofthevalley.org)',
      },
    });

    if (res.status === 401) {
      console.log('[TrainTracker] JWT expired, re-authenticating');
      await authenticate();
      return;
    }

    if (!res.ok) {
      console.warn(`[TrainTracker] Poll failed: ${res.status}`);
      return;
    }

    const devices = await res.json();
    if (!Array.isArray(devices) || devices.length === 0) return;

    const device = devices[0];
    if (!device) return;
    const loc = device.location;
    if (!loc) return;

    const lat = parseFloat(loc.latitude);
    const lng = parseFloat(loc.longitude);
    if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;

    position = {
      latitude: lat,
      longitude: lng,
      heading: parseInt(loc.heading, 10) || 0,
      speed: parseInt(loc.velocity, 10) || 0,
      status: deriveStatus(device),
      updatedAt: loc.lastUpdated || new Date().toISOString(),
    };
  } catch (err) {
    console.warn(`[TrainTracker] Poll error: ${err.message}`);
  } finally {
    isPolling = false;
  }
}

Comment on lines +123 to +124
await authenticate();
startPolling();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the USFT API is temporarily down or unreachable when the backend server starts up, authenticate() will throw an error, preventing startPolling() from ever being called. This means the train tracker will remain completely dead until the entire backend server is restarted. We should catch any initial authentication error and still start polling, allowing the service to automatically recover and authenticate on subsequent poll attempts.

    try {
      await authenticate();
    } catch (err) {
      console.warn('[TrainTracker] Initial USFT authentication failed, will retry during polling:', err.message);
    }
    startPolling();

Comment on lines +147 to +151
const age = Date.now() - new Date(position.updatedAt).getTime();
const threshold = position.status === 'active' ? ACTIVE_STALE_MS : IDLE_STALE_MS;
if (age > threshold) {
return { cvsr: null };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If position.updatedAt is an invalid date string, new Date(position.updatedAt).getTime() will return NaN. In JavaScript, NaN > threshold is always false, which means the stale check will be bypassed and stale data will be served indefinitely. We should parse the date safely and handle NaN by defaulting to Infinity age.

  const parsedDate = Date.parse(position.updatedAt);
  const age = Number.isNaN(parsedDate) ? Infinity : Date.now() - parsedDate;
  const threshold = position.status === 'active' ? ACTIVE_STALE_MS : IDLE_STALE_MS;
  if (age > threshold) {
    return { cvsr: null };
  }

Comment thread frontend/src/App.jsx
Comment on lines +778 to +785
fetch('/api/train/position').then(r => r.json()).then(data => {
const pos = data?.cvsr;
if (pos) {
const pad = 0.005;
setBoundsToFit([[pos.latitude - pad, pos.longitude - pad],
[pos.latitude + pad, pos.longitude + pad]]);
setFitNonce(n => n + 1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the API returns invalid or missing coordinates for the train position, pos.latitude - pad will evaluate to NaN. Passing NaN bounds to Leaflet's setBoundsToFit can cause Leaflet to crash or break map rendering. We should defensively verify that both latitude and longitude are valid numbers before setting the bounds.

Suggested change
fetch('/api/train/position').then(r => r.json()).then(data => {
const pos = data?.cvsr;
if (pos) {
const pad = 0.005;
setBoundsToFit([[pos.latitude - pad, pos.longitude - pad],
[pos.latitude + pad, pos.longitude + pad]]);
setFitNonce(n => n + 1);
}
fetch('/api/train/position').then(r => r.json()).then(data => {
const pos = data?.cvsr;
if (pos && typeof pos.latitude === 'number' && typeof pos.longitude === 'number') {
const pad = 0.005;
setBoundsToFit([[pos.latitude - pad, pos.longitude - pad],
[pos.latitude + pad, pos.longitude + pad]]);
setFitNonce(n => n + 1);
}

Comment on lines +1 to +2
export function snapToLine(point, lineCoords) {
if (!lineCoords || lineCoords.length < 2) return { position: point, bearing: 0 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should defensively check that the input point is a valid array with two numeric coordinates before performing calculations to prevent runtime errors.

Suggested change
export function snapToLine(point, lineCoords) {
if (!lineCoords || lineCoords.length < 2) return { position: point, bearing: 0 };
export function snapToLine(point, lineCoords) {
if (!point || !Array.isArray(point) || point.length < 2 || typeof point[0] !== 'number' || typeof point[1] !== 'number') {
return { position: point, bearing: 0 };
}
if (!lineCoords || lineCoords.length < 2) return { position: point, bearing: 0 };

Comment on lines +1 to +12
export function isFeatureEnabled(flag) {
const params = new URLSearchParams(window.location.search);
if (params.get('feature') === flag) {
if (params.get('off') === 'true') {
localStorage.removeItem(`feature_${flag}`);
return false;
}
localStorage.setItem(`feature_${flag}`, 'true');
return true;
}
return localStorage.getItem(`feature_${flag}`) === 'true';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a user has blocked local storage or is using a strict private browsing mode, calling localStorage.setItem or localStorage.getItem will throw a SecurityError and crash the entire application on load. We should wrap the storage access in a try-catch block to ensure the application remains functional.

Suggested change
export function isFeatureEnabled(flag) {
const params = new URLSearchParams(window.location.search);
if (params.get('feature') === flag) {
if (params.get('off') === 'true') {
localStorage.removeItem(`feature_${flag}`);
return false;
}
localStorage.setItem(`feature_${flag}`, 'true');
return true;
}
return localStorage.getItem(`feature_${flag}`) === 'true';
}
export function isFeatureEnabled(flag) {
try {
const params = new URLSearchParams(window.location.search);
if (params.get('feature') === flag) {
if (params.get('off') === 'true') {
localStorage.removeItem(`feature_${flag}`);
return false;
}
localStorage.setItem(`feature_${flag}`, 'true');
return true;
}
return localStorage.getItem(`feature_${flag}`) === 'true';
} catch (e) {
const params = new URLSearchParams(window.location.search);
return params.get('feature') === flag;
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seasonal polling for live trackers (train + boat)

1 participant