From 04271bc9250f58c2c779840fce031d5dd3bf344f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:07:43 +0100 Subject: Add floating action button for timetable navigation (#72) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> --- src/frontend/app/routes/timetable-$id.css | 75 ++++++++ src/frontend/app/routes/timetable-$id.tsx | 277 ++++++++++++++++++++++++------ 2 files changed, 298 insertions(+), 54 deletions(-) (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css index c834633..b4e231a 100644 --- a/src/frontend/app/routes/timetable-$id.css +++ b/src/frontend/app/routes/timetable-$id.css @@ -38,6 +38,10 @@ .timetable-full-content { margin-top: 1rem; + overflow-y: auto; + max-height: calc(100vh - 250px); + position: relative; + padding-bottom: 80px; /* Space for FAB */ } .error-message { @@ -149,3 +153,74 @@ padding: 1rem; } } + +/* Floating Action Button */ +.fab-container { + position: fixed; + bottom: 80px; + right: 20px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 1000; +} + +.fab { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background-color: var(--button-background-color, #007bff); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: all 0.3s ease; + animation: fadeIn 0.3s ease; +} + +.fab:hover { + background-color: var(--button-hover-background-color, #0069d9); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + transform: scale(1.05); +} + +.fab:active { + transform: scale(0.95); +} + +.fab-icon { + width: 24px; + height: 24px; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Adjust FAB position on mobile */ +@media (max-width: 768px) { + .fab-container { + bottom: 70px; + right: 16px; + } + + .fab { + width: 48px; + height: 48px; + } + + .fab-icon { + width: 20px; + height: 20px; + } +} diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx index 5ecebc8..c8f0125 100644 --- a/src/frontend/app/routes/timetable-$id.tsx +++ b/src/frontend/app/routes/timetable-$id.tsx @@ -1,7 +1,14 @@ import { useEffect, useState, useRef } from "react"; import { useParams, Link } from "react-router"; import StopDataProvider from "../data/StopDataProvider"; -import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { + ArrowLeft, + Eye, + EyeOff, + ChevronUp, + ChevronDown, + Clock, +} from "lucide-react"; import { type ScheduledTable } from "~/components/SchedulesTable"; import { TimetableSkeleton } from "~/components/TimetableSkeleton"; import { ErrorDisplay } from "~/components/ErrorDisplay"; @@ -12,12 +19,15 @@ import { useApp } from "~/AppContext"; import "./timetable-$id.css"; interface ErrorInfo { - type: 'network' | 'server' | 'unknown'; + type: "network" | "server" | "unknown"; status?: number; message?: string; } -const loadTimetableData = async (region: RegionId, stopId: string): Promise => { +const loadTimetableData = async ( + region: RegionId, + stopId: string, +): Promise => { const regionConfig = getRegionConfig(region); // Check if timetable is available for this region @@ -26,14 +36,17 @@ const loadTimetableData = async (region: RegionId, stopId: string): Promise setTimeout(resolve, 1000)); - - const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format - const resp = await fetch(`${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, { - headers: { - Accept: "application/json", + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format + const resp = await fetch( + `${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, + { + headers: { + Accept: "application/json", + }, }, - }); + ); if (!resp.ok) { throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); @@ -44,22 +57,26 @@ const loadTimetableData = async (region: RegionId, stopId: string): Promise { - const [hours, minutes] = time.split(':').map(Number); + const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; }; // Filter past entries (keep only a few recent past ones) -const filterTimetableData = (data: ScheduledTable[], currentTime: string, showPast: boolean = false): ScheduledTable[] => { +const filterTimetableData = ( + data: ScheduledTable[], + currentTime: string, + showPast: boolean = false, +): ScheduledTable[] => { if (showPast) return data; const currentMinutes = timeToMinutes(currentTime); - const sortedData = [...data].sort((a, b) => - timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time) + const sortedData = [...data].sort( + (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time), ); // Find the current position - const currentIndex = sortedData.findIndex(entry => - timeToMinutes(entry.calling_time) >= currentMinutes + const currentIndex = sortedData.findIndex( + (entry) => timeToMinutes(entry.calling_time) >= currentMinutes, ); if (currentIndex === -1) { @@ -74,11 +91,11 @@ const filterTimetableData = (data: ScheduledTable[], currentTime: string, showPa // Utility function to parse service ID and get the turn number const parseServiceId = (serviceId: string): string => { - const parts = serviceId.split('_'); - if (parts.length === 0) return ''; + const parts = serviceId.split("_"); + if (parts.length === 0) return ""; const lastPart = parts[parts.length - 1]; - if (lastPart.length < 6) return ''; + if (lastPart.length < 6) return ""; const last6 = lastPart.slice(-6); const lineCode = last6.slice(0, 3); @@ -92,20 +109,40 @@ const parseServiceId = (serviceId: string): string => { let displayLine: string; switch (lineNumber) { - case 1: displayLine = "C1"; break; - case 3: displayLine = "C3"; break; - case 30: displayLine = "N1"; break; - case 33: displayLine = "N4"; break; - case 8: displayLine = "A"; break; - case 101: displayLine = "H"; break; - case 150: displayLine = "REF"; break; - case 500: displayLine = "TUR"; break; - default: displayLine = `L${lineNumber}`; + case 1: + displayLine = "C1"; + break; + case 3: + displayLine = "C3"; + break; + case 30: + displayLine = "N1"; + break; + case 33: + displayLine = "N4"; + break; + case 8: + displayLine = "A"; + break; + case 101: + displayLine = "H"; + break; + case 150: + displayLine = "REF"; + break; + case 500: + displayLine = "TUR"; + break; + default: + displayLine = `L${lineNumber}`; } return `${displayLine}-${turnNumber}`; }; +// Scroll threshold for showing FAB buttons (in pixels) +const SCROLL_THRESHOLD = 100; + export default function Timetable() { const { t } = useTranslation(); const { region } = useApp(); @@ -116,37 +153,48 @@ export default function Timetable() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showPastEntries, setShowPastEntries] = useState(false); + const [showScrollTop, setShowScrollTop] = useState(false); + const [showScrollBottom, setShowScrollBottom] = useState(false); + const [showGoToNow, setShowGoToNow] = useState(false); const nextEntryRef = useRef(null); + const containerRef = useRef(null); const regionConfig = getRegionConfig(region); const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS - const filteredData = filterTimetableData(timetableData, currentTime, showPastEntries); + const filteredData = filterTimetableData( + timetableData, + currentTime, + showPastEntries, + ); const parseError = (error: any): ErrorInfo => { if (!navigator.onLine) { - return { type: 'network', message: 'No internet connection' }; + return { type: "network", message: "No internet connection" }; } - if (error.message?.includes('Failed to fetch') || error.message?.includes('NetworkError')) { - return { type: 'network' }; + if ( + error.message?.includes("Failed to fetch") || + error.message?.includes("NetworkError") + ) { + return { type: "network" }; } - if (error.message?.includes('HTTP')) { + if (error.message?.includes("HTTP")) { const statusMatch = error.message.match(/HTTP (\d+):/); const status = statusMatch ? parseInt(statusMatch[1]) : undefined; - return { type: 'server', status }; + return { type: "server", status }; } - return { type: 'unknown', message: error.message }; + return { type: "unknown", message: error.message }; }; const loadData = async () => { // Check if timetable is available for this region if (!regionConfig.timetableEndpoint) { setError({ - type: 'server', + type: "server", status: 501, - message: 'Timetable not available for this region' + message: "Timetable not available for this region", }); setLoading(false); return; @@ -163,24 +211,25 @@ export default function Timetable() { // Scroll to next entry after a short delay to allow rendering setTimeout(() => { const currentMinutes = timeToMinutes(currentTime); - const sortedData = [...timetableBody].sort((a, b) => - timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time) + const sortedData = [...timetableBody].sort( + (a, b) => + timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time), ); - const nextIndex = sortedData.findIndex(entry => - timeToMinutes(entry.calling_time) >= currentMinutes + const nextIndex = sortedData.findIndex( + (entry) => timeToMinutes(entry.calling_time) >= currentMinutes, ); if (nextIndex !== -1 && nextEntryRef.current) { nextEntryRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'center' + behavior: "smooth", + block: "center", }); } }, 500); } } catch (err) { - console.error('Error loading timetable data:', err); + console.error("Error loading timetable data:", err); setError(parseError(err)); setTimetableData([]); } finally { @@ -193,6 +242,74 @@ export default function Timetable() { setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); }, [params.id, region]); + // Handle scroll events to update FAB visibility + useEffect(() => { + const handleScroll = () => { + if ( + !containerRef.current || + loading || + error || + timetableData.length === 0 + ) { + return; + } + + const container = containerRef.current; + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const scrollBottom = scrollHeight - scrollTop - clientHeight; + + // Threshold for showing scroll buttons (in pixels) + const threshold = 100; + + // Show scroll top button when scrolled down + setShowScrollTop(scrollTop > threshold); + + // Show scroll bottom button when not at bottom + setShowScrollBottom(scrollBottom > threshold); + + // Check if next entry (current time) is visible + if (nextEntryRef.current) { + const rect = nextEntryRef.current.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const isNextVisible = + rect.top >= containerRect.top && rect.bottom <= containerRect.bottom; + + setShowGoToNow(!isNextVisible); + } + }; + + const container = containerRef.current; + if (container) { + container.addEventListener("scroll", handleScroll); + // Initial check + handleScroll(); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + } + }, [loading, error, timetableData]); + + const scrollToTop = () => { + containerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const scrollToBottom = () => { + containerRef.current?.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "smooth", + }); + }; + + const scrollToNow = () => { + nextEntryRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + if (loading) { return (
@@ -242,16 +359,24 @@ export default function Timetable() {
) : timetableData.length === 0 ? (
-

{t("timetable.noDataAvailable", "No hay datos de horarios disponibles para hoy")}

+

+ {t( + "timetable.noDataAvailable", + "No hay datos de horarios disponibles para hoy", + )} +

- {t("timetable.errorDetail", "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde.")} + {t( + "timetable.errorDetail", + "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde.", + )}

) : ( -
+
+ )} + {showScrollTop && ( + + )} + {showScrollBottom && !showScrollTop && ( + + )} +
+ )}
)}
@@ -301,7 +462,11 @@ const TimetableTableWithScroll: React.FC<{ {data.map((entry, index) => { const entryMinutes = timeToMinutes(entry.calling_time); const isPast = entryMinutes < nowMinutes; - const isNext = !isPast && (index === 0 || timeToMinutes(data[index - 1]?.calling_time || '00:00:00') < nowMinutes); + const isNext = + !isPast && + (index === 0 || + timeToMinutes(data[index - 1]?.calling_time || "00:00:00") < + nowMinutes); return (
@@ -325,7 +490,9 @@ const TimetableTableWithScroll: React.FC<{ {entry.route && entry.route.trim() ? ( {entry.route} ) : ( - {t("timetable.noDestination", "Línea")} {entry.line} + + {t("timetable.noDestination", "Línea")} {entry.line} + )}
@@ -341,7 +508,7 @@ const TimetableTableWithScroll: React.FC<{
{!isPast && entry.next_streets.length > 0 && (
- {entry.next_streets.join(' — ')} + {entry.next_streets.join(" — ")}
)}
@@ -351,7 +518,9 @@ const TimetableTableWithScroll: React.FC<{
{data.length === 0 && ( -

{t("timetable.noData", "No hay datos de horarios disponibles")}

+

+ {t("timetable.noData", "No hay datos de horarios disponibles")} +

)} ); -- cgit v1.3