From a68ba30716062b265f85c4be078a736c7135d7bc Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 30 Nov 2025 20:49:48 +0100 Subject: Refactor StopMap and Settings components; replace region config usage with REGION_DATA, update StopDataProvider calls, and improve UI elements. Remove unused timetable files and add Tailwind CSS support. --- src/frontend/app/components/GroupedTable.tsx | 90 ---- src/frontend/app/components/LineIcon.css | 12 + src/frontend/app/components/LineIcon.tsx | 20 +- src/frontend/app/components/PullToRefresh.css | 2 +- src/frontend/app/components/PullToRefresh.tsx | 13 +- src/frontend/app/components/RegularTable.tsx | 94 ---- src/frontend/app/components/SchedulesTable.css | 208 -------- src/frontend/app/components/SchedulesTable.tsx | 213 -------- .../app/components/SchedulesTableSkeleton.tsx | 136 ----- src/frontend/app/components/StopGalleryItem.tsx | 12 +- src/frontend/app/components/StopItem.tsx | 9 +- src/frontend/app/components/StopMapModal.tsx | 32 +- src/frontend/app/components/StopMapSheet.tsx | 38 +- src/frontend/app/components/StopSheet.tsx | 86 ++-- .../Stops/ConsolidatedCirculationCard.css | 10 +- .../Stops/ConsolidatedCirculationCard.tsx | 37 +- .../Stops/ConsolidatedCirculationList.tsx | 4 - src/frontend/app/components/layout/Header.tsx | 1 - src/frontend/app/components/ui/Button.css | 39 -- src/frontend/app/components/ui/Button.tsx | 25 - src/frontend/app/components/ui/PageContainer.css | 20 - src/frontend/app/components/ui/PageContainer.tsx | 14 - src/frontend/app/config/RegionConfig.ts | 69 +-- src/frontend/app/contexts/MapContext.tsx | 35 +- src/frontend/app/contexts/SettingsContext.tsx | 46 +- src/frontend/app/data/LineColors.ts | 13 +- src/frontend/app/data/StopDataProvider.ts | 103 ++-- src/frontend/app/i18n/locales/en-GB.json | 26 +- src/frontend/app/i18n/locales/es-ES.json | 26 +- src/frontend/app/i18n/locales/gl-ES.json | 26 +- src/frontend/app/routes.tsx | 2 - src/frontend/app/routes/estimates-$id.css | 270 ---------- src/frontend/app/routes/estimates-$id.tsx | 374 -------------- src/frontend/app/routes/home.tsx | 20 +- src/frontend/app/routes/map.tsx | 42 +- src/frontend/app/routes/settings.tsx | 132 +---- src/frontend/app/routes/stops-$id.css | 2 +- src/frontend/app/routes/stops-$id.tsx | 48 +- src/frontend/app/routes/timetable-$id.css | 224 -------- src/frontend/app/routes/timetable-$id.tsx | 570 --------------------- src/frontend/app/tailwind.css | 6 + 41 files changed, 286 insertions(+), 2863 deletions(-) delete mode 100644 src/frontend/app/components/GroupedTable.tsx delete mode 100644 src/frontend/app/components/RegularTable.tsx delete mode 100644 src/frontend/app/components/SchedulesTable.css delete mode 100644 src/frontend/app/components/SchedulesTable.tsx delete mode 100644 src/frontend/app/components/SchedulesTableSkeleton.tsx delete mode 100644 src/frontend/app/components/ui/Button.css delete mode 100644 src/frontend/app/components/ui/Button.tsx delete mode 100644 src/frontend/app/components/ui/PageContainer.css delete mode 100644 src/frontend/app/components/ui/PageContainer.tsx delete mode 100644 src/frontend/app/routes/estimates-$id.css delete mode 100644 src/frontend/app/routes/estimates-$id.tsx delete mode 100644 src/frontend/app/routes/timetable-$id.css delete mode 100644 src/frontend/app/routes/timetable-$id.tsx create mode 100644 src/frontend/app/tailwind.css (limited to 'src/frontend/app') diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx deleted file mode 100644 index 175899a..0000000 --- a/src/frontend/app/components/GroupedTable.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { type RegionConfig } from "../config/RegionConfig"; -import { type Estimate } from "../routes/estimates-$id"; -import LineIcon from "./LineIcon"; - -interface GroupedTable { - data: Estimate[]; - dataDate: Date | null; - regionConfig: RegionConfig; -} - -export const GroupedTable: React.FC = ({ - data, - dataDate, - regionConfig, -}) => { - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} m`; - } - }; - - const groupedEstimates = data.reduce( - (acc, estimate) => { - if (!acc[estimate.line]) { - acc[estimate.line] = []; - } - acc[estimate.line].push(estimate); - return acc; - }, - {} as Record - ); - - const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { - const firstArrivalA = groupedEstimates[a][0].minutes; - const firstArrivalB = groupedEstimates[b][0].minutes; - return firstArrivalA - firstArrivalB; - }); - - return ( - - - - - - - - - {regionConfig.showMeters && } - - - - - {sortedLines.map((line) => - groupedEstimates[line].map((estimate, idx) => ( - - {idx === 0 && ( - - )} - - - {regionConfig.showMeters && ( - - )} - - )) - )} - - - {data?.length === 0 && ( - - - - - - )} -
- Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()} -
LíneaRutaLlegadaDistancia
- - {estimate.route}{`${estimate.minutes} min`} - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible"} -
- No hay estimaciones disponibles -
- ); -}; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 01ff2bd..89f8bdb 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -71,6 +71,18 @@ --line-u2-text: hsl(0, 0%, 100%); --line-vts: hsl(300, 33%, 30%); --line-vts-text: hsl(0, 0%, 100%); + + /* Special christmas line - Touristic bus */ + --line-nad: hsl(0, 100%, 40%); + --line-nad-text: hsl(0, 0%, 100%); + + --line-mar: hsl(208, 68%, 66%); + --line-mar-text: hsl(0, 0%, 100%); + --line-rio: hsl(208, 68%, 66%); + --line-rio-text: hsl(0, 0%, 100%); + --line-gol: hsl(208, 68%, 66%); + --line-gol-text: hsl(0, 0%, 100%); + } .line-icon-default { diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 5ccf80a..fc40824 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,25 +1,23 @@ import React, { useMemo } from "react"; -import { type RegionId } from "../config/RegionConfig"; import "./LineIcon.css"; interface LineIconProps { line: string; - - /** - * @deprecated Unused since region is only Vigo - */ - region?: RegionId; - - mode?: "rounded"|"pill"|"default"; + mode?: "rounded" | "pill" | "default"; } const LineIcon: React.FC = ({ line, mode = "default", }) => { + const actualLine = useMemo(() => { + return line.trim().replace('510', 'NAD'); + }, [line]) + const formattedLine = useMemo(() => { - return /^[a-zA-Z]/.test(line) ? line : `L${line}`; - }, [line]); + return /^[a-zA-Z]/.test(actualLine) ? actualLine : `L${actualLine}`; + }, [actualLine]); + const cssVarName = `--line-${formattedLine.toLowerCase()}`; const cssTextVarName = `--line-${formattedLine.toLowerCase()}-text`; @@ -33,7 +31,7 @@ const LineIcon: React.FC = ({ } as React.CSSProperties } > - {line} + {actualLine} ); }; diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css index d4946d2..3e8f802 100644 --- a/src/frontend/app/components/PullToRefresh.css +++ b/src/frontend/app/components/PullToRefresh.css @@ -6,7 +6,7 @@ .pull-to-refresh-indicator { position: fixed; - top: 20px; + top: 80px; left: 50%; transform: translateX(-50%); z-index: 1000; diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx index b3abe86..2de1a4f 100644 --- a/src/frontend/app/components/PullToRefresh.tsx +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useState, useEffect, useCallback } from "react"; import { motion, useMotionValue, useTransform } from "framer-motion"; import { RefreshCw } from "lucide-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import "./PullToRefresh.css"; interface PullToRefreshProps { @@ -26,15 +26,6 @@ export const PullToRefresh: React.FC = ({ const scale = useTransform(y, [0, threshold], [0.5, 1]); const rotate = useTransform(y, [0, threshold], [0, 180]); - const isAtPageTop = useCallback(() => { - const scrollTop = - window.pageYOffset || - document.documentElement.scrollTop || - document.body.scrollTop || - 0; - return scrollTop <= 10; // Increased tolerance to 10px - }, []); - const handleTouchStart = useCallback( (e: TouchEvent) => { // Very strict check - must be at absolute top @@ -160,7 +151,6 @@ export const PullToRefresh: React.FC = ({ return (
- {/* Simple indicator */} {isPulling && ( = ({ )} - {/* Normal content - no transform interference */}
{children}
); diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx deleted file mode 100644 index a738d03..0000000 --- a/src/frontend/app/components/RegularTable.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { type RegionConfig } from "../config/RegionConfig"; -import { type Estimate } from "../routes/estimates-$id"; -import LineIcon from "./LineIcon"; - -interface RegularTableProps { - data: Estimate[]; - dataDate: Date | null; - regionConfig: RegionConfig; -} - -export const RegularTable: React.FC = ({ - data, - dataDate, - regionConfig, -}) => { - const { t } = useTranslation(); - - const absoluteArrivalTime = (minutes: number) => { - const now = new Date(); - const arrival = new Date(now.getTime() + minutes * 60000); - return Intl.DateTimeFormat( - typeof navigator !== "undefined" ? navigator.language : "en", - { - hour: "2-digit", - minute: "2-digit", - } - ).format(arrival); - }; - - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} ${t("estimates.meters", "m")}`; - } - }; - - return ( - - - - - - - - - {regionConfig.showMeters && ( - - )} - - - - - {data - .sort((a, b) => a.minutes - b.minutes) - .map((estimate, idx) => ( - - - - - {regionConfig.showMeters && ( - - )} - - ))} - - - {data?.length === 0 && ( - - - - - - )} -
- {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { - time: dataDate?.toLocaleTimeString(), - })} -
{t("estimates.line", "Línea")}{t("estimates.route", "Ruta")}{t("estimates.arrival", "Llegada")}{t("estimates.distance", "Distancia")}
- - {estimate.route} - {estimate.minutes > 15 - ? absoluteArrivalTime(estimate.minutes) - : `${estimate.minutes} ${t("estimates.minutes", "min")}`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : t("estimates.not_available", "No disponible")} -
- {t("estimates.none", "No hay estimaciones disponibles")} -
- ); -}; diff --git a/src/frontend/app/components/SchedulesTable.css b/src/frontend/app/components/SchedulesTable.css deleted file mode 100644 index c0c5cb7..0000000 --- a/src/frontend/app/components/SchedulesTable.css +++ /dev/null @@ -1,208 +0,0 @@ -.timetable-container { - margin-top: 2rem; -} - -.timetable-caption { - font-weight: bold; - margin-bottom: 1rem; - text-align: left; - font-size: 1.1rem; - color: var(--text-primary); -} - -.timetable-cards { - display: flex; - flex-direction: column; - gap: 1rem; - margin-bottom: 1rem; -} - -.timetable-card { - background-color: var(--surface-future); - border: 1px solid var(--card-border); - border-radius: 10px; - padding: 1.25rem; - transition: - background-color 0.2s ease, - border 0.2s ease; -} - -/* Next upcoming service: slight emphasis */ -.timetable-card.timetable-next { - background-color: var(--surface-next); - border-color: var(--card-border); - position: relative; -} - -.timetable-card.timetable-next::before { - content: ""; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-top-left-radius: 10px; - border-bottom-left-radius: 10px; - background: var(--accent-next); -} - -.timetable-card.timetable-past { - background-color: var(--surface-past); - color: var(--text-secondary); - border: 1px solid var(--card-border); -} - -.timetable-card .card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.timetable-card .line-info { - flex-shrink: 0; -} - -.timetable-card .destination-info { - flex: 1; - text-align: left; - margin: 0 1rem; - color: var(--text-primary); -} - -.timetable-card .destination-info strong { - font-size: 0.95rem; -} - -.timetable-card.timetable-past .destination-info { - color: var(--text-secondary); -} - -.timetable-card .time-info { - display: flex; - flex-direction: column; - align-items: flex-end; - flex-shrink: 0; -} - -.timetable-card .timetable-card .departure-time { - font-weight: bold; - font-family: monospace; - font-size: 1.1rem; - color: var(--text-primary); -} - -.timetable-card.timetable-past .departure-time { - color: var(--text-secondary); -} - -.timetable-card .card-body { - line-height: 1.4; -} - -.timetable-card .route-streets { - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.8; - word-break: break-word; -} - -.timetable-card .timetable-card .service-id { - font-family: monospace; - font-size: 0.8rem; - color: var(--text-secondary); - background: var(--service-background); - padding: 0.15rem 0.4rem; - border-radius: 3px; - font-weight: 500; - display: inline; - margin-right: 0.2em; -} - -.timetable-card.timetable-past .service-id { - color: var(--text-secondary); - background: var(--service-background-past); -} - -.timetable-container .no-data { - text-align: center; - color: var(--text-secondary); - font-style: italic; - padding: 2rem; - background: var(--card-background); - border-radius: 8px; - border: 1px solid var(--card-border); -} - -/* Responsive design */ -@media (max-width: 768px) { - .timetable-cards { - gap: 0.5rem; - } - .timetable-card { - padding: 0.75rem; - } - .timetable-card .card-header { - margin-bottom: 0.5rem; - } - .timetable-card .destination-info { - margin: 0 0.5rem; - } - .timetable-card .destination-info strong { - font-size: 0.9rem; - } - .timetable-card .departure-time { - font-size: 1rem; - } - .timetable-card .service-id { - font-size: 0.8rem; - padding: 0.2rem 0.4rem; - } -} - -@media (max-width: 480px) { - .timetable-card .card-header { - flex-direction: column; - align-items: stretch; - gap: 0.5rem; - } - - .timetable-card .destination-info { - text-align: left; - margin: 0; - order: 2; - } - - .timetable-card .time-info { - align-items: flex-start; - order: 1; - align-self: flex-end; - } - - .timetable-card .line-info { - order: 0; - align-self: flex-start; - } - - /* Create a flex container for line and time on mobile */ - .timetable-card .card-header { - position: relative; - } - - .timetable-card .line-info { - position: absolute; - left: 0; - top: 0; - } - - .timetable-card .time-info { - position: absolute; - right: 0; - top: 0; - } - - .timetable-card .destination-info { - margin-top: 2rem; - text-align: left; - } -} diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx deleted file mode 100644 index faf7b01..0000000 --- a/src/frontend/app/components/SchedulesTable.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useApp } from "~/AppContext"; -import LineIcon from "./LineIcon"; -import "./SchedulesTable.css"; - -export type ScheduledTable = { - trip_id: string; - service_id: string; - - line: string; - route: string; - - stop_sequence: number; - shape_dist_traveled: number; - next_streets: string[]; - - starting_code: string; - starting_name: string; - starting_time: string; - - calling_time: string; - calling_ssm: number; - - terminus_code: string; - terminus_name: string; - terminus_time: string; -}; - -interface TimetableTableProps { - data: ScheduledTable[]; - showAll?: boolean; - currentTime?: string; // HH:MM:SS format -} - -// 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 lastPart = parts[parts.length - 1]; - if (lastPart.length < 6) return ""; - - const last6 = lastPart.slice(-6); - const lineCode = last6.slice(0, 3); - const turnCode = last6.slice(-3); - - // Remove leading zeros from turn - const turnNumber = parseInt(turnCode, 10).toString(); - - // Parse line number with special cases - const lineNumber = parseInt(lineCode, 10); - 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 201: - displayLine = "U1"; - break; - case 202: - displayLine = "U2"; - break; - case 500: - displayLine = "TUR"; - break; - default: - displayLine = `L${lineNumber}`; - } - - return `${displayLine}-${turnNumber}`; -}; - -// Utility function to compare times -const timeToMinutes = (time: string): number => { - const [hours, minutes] = time.split(":").map(Number); - return hours * 60 + minutes; -}; - -// Utility function to format GTFS time for display (handle hours >= 24) -const formatTimeForDisplay = (time: string): string => { - const [hours, minutes] = time.split(":").map(Number); - const normalizedHours = hours % 24; - return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; -}; - -// Utility function to find nearby entries -const findNearbyEntries = ( - entries: ScheduledTable[], - currentTime: string, - before: number = 4, - after: number = 4 -): ScheduledTable[] => { - if (!currentTime) return entries.slice(0, before + after); - - const currentMinutes = timeToMinutes(currentTime); - const sortedEntries = [...entries].sort( - (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time) - ); - - let currentIndex = sortedEntries.findIndex( - (entry) => timeToMinutes(entry.calling_time) >= currentMinutes - ); - - if (currentIndex === -1) { - // All entries are before current time, show last ones - return sortedEntries.slice(-before - after); - } - - const startIndex = Math.max(0, currentIndex - before); - const endIndex = Math.min(sortedEntries.length, currentIndex + after); - - return sortedEntries.slice(startIndex, endIndex); -}; - -export const SchedulesTable: React.FC = ({ - data, - showAll = false, - currentTime, -}) => { - const { t } = useTranslation(); - const { region } = useApp(); - - const displayData = showAll - ? data - : findNearbyEntries(data, currentTime || ""); - const nowMinutes = currentTime - ? timeToMinutes(currentTime) - : timeToMinutes(new Date().toTimeString().slice(0, 8)); - - return ( -
-
- {showAll - ? t("timetable.fullCaption", "Horarios teóricos de la parada") - : t("timetable.nearbyCaption", "Próximos horarios teóricos")} -
- -
- {displayData.map((entry, index) => { - const entryMinutes = timeToMinutes(entry.calling_time); - const isPast = entryMinutes < nowMinutes; - return ( -
-
-
- -
- -
- {entry.route && entry.route.trim() ? ( - {entry.route} - ) : ( - - {t("timetable.noDestination", "Línea")} {entry.line} - - )} -
- -
- - {formatTimeForDisplay(entry.calling_time)} - -
-
-
-
- - {parseServiceId(entry.service_id)} - - {entry.next_streets.length > 0 && ( - — {entry.next_streets.join(" — ")} - )} -
-
-
- ); - })} -
- {displayData.length === 0 && ( -

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

- )} -
- ); -}; diff --git a/src/frontend/app/components/SchedulesTableSkeleton.tsx b/src/frontend/app/components/SchedulesTableSkeleton.tsx deleted file mode 100644 index 3ae9729..0000000 --- a/src/frontend/app/components/SchedulesTableSkeleton.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React from "react"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; -import { useTranslation } from "react-i18next"; - -interface EstimatesTableSkeletonProps { - rows?: number; -} - -export const SchedulesTableSkeleton: React.FC = ({ - rows = 3, -}) => { - const { t } = useTranslation(); - - return ( - - - - - - - - - - - - - - - {Array.from({ length: rows }, (_, index) => ( - - - - - - - ))} - -
- -
{t("estimates.line", "Línea")}{t("estimates.route", "Ruta")}{t("estimates.arrival", "Llegada")}{t("estimates.distance", "Distancia")}
- - - - -
- - -
-
- -
-
- ); -}; - -interface EstimatesGroupedSkeletonProps { - groups?: number; - rowsPerGroup?: number; -} - -export const EstimatesGroupedSkeleton: React.FC< - EstimatesGroupedSkeletonProps -> = ({ groups = 3, rowsPerGroup = 2 }) => { - const { t } = useTranslation(); - - return ( - - - - - - - - - - - - - - - {Array.from({ length: groups }, (_, groupIndex) => ( - - {Array.from({ length: rowsPerGroup }, (_, rowIndex) => ( - - - - - - - ))} - - ))} - -
- -
{t("estimates.line", "Línea")}{t("estimates.route", "Ruta")}{t("estimates.arrival", "Llegada")}{t("estimates.distance", "Distancia")}
- {rowIndex === 0 && ( - - )} - - - -
- - -
-
- -
-
- ); -}; diff --git a/src/frontend/app/components/StopGalleryItem.tsx b/src/frontend/app/components/StopGalleryItem.tsx index b32661a..72a13e5 100644 --- a/src/frontend/app/components/StopGalleryItem.tsx +++ b/src/frontend/app/components/StopGalleryItem.tsx @@ -1,30 +1,26 @@ import React from "react"; import { Link } from "react-router"; -import { type Stop } from "../data/StopDataProvider"; +import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import LineIcon from "./LineIcon"; -import { useApp } from "../AppContext"; -import StopDataProvider from "../data/StopDataProvider"; interface StopGalleryItemProps { stop: Stop; } const StopGalleryItem: React.FC = ({ stop }) => { - const { region } = useApp(); - return (
- +
{stop.favourite && } ({stop.stopId})
- {StopDataProvider.getDisplayName(region, stop)} + {StopDataProvider.getDisplayName(stop)}
{stop.lines?.slice(0, 5).map((line) => ( - + ))} {stop.lines && stop.lines.length > 5 && ( +{stop.lines.length - 5} diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx index de51576..7875b59 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -1,6 +1,5 @@ import React from "react"; import { Link } from "react-router"; -import { useApp } from "../AppContext"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import LineIcon from "./LineIcon"; @@ -9,15 +8,13 @@ interface StopItemProps { } const StopItem: React.FC = ({ stop }) => { - const { region } = useApp(); - return (
  • - +
    {stop.favourite && } - {StopDataProvider.getDisplayName(region, stop)} + {StopDataProvider.getDisplayName(stop)} ({stop.stopId}) @@ -25,7 +22,7 @@ const StopItem: React.FC = ({ stop }) => {
    {stop.lines?.map((line) => ( - + ))}
    diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index 74f20d9..55ad848 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -1,15 +1,15 @@ import maplibregl from "maplibre-gl"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map, { - Layer, - Marker, - Source, - type MapRef + Layer, + Marker, + Source, + type MapRef } from "react-map-gl/maplibre"; import { Sheet } from "react-modal-sheet"; import { useApp } from "~/AppContext"; -import { getRegionConfig, type RegionId } from "~/config/RegionConfig"; -import { getLineColor } from "~/data/LineColors"; +import { REGION_DATA } from "~/config/RegionConfig"; +import { getLineColour } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; import "./StopMapModal.css"; @@ -37,7 +37,6 @@ export interface ConsolidatedCirculationForMap { interface StopMapModalProps { stop: Stop; circulations: ConsolidatedCirculationForMap[]; - region: RegionId; isOpen: boolean; onClose: () => void; selectedCirculationId?: string; @@ -46,7 +45,6 @@ interface StopMapModalProps { export const StopMapModal: React.FC = ({ stop, circulations, - region, isOpen, onClose, selectedCirculationId, @@ -59,8 +57,6 @@ export const StopMapModal: React.FC = ({ const [shapeData, setShapeData] = useState(null); const [previousShapeData, setPreviousShapeData] = useState(null); - const regionConfig = getRegionConfig(region); - // Filter circulations that have GPS coordinates const busesWithPosition = useMemo( () => circulations.filter((c) => !!c.currentPosition), @@ -165,7 +161,7 @@ export const StopMapModal: React.FC = ({ maxZoom: 17, } as any); } - } catch {} + } catch { } }, [stop, selectedBus, shapeData, previousShapeData]); // Load style without traffic layers for the stop map @@ -246,7 +242,7 @@ export const StopMapModal: React.FC = ({ !selectedBus || !selectedBus.schedule?.shapeId || selectedBus.currentPosition?.shapeIndex === undefined || - !regionConfig.shapeEndpoint + !REGION_DATA.shapeEndpoint ) { setShapeData(null); setPreviousShapeData(null); @@ -266,7 +262,7 @@ export const StopMapModal: React.FC = ({ sLat?: number, sLon?: number ) => { - let url = `${regionConfig.shapeEndpoint}?shapeId=${sId}`; + let url = `${REGION_DATA.shapeEndpoint}?shapeId=${sId}`; if (bIndex !== undefined) url += `&busShapeIndex=${bIndex}`; if (sIndex !== undefined) url += `&stopShapeIndex=${sIndex}`; else if (sLat && sLon) url += `&stopLat=${sLat}&stopLon=${sLon}`; @@ -334,7 +330,7 @@ export const StopMapModal: React.FC = ({ }; loadShapes().catch((err) => console.error("Failed to load shape", err)); - }, [isOpen, selectedBus, regionConfig.shapeEndpoint]); + }, [isOpen, selectedBus]); if (busesWithPosition.length === 0) { return null; // Don't render if no buses with GPS coordinates @@ -362,7 +358,7 @@ export const StopMapModal: React.FC = ({ }} style={{ width: "100%", height: "50vh" }} mapStyle={styleSpec} - attributionControl={{compact: false, customAttribution: "Concello de Vigo & Viguesa de Transportes SL"}} + attributionControl={{ compact: false, customAttribution: "Concello de Vigo & Viguesa de Transportes SL" }} ref={mapRef} interactive={true} onMove={(e) => { @@ -422,7 +418,7 @@ export const StopMapModal: React.FC = ({ id="prev-route-shape-inner" type="line" paint={{ - "line-color": getLineColor(region, selectedBus.line) + "line-color": getLineColour(selectedBus.line) .background, "line-width": 4, "line-dasharray": [2, 2], @@ -455,7 +451,7 @@ export const StopMapModal: React.FC = ({ id="route-shape-inner" type="line" paint={{ - "line-color": getLineColor(region, selectedBus.line) + "line-color": getLineColour(selectedBus.line) .background, "line-width": 3, "line-opacity": 0.7, @@ -534,7 +530,7 @@ export const StopMapModal: React.FC = ({ > = ({ stop, - circulations, - region, + circulations }) => { const { theme } = useApp(); const [styleSpec, setStyleSpec] = useState(null); @@ -70,18 +68,16 @@ export const StopMap: React.FC = ({ const [showAttribution, setShowAttribution] = useState(false); const [shapes, setShapes] = useState>({}); - const regionConfig = getRegionConfig(region); - useEffect(() => { circulations.forEach((c) => { if ( c.schedule?.shapeId && c.currentPosition?.shapeIndex !== undefined && - regionConfig.shapeEndpoint + REGION_DATA.shapeEndpoint ) { const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; if (!shapes[key]) { - let url = `${regionConfig.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`; + let url = `${REGION_DATA.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`; if (c.stopShapeIndex !== undefined) { url += `&stopShapeIndex=${c.stopShapeIndex}`; } else { @@ -102,7 +98,7 @@ export const StopMap: React.FC = ({ } } }); - }, [circulations, regionConfig.shapeEndpoint, shapes]); + }, [circulations, shapes]); type Pt = { lat: number; lon: number }; const haversineKm = (a: Pt, b: Pt) => { @@ -195,7 +191,7 @@ export const StopMap: React.FC = ({ accuracy: pos.coords.accuracy, }); }, - () => {}, + () => { }, { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 } ); geoWatchId.current = navigator.geolocation.watchPosition( @@ -206,15 +202,15 @@ export const StopMap: React.FC = ({ accuracy: pos.coords.accuracy, }); }, - () => {}, + () => { }, { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 } ); - } catch {} + } catch { } return () => { if (geoWatchId.current != null && "geolocation" in navigator) { try { navigator.geolocation.clearWatch(geoWatchId.current); - } catch {} + } catch { } } }; }, []); @@ -278,7 +274,7 @@ export const StopMap: React.FC = ({ maxZoom: 17, } as any); } - } catch {} + } catch { } }; const handleCenter = () => { @@ -318,7 +314,7 @@ export const StopMap: React.FC = ({ maxZoom: 17, } as any); } - } catch {} + } catch { } }; return ( @@ -347,7 +343,7 @@ export const StopMap: React.FC = ({ const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; const shapeData = shapes[key]; if (!shapeData) return null; - const lineColor = getLineColor(region, c.line); + const lineColor = getLineColour(c.line); return ( = ({ const pts = busPositions.map((c) => c.currentPosition ? map.project([ - c.currentPosition.longitude, - c.currentPosition.latitude, - ]) + c.currentPosition.longitude, + c.currentPosition.latitude, + ]) : null ); for (let i = 0; i < pts.length; i++) { @@ -462,7 +458,7 @@ export const StopMap: React.FC = ({ return busPositions.map((c, idx) => { const p = c.currentPosition!; - const lineColor = getLineColor(region, c.line); + const lineColor = getLineColour(c.line); const showLabel = zoom >= 13; const labelGap = gaps[idx] ?? baseGap; return ( diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 6d2abf0..77bb5f1 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -3,9 +3,8 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; +import { REGION_DATA } from "~/config/RegionConfig"; import type { Stop } from "~/data/StopDataProvider"; -import { useApp } from "../AppContext"; -import { type RegionId, getRegionConfig } from "../config/RegionConfig"; import { type ConsolidatedCirculation } from "../routes/stops-$id"; import { ErrorDisplay } from "./ErrorDisplay"; import LineIcon from "./LineIcon"; @@ -27,12 +26,10 @@ interface ErrorInfo { } const loadConsolidatedData = async ( - region: RegionId, stopId: number ): Promise => { - const regionConfig = getRegionConfig(region); const resp = await fetch( - `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, { headers: { Accept: "application/json", @@ -53,8 +50,6 @@ export const StopSheet: React.FC = ({ stop, }) => { const { t } = useTranslation(); - const { region } = useApp(); - const regionConfig = getRegionConfig(region); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -87,7 +82,7 @@ export const StopSheet: React.FC = ({ setError(null); setData(null); - const stopData = await loadConsolidatedData(region, stop.stopId); + const stopData = await loadConsolidatedData(stop.stopId); setData(stopData); setLastUpdated(new Date()); } catch (err) { @@ -102,15 +97,15 @@ export const StopSheet: React.FC = ({ if (isOpen && stop.stopId) { loadData(); } - }, [isOpen, stop.stopId, region]); + }, [isOpen, stop.stopId]); // Show only the next 4 arrivals const sortedData = data ? [...data].sort( - (a, b) => - (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - - (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) - ) + (a, b) => + (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - + (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) + ) : []; const limitedEstimates = sortedData.slice(0, 4); @@ -130,7 +125,7 @@ export const StopSheet: React.FC = ({ > {stop.lines.map((line) => (
    - +
    ))}
  • @@ -166,7 +161,6 @@ export const StopSheet: React.FC = ({ ))} @@ -179,39 +173,39 @@ export const StopSheet: React.FC = ({
    - {lastUpdated && ( -
    - {t("estimates.last_updated", "Actualizado a las")}{" "} - {lastUpdated.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} -
    - )} - -
    - - - - {t("map.view_all_estimates", "Ver todas las estimaciones")} - + {lastUpdated && ( +
    + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })}
    + )} + +
    + + + + {t("map.view_all_estimates", "Ver todas las estimaciones")} +
    +
    diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css index 3dc33ea..e61ac25 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import '../../tailwind.css'; .consolidated-circulation-card { all: unset; @@ -133,19 +133,19 @@ } .meta-chip.delay-ok { - @apply bg-green-400/30 dark:bg-green-600/30 border-green-500 dark:border-green-700 text-green-800 dark:text-green-200; + @apply bg-green-600/80 dark:bg-green-600/30 border-green-500 dark:border-green-700 text-white dark:text-green-200; } .meta-chip.delay-warn { - @apply bg-yellow-400/30 dark:bg-yellow-600/30 border-yellow-500 dark:border-yellow-700 text-yellow-800 dark:text-yellow-200; + @apply bg-amber-600/80 dark:bg-yellow-600/30 border-yellow-500 dark:border-yellow-700 text-white dark:text-yellow-200; } .meta-chip.delay-critical { - @apply bg-red-400/30 dark:bg-red-600/30 border-red-500 dark:border-red-700 text-white; + @apply bg-red-400/80 dark:bg-red-600/30 border-red-500 dark:border-red-700 text-white; } .meta-chip.delay-early { - @apply bg-blue-400/30 dark:bg-blue-600/30 border-blue-500 dark:border-blue-700 text-blue-800 dark:text-blue-200; + @apply bg-blue-400/80 dark:bg-blue-600/30 border-blue-500 dark:border-blue-700 text-white dark:text-blue-200; } /* GPS Indicator */ diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 0b97c11..6f92644 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { type RegionConfig } from "~/config/RegionConfig"; import LineIcon from "~components/LineIcon"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; @@ -8,7 +7,6 @@ import "./ConsolidatedCirculationCard.css"; interface ConsolidatedCirculationCardProps { estimate: ConsolidatedCirculation; - regionConfig: RegionConfig; onMapClick?: () => void; readonly?: boolean; } @@ -72,7 +70,7 @@ const parseServiceId = (serviceId: string): string => { export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, regionConfig, onMapClick, readonly }) => { +> = ({ estimate, onMapClick, readonly }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -171,30 +169,28 @@ export const ConsolidatedCirculationCard: React.FC< const interactiveProps = readonly ? {} : { - onClick: onMapClick, - type: "button" as const, - disabled: !hasGpsPosition, - }; + onClick: onMapClick, + type: "button" as const, + disabled: !hasGpsPosition, + }; return (
    - +
    {estimate.route} @@ -202,9 +198,8 @@ export const ConsolidatedCirculationCard: React.FC< {hasGpsPosition && (
    )} diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 4c2916a..547fdf7 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -1,5 +1,4 @@ import { useTranslation } from "react-i18next"; -import { type RegionConfig } from "~/config/RegionConfig"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard"; @@ -8,14 +7,12 @@ import "./ConsolidatedCirculationList.css"; interface RegularTableProps { data: ConsolidatedCirculation[]; dataDate: Date | null; - regionConfig: RegionConfig; onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; } export const ConsolidatedCirculationList: React.FC = ({ data, dataDate, - regionConfig, onCirculationClick, }) => { const { t } = useTranslation(); @@ -44,7 +41,6 @@ export const ConsolidatedCirculationList: React.FC = ({ onCirculationClick?.(estimate, idx)} /> ))} diff --git a/src/frontend/app/components/layout/Header.tsx b/src/frontend/app/components/layout/Header.tsx index 2bdd764..b8184f1 100644 --- a/src/frontend/app/components/layout/Header.tsx +++ b/src/frontend/app/components/layout/Header.tsx @@ -1,5 +1,4 @@ import { Menu } from "lucide-react"; -import React from "react"; import "./Header.css"; interface HeaderProps { diff --git a/src/frontend/app/components/ui/Button.css b/src/frontend/app/components/ui/Button.css deleted file mode 100644 index bf02a7c..0000000 --- a/src/frontend/app/components/ui/Button.css +++ /dev/null @@ -1,39 +0,0 @@ -.ui-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.1s ease; - border: none; -} - -.ui-button:active { - transform: translateY(1px); -} - -.ui-button--primary { - background: var(--button-background-color); - color: white; -} - -.ui-button--primary:hover { - background: var(--button-hover-background-color); -} - -.ui-button--secondary { - background: var(--border-color); - color: var(--text-color); -} - -.ui-button--secondary:hover { - background: #e0e0e0; -} - -.ui-button__icon { - display: flex; - align-items: center; -} diff --git a/src/frontend/app/components/ui/Button.tsx b/src/frontend/app/components/ui/Button.tsx deleted file mode 100644 index 18a15b2..0000000 --- a/src/frontend/app/components/ui/Button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import "./Button.css"; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "outline"; - icon?: React.ReactNode; -} - -export const Button: React.FC = ({ - children, - variant = "primary", - icon, - className = "", - ...props -}) => { - return ( - - ); -}; diff --git a/src/frontend/app/components/ui/PageContainer.css b/src/frontend/app/components/ui/PageContainer.css deleted file mode 100644 index 8a86035..0000000 --- a/src/frontend/app/components/ui/PageContainer.css +++ /dev/null @@ -1,20 +0,0 @@ -.page-container { - max-width: 100%; - padding: 0 16px; - background-color: var(--background-color); - color: var(--text-color); -} - -@media (min-width: 768px) { - .page-container { - width: 90%; - max-width: 768px; - margin: 0 auto; - } -} - -@media (min-width: 1024px) { - .page-container { - max-width: 1024px; - } -} diff --git a/src/frontend/app/components/ui/PageContainer.tsx b/src/frontend/app/components/ui/PageContainer.tsx deleted file mode 100644 index 4c9684a..0000000 --- a/src/frontend/app/components/ui/PageContainer.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import "./PageContainer.css"; - -interface PageContainerProps { - children: React.ReactNode; - className?: string; -} - -export const PageContainer: React.FC = ({ - children, - className = "", -}) => { - return
    {children}
    ; -}; diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index a6ffdf8..4677509 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -1,53 +1,22 @@ -export type RegionId = "vigo"; +import type { LngLatLike } from "maplibre-gl"; -export interface RegionConfig { - id: RegionId; - name: string; - stopsEndpoint: string; - estimatesEndpoint: string; - consolidatedCirculationsEndpoint: string | null; - timetableEndpoint: string | null; - shapeEndpoint: string | null; - defaultCenter: [number, number]; // [lat, lng] - bounds?: { - sw: [number, number]; - ne: [number, number]; - }; - textColour?: string; - defaultZoom: number; - showMeters: boolean; // Whether to show distance in meters -} - -export const REGIONS: Record = { - vigo: { - id: "vigo", - name: "Vigo", - stopsEndpoint: "/stops/vigo.json", - estimatesEndpoint: "/api/vigo/GetStopEstimates", - consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", - timetableEndpoint: "/api/vigo/GetStopTimetable", - shapeEndpoint: "/api/vigo/GetShape", - defaultCenter: [42.229188855975046, -8.72246955783102], - bounds: { - sw: [-8.951059, 42.098923], - ne: [-8.447748, 42.3496], - }, - textColour: "#e72b37", - defaultZoom: 14, - showMeters: true, +export const REGION_DATA = { + id: "vigo", + name: "Vigo", + stopsEndpoint: "/stops/vigo.json", + estimatesEndpoint: "/api/vigo/GetStopEstimates", + consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", + timetableEndpoint: "/api/vigo/GetStopTimetable", + shapeEndpoint: "/api/vigo/GetShape", + defaultCenter: [ + 42.229188855975046, + -8.72246955783102 + ] as LngLatLike, + bounds: { + sw: [-8.951059, 42.098923] as LngLatLike, + ne: [-8.447748, 42.3496] as LngLatLike, }, + textColour: "#e72b37", + defaultZoom: 14, + showMeters: true, }; - -export const DEFAULT_REGION: RegionId = "vigo"; - -export function getRegionConfig(regionId: RegionId): RegionConfig { - return REGIONS[regionId]; -} - -export function getAvailableRegions(): RegionConfig[] { - return Object.values(REGIONS); -} - -export function isValidRegion(regionId: string): regionId is RegionId { - return regionId === "vigo"; -} diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx index b47b67f..af13bb7 100644 --- a/src/frontend/app/contexts/MapContext.tsx +++ b/src/frontend/app/contexts/MapContext.tsx @@ -1,13 +1,12 @@ import { type LngLatLike } from "maplibre-gl"; import { - createContext, - useContext, - useEffect, - useState, - type ReactNode, + createContext, + useContext, + useEffect, + useState, + type ReactNode, } from "react"; -import { getRegionConfig } from "../config/RegionConfig"; -import { useSettings } from "./SettingsContext"; +import { REGION_DATA } from "~/config/RegionConfig"; interface MapState { center: LngLatLike; @@ -28,9 +27,6 @@ interface MapContextProps { const MapContext = createContext(undefined); export const MapProvider = ({ children }: { children: ReactNode }) => { - const { region } = useSettings(); - const [prevRegion, setPrevRegion] = useState(region); - const [mapState, setMapState] = useState(() => { const savedMapState = localStorage.getItem("mapState"); if (savedMapState) { @@ -39,10 +35,9 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { // Validate that the saved center is valid if needed, or just trust it. // We might want to ensure we have a fallback if the region changed while the app was closed? // But for now, let's stick to the existing logic. - const regionConfig = getRegionConfig(region); return { - center: parsed.center || regionConfig.defaultCenter, - zoom: parsed.zoom || regionConfig.defaultZoom, + center: parsed.center || REGION_DATA.defaultCenter, + zoom: parsed.zoom || REGION_DATA.defaultZoom, userLocation: parsed.userLocation || null, hasLocationPermission: parsed.hasLocationPermission || false, }; @@ -50,10 +45,9 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { console.error("Error parsing saved map state", e); } } - const regionConfig = getRegionConfig(region); return { - center: regionConfig.defaultCenter, - zoom: regionConfig.defaultZoom, + center: REGION_DATA.defaultCenter, + zoom: REGION_DATA.defaultZoom, userLocation: null, hasLocationPermission: false, }; @@ -99,15 +93,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { }); }; - // Sync map state when region changes - useEffect(() => { - if (region !== prevRegion) { - const regionConfig = getRegionConfig(region); - updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom); - setPrevRegion(region); - } - }, [region, prevRegion]); - // Try to get user location on load if permission was granted useEffect(() => { if (mapState.hasLocationPermission && !mapState.userLocation) { diff --git a/src/frontend/app/contexts/SettingsContext.tsx b/src/frontend/app/contexts/SettingsContext.tsx index a273008..5f6ff46 100644 --- a/src/frontend/app/contexts/SettingsContext.tsx +++ b/src/frontend/app/contexts/SettingsContext.tsx @@ -1,16 +1,11 @@ import { - createContext, - useContext, - useEffect, - useState, - type ReactNode, + createContext, + useContext, + useEffect, + useState, + type ReactNode, } from "react"; import { APP_CONFIG } from "../config/AppConfig"; -import { - DEFAULT_REGION, - isValidRegion, - type RegionId -} from "../config/RegionConfig"; export type Theme = "light" | "dark" | "system"; export type TableStyle = "regular" | "grouped" | "experimental_consolidated"; @@ -21,15 +16,8 @@ interface SettingsContextProps { setTheme: React.Dispatch>; toggleTheme: () => void; - tableStyle: TableStyle; - setTableStyle: React.Dispatch>; - toggleTableStyle: () => void; - mapPositionMode: MapPositionMode; setMapPositionMode: (mode: MapPositionMode) => void; - - region: RegionId; - setRegion: (region: RegionId) => void; resolvedTheme: "light" | "dark"; } @@ -151,38 +139,14 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { }, [mapPositionMode]); //#endregion - //#region Region - const [region, setRegionState] = useState(() => { - const savedRegion = localStorage.getItem("region"); - if (savedRegion && isValidRegion(savedRegion)) { - return savedRegion; - } - return DEFAULT_REGION; - }); - - const setRegion = (newRegion: RegionId) => { - setRegionState(newRegion); - localStorage.setItem("region", newRegion); - }; - - useEffect(() => { - localStorage.setItem("region", region); - }, [region]); - //#endregion - return ( diff --git a/src/frontend/app/data/LineColors.ts b/src/frontend/app/data/LineColors.ts index fba150d..4e5fe8f 100644 --- a/src/frontend/app/data/LineColors.ts +++ b/src/frontend/app/data/LineColors.ts @@ -1,4 +1,3 @@ -import type { RegionId } from "../config/RegionConfig"; interface LineColorInfo { background: string; @@ -58,15 +57,11 @@ const defaultLineColor: LineColorInfo = { text: "#ffffff", }; -export function getLineColor(region: RegionId, line: string): LineColorInfo { +export function getLineColour(line: string): LineColorInfo { let formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`; formattedLine = formattedLine.toLowerCase().trim(); - if (region === "vigo") { - return ( - vigoLineColors[formattedLine.toLowerCase().trim()] ?? defaultLineColor - ); - } - - return defaultLineColor; + return ( + vigoLineColors[formattedLine.toLowerCase().trim()] ?? defaultLineColor + ); } diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 2f13e43..abe7123 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,4 +1,4 @@ -import { type RegionId, getRegionConfig } from "../config/RegionConfig"; +import { REGION_DATA } from "~/config/RegionConfig"; export interface CachedStopList { timestamp: number; @@ -32,51 +32,49 @@ const stopsMapByRegion: Record> = {}; const customNamesByRegion: Record> = {}; // Initialize cachedStops and customNames once per region -async function initStops(region: RegionId) { - if (!cachedStopsByRegion[region]) { - const regionConfig = getRegionConfig(region); - const response = await fetch(regionConfig.stopsEndpoint); +async function initStops() { + if (!cachedStopsByRegion[REGION_DATA.id]) { + const response = await fetch(REGION_DATA.stopsEndpoint); const stops = (await response.json()) as Stop[]; // build array and map - stopsMapByRegion[region] = {}; - cachedStopsByRegion[region] = stops.map((stop) => { + stopsMapByRegion[REGION_DATA.id] = {}; + cachedStopsByRegion[REGION_DATA.id] = stops.map((stop) => { const entry = { ...stop, favourite: false } as Stop; - stopsMapByRegion[region][stop.stopId] = entry; + stopsMapByRegion[REGION_DATA.id][stop.stopId] = entry; return entry; }); // load custom names - const rawCustom = localStorage.getItem(`customStopNames_${region}`); + const rawCustom = localStorage.getItem(`customStopNames_${REGION_DATA.id}`); if (rawCustom) { - customNamesByRegion[region] = JSON.parse(rawCustom) as Record< + customNamesByRegion[REGION_DATA.id] = JSON.parse(rawCustom) as Record< number, string >; } else { - customNamesByRegion[region] = {}; + customNamesByRegion[REGION_DATA.id] = {}; } } } -async function getStops(region: RegionId): Promise { - await initStops(region); +async function getStops(): Promise { + await initStops(); // update favourites - const rawFav = localStorage.getItem(`favouriteStops_${region}`); + const rawFav = localStorage.getItem("favouriteStops_vigo"); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; - cachedStopsByRegion[region]!.forEach( + cachedStopsByRegion["vigo"]!.forEach( (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)) ); - return cachedStopsByRegion[region]!; + return cachedStopsByRegion["vigo"]!; } // New: get single stop by id async function getStopById( - region: RegionId, stopId: number ): Promise { - await initStops(region); - const stop = stopsMapByRegion[region]?.[stopId]; + await initStops(); + const stop = stopsMapByRegion[REGION_DATA.id]?.[stopId]; if (stop) { - const rawFav = localStorage.getItem(`favouriteStops_${region}`); + const rawFav = localStorage.getItem(`favouriteStops_${REGION_DATA.id}`); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; stop.favourite = favouriteStops.includes(stopId); } @@ -84,42 +82,42 @@ async function getStopById( } // Updated display name to include custom names -function getDisplayName(region: RegionId, stop: Stop): string { - const customNames = customNamesByRegion[region] || {}; +function getDisplayName(stop: Stop): string { + const customNames = customNamesByRegion[REGION_DATA.id] || {}; if (customNames[stop.stopId]) return customNames[stop.stopId]; const nameObj = stop.name; return nameObj.intersect || nameObj.original; } // New: set or remove custom names -function setCustomName(region: RegionId, stopId: number, label: string) { - if (!customNamesByRegion[region]) { - customNamesByRegion[region] = {}; +function setCustomName(stopId: number, label: string) { + if (!customNamesByRegion[REGION_DATA.id]) { + customNamesByRegion[REGION_DATA.id] = {}; } - customNamesByRegion[region][stopId] = label; + customNamesByRegion[REGION_DATA.id][stopId] = label; localStorage.setItem( - `customStopNames_${region}`, - JSON.stringify(customNamesByRegion[region]) + `customStopNames_${REGION_DATA.id}`, + JSON.stringify(customNamesByRegion[REGION_DATA.id]) ); } -function removeCustomName(region: RegionId, stopId: number) { - if (customNamesByRegion[region]) { - delete customNamesByRegion[region][stopId]; +function removeCustomName(stopId: number) { + if (customNamesByRegion[REGION_DATA.id]?.[stopId]) { + delete customNamesByRegion[REGION_DATA.id][stopId]; localStorage.setItem( - `customStopNames_${region}`, - JSON.stringify(customNamesByRegion[region]) + `customStopNames_${REGION_DATA.id}`, + JSON.stringify(customNamesByRegion[REGION_DATA.id]) ); } } // New: get custom label for a stop -function getCustomName(region: RegionId, stopId: number): string | undefined { - return customNamesByRegion[region]?.[stopId]; +function getCustomName(stopId: number): string | undefined { + return customNamesByRegion[REGION_DATA.id]?.[stopId]; } -function addFavourite(region: RegionId, stopId: number) { - const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); +function addFavourite(stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; @@ -128,14 +126,14 @@ function addFavourite(region: RegionId, stopId: number) { if (!favouriteStops.includes(stopId)) { favouriteStops.push(stopId); localStorage.setItem( - `favouriteStops_${region}`, + `favouriteStops_vigo`, JSON.stringify(favouriteStops) ); } } -function removeFavourite(region: RegionId, stopId: number) { - const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); +function removeFavourite(stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; @@ -143,13 +141,13 @@ function removeFavourite(region: RegionId, stopId: number) { const newFavouriteStops = favouriteStops.filter((id) => id !== stopId); localStorage.setItem( - `favouriteStops_${region}`, + `favouriteStops_vigo`, JSON.stringify(newFavouriteStops) ); } -function isFavourite(region: RegionId, stopId: number): boolean { - const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); +function isFavourite(stopId: number): boolean { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); if (rawFavouriteStops) { const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; return favouriteStops.includes(stopId); @@ -159,8 +157,8 @@ function isFavourite(region: RegionId, stopId: number): boolean { const RECENT_STOPS_LIMIT = 10; -function pushRecent(region: RegionId, stopId: number) { - const rawRecentStops = localStorage.getItem(`recentStops_${region}`); +function pushRecent(stopId: number) { + const rawRecentStops = localStorage.getItem(`recentStops_vigo`); let recentStops: Set = new Set(); if (rawRecentStops) { recentStops = new Set(JSON.parse(rawRecentStops) as number[]); @@ -174,21 +172,21 @@ function pushRecent(region: RegionId, stopId: number) { } localStorage.setItem( - `recentStops_${region}`, + `recentStops_vigo`, JSON.stringify(Array.from(recentStops)) ); } -function getRecent(region: RegionId): number[] { - const rawRecentStops = localStorage.getItem(`recentStops_${region}`); +function getRecent(): number[] { + const rawRecentStops = localStorage.getItem(`recentStops_vigo`); if (rawRecentStops) { return JSON.parse(rawRecentStops) as number[]; } return []; } -function getFavouriteIds(region: RegionId): number[] { - const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); +function getFavouriteIds(): number[] { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); if (rawFavouriteStops) { return JSON.parse(rawFavouriteStops) as number[]; } @@ -196,9 +194,8 @@ function getFavouriteIds(region: RegionId): number[] { } // New function to load stops from network -async function loadStopsFromNetwork(region: RegionId): Promise { - const regionConfig = getRegionConfig(region); - const response = await fetch(regionConfig.stopsEndpoint); +async function loadStopsFromNetwork(): Promise { + const response = await fetch(REGION_DATA.stopsEndpoint); const stops = (await response.json()) as Stop[]; return stops.map((stop) => ({ ...stop, favourite: false }) as Stop); } diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 25ab97f..e09ebb2 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -12,34 +12,10 @@ "theme_light": "Light", "theme_dark": "Dark", "theme_system": "System", - "table_style": "Table style:", - "table_style_regular": "Show in order", - "table_style_grouped": "Group by line", - "table_style_experimental_consolidated": "(EXPERIMENTAL) Consolidated data", "map_position_mode": "Map position:", "map_position_gps": "GPS position", "map_position_last": "Where I left it", - "language": "Language", - "app_updates": "App updates", - "check_updates": "Check for updates", - "checking_updates": "Checking...", - "clear_cache": "Clear cache", - "sw_not_supported": "Service Workers are not supported in this browser", - "update_available": "New version available! A notification will appear to update.", - "up_to_date": "You already have the latest version.", - "update_error": "Error checking for updates. Try reloading the page.", - "clear_cache_confirm": "Are you sure you want to clear the cache? This will remove all locally stored data.", - "cache_cleared": "Cache cleared. The page will reload to apply changes.", - "cache_error": "Error clearing cache.", - "reset_pwa": "Reset PWA (Nuclear)", - "reset_pwa_confirm": "Are you sure? This will delete ALL app data and restart it completely. Use only if there are serious cache issues.", - "reset_pwa_error": "Error resetting PWA.", - "update_help": "If you're having issues with the app or don't see the latest features, use these buttons to force an update or clear stored data.", - "details_summary": "What does this mean?", - "details_table": "The timetable can be shown in two ways:", - "details_regular": "Stops are shown in the order they are visited. Apps like Infobus (Vitrasa) use this style.", - "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style.", - "details_experimental_consolidated": "Stops are shown using consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate. It works only in the Vigo region." + "language": "Language" }, "stoplist": { "search_placeholder": "Search stop by name or code...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index d567bb3..34d38f8 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -12,34 +12,10 @@ "theme_light": "Claro", "theme_dark": "Oscuro", "theme_system": "Sistema", - "table_style": "Estilo de tabla:", - "table_style_regular": "Mostrar por orden", - "table_style_grouped": "Agrupar por línea", - "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados", "map_position_mode": "Posición del mapa:", "map_position_gps": "Posición GPS", "map_position_last": "Donde lo dejé", - "language": "Idioma", - "app_updates": "Actualizaciones de la aplicación", - "check_updates": "Comprobar actualizaciones", - "checking_updates": "Comprobando...", - "clear_cache": "Limpiar caché", - "sw_not_supported": "Service Workers no son compatibles en este navegador", - "update_available": "¡Nueva versión disponible! Aparecerá una notificación para actualizar.", - "up_to_date": "Ya tienes la versión más reciente.", - "update_error": "Error al comprobar actualizaciones. Intenta recargar la página.", - "clear_cache_confirm": "¿Estás seguro de que quieres limpiar la caché? Esto eliminará todos los datos guardados localmente.", - "cache_cleared": "Caché limpiada. La página se recargará para aplicar los cambios.", - "cache_error": "Error al limpiar la caché.", - "reset_pwa": "Reiniciar PWA (Nuclear)", - "reset_pwa_confirm": "¿Estás seguro? Esto eliminará TODOS los datos de la aplicación y la reiniciará completamente. Úsalo solo si hay problemas graves de caché.", - "reset_pwa_error": "Error al reiniciar la PWA.", - "update_help": "Si tienes problemas con la aplicación o no ves las últimas funciones, usa estos botones para forzar una actualización o limpiar los datos guardados.", - "details_summary": "¿Qué significa esto?", - "details_table": "La tabla de horarios puede mostrarse de dos formas:", - "details_regular": "Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.", - "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.", - "details_experimental_consolidated": "Las paradas se muestran utilizando datos consolidados de múltiples fuentes en tiempo real. Esta función está en fase experimental y puede no ser completamente precisa. Funciona únicamente en la región de Vigo." + "language": "Idioma" }, "stoplist": { "search_placeholder": "Buscar parada por nombre o código...", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index aab3140..1b98730 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -12,34 +12,10 @@ "theme_light": "Claro", "theme_dark": "Escuro", "theme_system": "Sistema", - "table_style": "Estilo de táboa:", - "table_style_regular": "Mostrar por orde", - "table_style_grouped": "Agrupar por liña", - "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados", "map_position_mode": "Posición do mapa:", "map_position_gps": "Posición GPS", "map_position_last": "Onde o deixei", - "language": "Idioma", - "app_updates": "Actualizacións da aplicación", - "check_updates": "Comprobar actualizacións", - "checking_updates": "Comprobando...", - "clear_cache": "Limpar caché", - "sw_not_supported": "Os Service Workers non son compatibles neste navegador", - "update_available": "Nova versión dispoñible! Aparecerá unha notificación para actualizar.", - "up_to_date": "Xa tes a versión máis recente.", - "update_error": "Erro ao comprobar actualizacións. Tenta recargar a páxina.", - "clear_cache_confirm": "Estás seguro de que queres limpar a caché? Isto eliminará todos os datos gardados localmente.", - "cache_cleared": "Caché limpa. A páxina recargarase para aplicar os cambios.", - "cache_error": "Erro ao limpar a caché.", - "reset_pwa": "Reiniciar PWA (Nuclear)", - "reset_pwa_confirm": "Estás seguro? Isto eliminará TODOS os datos da aplicación e a reiniciará completamente. Úsao só se hai problemas graves de caché.", - "reset_pwa_error": "Erro ao reiniciar a PWA.", - "update_help": "Se tes problemas coa aplicación ou non ves as últimas funcións, usa estes botóns para forzar unha actualización ou limpar os datos gardados.", - "details_summary": "Que significa isto?", - "details_table": "A táboa de horarios pode mostrarse de dúas formas:", - "details_regular": "As paradas móstranse na orde na que se visitan. Aplicacións como Infobus (Vitrasa) usan este estilo.", - "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo.", - "details_experimental_consolidated": "As paradas móstranse utilizando datos consolidados de múltiples fontes en tempo real. Esta función está en fase experimental e pode non ser completamente precisa. Funciona unicamente na rexión de Vigo." + "language": "Idioma" }, "stoplist": { "search_placeholder": "Buscar parada por nome ou código...", diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 60671cd..4ade748 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -5,8 +5,6 @@ export default [ route("/map", "routes/map.tsx"), route("/stops", "routes/stops.tsx"), route("/stops/:id", "routes/stops-$id.tsx"), - route("/estimates/:id", "routes/estimates-$id.tsx"), - route("/timetable/:id", "routes/timetable-$id.tsx"), route("/settings", "routes/settings.tsx"), route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css deleted file mode 100644 index 0658156..0000000 --- a/src/frontend/app/routes/estimates-$id.css +++ /dev/null @@ -1,270 +0,0 @@ -.table-responsive { - overflow-x: auto; - margin-bottom: 1.5rem; -} - -.table { - width: 100%; - border-collapse: collapse; -} - -.table caption { - margin-bottom: 0.5rem; - font-weight: 500; -} - -.table th, -.table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid #eee; -} - -.table th { - border-bottom: 2px solid #ddd; -} - -.table tfoot td { - text-align: center; -} - -/* Estimates page specific styles */ -.estimates-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - gap: 1rem; -} - -.manual-refresh-button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--primary-color); - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - min-width: max-content; -} - -.manual-refresh-button:hover:not(:disabled) { - background: var(--primary-color-hover); - transform: translateY(-1px); -} - -.manual-refresh-button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.refresh-icon { - width: 1.5rem; - height: 1.5rem; - transition: transform 0.2s ease; -} - -.refresh-icon.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (max-width: 640px) { - .estimates-header { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .manual-refresh-button { - align-self: flex-end; - padding: 0.4rem 0.6rem; - font-size: 0.8rem; - } -} - -.estimates-stop-id { - font-size: 1rem; - color: var(--subtitle-color); - margin-left: 0.5rem; -} - -.estimates-arrival { - color: #28a745; - font-weight: 500; -} - -.estimates-delayed { - color: #dc3545; -} - -.button-group { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.button { - padding: 0.75rem 1rem; - background-color: var(--button-background-color); - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - text-align: center; - text-decoration: none; - display: inline-block; -} - -.button:hover { - background-color: var(--button-hover-background-color); -} - -.button:disabled { - background-color: var(--button-disabled-background-color); - cursor: not-allowed; -} - -.star-icon { - margin-right: 0.5rem; - color: #ccc; - fill: none; -} - -.star-icon.active { - color: var(--star-color); - /* Yellow color for active star */ - fill: var(--star-color); -} - -/* Pencil (edit) icon next to header */ -.edit-icon { - margin-right: 0.5rem; - color: #ccc; - cursor: pointer; - stroke-width: 2px; -} - -.edit-icon:hover { - color: var(--star-color); -} - -/* Timetable section styles */ -.timetable-section { - padding-bottom: 3rem; -} - -/* Timetable cards should be single column */ -.timetable-section .timetable-cards { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.timetable-section .timetable-card { - padding: 0.875rem; -} - -.timetable-actions { - margin-top: 1.5rem; - text-align: center; -} - -.view-all-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: var(--link-color, #007bff); - text-decoration: none; - font-weight: 500; - padding: 0.5rem 1rem; - border: 1px solid var(--link-color, #007bff); - border-radius: 6px; - transition: all 0.2s ease; -} - -.view-all-link:hover { - background-color: var(--link-color, #007bff); - color: white; - text-decoration: none; -} - -.external-icon { - width: 1rem; - height: 1rem; -} - -.estimates-lines-container { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.estimates-lines-container.scrollable { - flex-wrap: nowrap; - overflow-x: auto; - max-height: calc(2 * (var(--line-icon-height, 2rem) + 0.5rem)); - align-content: flex-start; - scrollbar-width: thin; -} - -.estimates-lines-container.scrollable::-webkit-scrollbar { - height: 6px; -} - -.estimates-lines-container.scrollable::-webkit-scrollbar-thumb { - background-color: var(--border-color); - border-radius: 3px; -} - -.estimates-line-icon { - flex-shrink: 0; -} - -.experimental-notice { - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1rem; - color: #856404; -} - -.experimental-notice strong { - display: block; - margin-bottom: 0.5rem; - color: #856404; -} - -.experimental-notice p { - margin: 0; - font-size: 0.9rem; - line-height: 1.4; -} - -[data-theme="dark"] .experimental-notice { - background-color: #3d3100; - border-color: #ffc107; - color: #ffd966; -} - -[data-theme="dark"] .experimental-notice strong { - color: #ffd966; -} diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx deleted file mode 100644 index afeb3d2..0000000 --- a/src/frontend/app/routes/estimates-$id.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { Edit2, ExternalLink, RefreshCw, Star } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate, useParams } from "react-router"; -import { ErrorDisplay } from "~/components/ErrorDisplay"; -import LineIcon from "~/components/LineIcon"; -import { PullToRefresh } from "~/components/PullToRefresh"; -import { - type ScheduledTable, - SchedulesTable, -} from "~/components/SchedulesTable"; -import { - EstimatesGroupedSkeleton, - SchedulesTableSkeleton, -} from "~/components/SchedulesTableSkeleton"; -import { StopAlert } from "~/components/StopAlert"; -import { TimetableSkeleton } from "~/components/TimetableSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; -import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import { useApp } from "../AppContext"; -import { GroupedTable } from "../components/GroupedTable"; -import { RegularTable } from "../components/RegularTable"; -import StopDataProvider, { type Stop } from "../data/StopDataProvider"; -import "./estimates-$id.css"; - -export interface Estimate { - line: string; - route: string; - minutes: number; - meters: number; -} - -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadData = async ( - region: RegionId, - stopId: string -): Promise => { - const regionConfig = getRegionConfig(region); - const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { - headers: { - Accept: "application/json", - }, - }); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - -const loadTimetableData = async ( - region: RegionId, - stopId: string -): Promise => { - const regionConfig = getRegionConfig(region); - - // Check if timetable is available for this region - if (!regionConfig.timetableEndpoint) { - throw new Error("Timetable not available for this region"); - } - - // Use "today" to let server determine date based on Europe/Madrid timezone - 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}`); - } - - return await resp.json(); -}; - -export default function Estimates() { - const { t } = useTranslation(); - const params = useParams(); - const stopIdNum = parseInt(params.id ?? ""); - const [customName, setCustomName] = useState(undefined); - const [stopData, setStopData] = useState(undefined); - - // Estimates data state - const [data, setData] = useState(null); - const [dataDate, setDataDate] = useState(null); - const [estimatesLoading, setEstimatesLoading] = useState(true); - const [estimatesError, setEstimatesError] = useState(null); - - // Timetable data state - const [timetableData, setTimetableData] = useState([]); - const [timetableLoading, setTimetableLoading] = useState(true); - const [timetableError, setTimetableError] = useState(null); - - const [favourited, setFavourited] = useState(false); - const [isManualRefreshing, setIsManualRefreshing] = useState(false); - const { tableStyle, region } = useApp(); - const regionConfig = getRegionConfig(region); - - // Redirect to /stops/$id if table style is experimental_consolidated - if (tableStyle === "experimental_consolidated") { - return ; - } - - const parseError = (error: any): ErrorInfo => { - if (!navigator.onLine) { - 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("HTTP")) { - const statusMatch = error.message.match(/HTTP (\d+):/); - const status = statusMatch ? parseInt(statusMatch[1]) : undefined; - return { type: "server", status }; - } - - return { type: "unknown", message: error.message }; - }; - - const loadEstimatesData = useCallback(async () => { - try { - setEstimatesLoading(true); - setEstimatesError(null); - - const body = await loadData(region, params.id!); - setData(body); - setDataDate(new Date()); - - // Load stop data from StopDataProvider - const stop = await StopDataProvider.getStopById(region, stopIdNum); - setStopData(stop); - setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); - } catch (error) { - console.error("Error loading estimates data:", error); - setEstimatesError(parseError(error)); - setData(null); - setDataDate(null); - } finally { - setEstimatesLoading(false); - } - }, [params.id, stopIdNum, region]); - - const loadTimetableDataAsync = useCallback(async () => { - // Skip loading timetable if not available for this region - if (!regionConfig.timetableEndpoint) { - setTimetableLoading(false); - return; - } - - try { - setTimetableLoading(true); - setTimetableError(null); - - const timetableBody = await loadTimetableData(region, params.id!); - setTimetableData(timetableBody); - } catch (error) { - console.error("Error loading timetable data:", error); - setTimetableError(parseError(error)); - setTimetableData([]); - } finally { - setTimetableLoading(false); - } - }, [params.id, region, regionConfig.timetableEndpoint]); - - // Manual refresh function for pull-to-refresh and button - const handleManualRefresh = useCallback(async () => { - try { - setIsManualRefreshing(true); - // Only reload real-time estimates data, not timetable - await loadEstimatesData(); - } finally { - setIsManualRefreshing(false); - } - }, [loadEstimatesData]); - - // Auto-refresh estimates data every 30 seconds (only if not in error state) - useAutoRefresh({ - onRefresh: loadEstimatesData, - interval: 30000, - enabled: !estimatesError, - }); - - useEffect(() => { - // Initial load - loadEstimatesData(); - loadTimetableDataAsync(); - - StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); - setFavourited( - StopDataProvider.isFavourite(region, parseInt(params.id ?? "")) - ); - }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]); - - const toggleFavourite = () => { - if (favourited) { - StopDataProvider.removeFavourite(region, stopIdNum); - setFavourited(false); - } else { - StopDataProvider.addFavourite(region, stopIdNum); - setFavourited(true); - } - }; - - // Helper function to get the display name for the stop - const getStopDisplayName = () => { - if (customName) return customName; - if (stopData?.name.intersect) return stopData.name.intersect; - if (stopData?.name.original) return stopData.name.original; - return `Parada ${stopIdNum}`; - }; - - const handleRename = () => { - const current = getStopDisplayName(); - const input = window.prompt("Custom name for this stop:", current); - if (input === null) return; // cancelled - const trimmed = input.trim(); - if (trimmed === "") { - StopDataProvider.removeCustomName(region, stopIdNum); - setCustomName(undefined); - } else { - StopDataProvider.setCustomName(region, stopIdNum, trimmed); - setCustomName(trimmed); - } - }; - - // Show loading skeleton while initial data is loading - if (estimatesLoading && !data) { - return ( - -
    -
    -

    - - - {t("common.loading")}... -

    -
    - -
    - {tableStyle === "grouped" ? ( - - ) : ( - - )} -
    - -
    - -
    -
    -
    - ); - } - - return ( - -
    -
    -

    - - - {getStopDisplayName()}{" "} - ({stopIdNum}) -

    - - -
    - - {stopData && stopData.lines && stopData.lines.length > 0 && ( -
    - {stopData.lines.map((line) => ( -
    - -
    - ))} -
    - )} - - {stopData && } - -
    - {estimatesLoading ? ( - tableStyle === "grouped" ? ( - - ) : ( - - ) - ) : estimatesError ? ( - - ) : data ? ( - tableStyle === "grouped" ? ( - - ) : ( - - ) - ) : null} -
    - -
    - {timetableLoading ? ( - - ) : timetableError ? ( - - ) : timetableData.length > 0 ? ( - <> - -
    - - - {t("timetable.viewAll", "Ver todos los horarios")} - -
    - - ) : null} -
    -
    -
    - ); -} diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 31a8e6a..7d8338f 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -124,9 +124,9 @@ export default function StopList() { const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRadians(lat1)) * - Math.cos(toRadians(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }; @@ -160,8 +160,8 @@ export default function StopList() { // Load favourite and recent IDs immediately from localStorage useEffect(() => { - setFavouriteIds(StopDataProvider.getFavouriteIds(region)); - setRecentIds(StopDataProvider.getRecent(region)); + setFavouriteIds(StopDataProvider.getFavouriteIds()); + setRecentIds(StopDataProvider.getRecent()); }, [region]); // Load stops from network @@ -169,10 +169,10 @@ export default function StopList() { try { setLoading(true); - const stops = await StopDataProvider.loadStopsFromNetwork(region); + const stops = await StopDataProvider.loadStopsFromNetwork(); // Add favourite flags to stops - const favouriteStopsIds = StopDataProvider.getFavouriteIds(region); + const favouriteStopsIds = StopDataProvider.getFavouriteIds(); const stopsWithFavourites = stops.map((stop) => ({ ...stop, favourite: favouriteStopsIds.includes(stop.stopId), @@ -186,7 +186,7 @@ export default function StopList() { ); setFavouriteStops(favStops); - const recIds = StopDataProvider.getRecent(region); + const recIds = StopDataProvider.getRecent(); const recStops = recIds .map((id) => stopsWithFavourites.find((stop) => stop.stopId === id)) .filter(Boolean) as Stop[]; @@ -304,8 +304,8 @@ export default function StopList() { )} {!loading && data ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map( - (stop) => - ) + (stop) => + ) : null}
    diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index df4808d..343cf91 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -15,7 +15,7 @@ import Map, { type StyleSpecification } from "react-map-gl/maplibre"; import { StopSheet } from "~/components/StopSheet"; -import { getRegionConfig } from "~/config/RegionConfig"; +import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp } from "../AppContext"; @@ -40,7 +40,7 @@ export default function StopMap() { >([]); const [selectedStop, setSelectedStop] = useState(null); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { mapState, updateMapState, theme, region } = useApp(); + const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); const [mapStyleKey, setMapStyleKey] = useState("light"); @@ -62,7 +62,7 @@ export default function StopMap() { }; useEffect(() => { - StopDataProvider.getStops(region).then((data) => { + StopDataProvider.getStops().then((data) => { const features: GeoJsonFeature< Point, { stopId: number; name: string; lines: string[]; cancelled?: boolean } @@ -81,7 +81,7 @@ export default function StopMap() { })); setStops(features); }); - }, [region]); + }, []); useEffect(() => { //const styleName = "carto"; @@ -155,7 +155,7 @@ export default function StopMap() { const stopId = parseInt(props.stopId, 10); // fetch full stop to get lines array - StopDataProvider.getStopById(region, stopId) + StopDataProvider.getStopById(stopId) .then((stop) => { if (!stop) { console.warn("Stop not found:", stopId); @@ -186,14 +186,10 @@ export default function StopMap() { zoom: mapState.zoom, }} attributionControl={{ compact: false }} - maxBounds={ - getRegionConfig(region).bounds - ? [getRegionConfig(region).bounds!.sw, getRegionConfig(region).bounds!.ne] - : undefined - } + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} > - + - {selectedStop && ( - setIsSheetOpen(false)} - stop={selectedStop} - /> - )} - + { + selectedStop && ( + setIsSheetOpen(false)} + stop={selectedStop} + /> + ) + } + ); } diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index 351ccf0..faad5a6 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,76 +1,41 @@ -import { useState } from "react"; +import { Computer, Moon, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Theme, useApp } from "../AppContext"; -import { getAvailableRegions } from "../config/RegionConfig"; import "./settings.css"; export default function Settings() { const { t, i18n } = useTranslation(); usePageTitle(t("navbar.settings", "Ajustes")); - const navigate = useNavigate(); const { theme, setTheme, - tableStyle, - setTableStyle, mapPositionMode, - setMapPositionMode, - region, - setRegion, + setMapPositionMode } = useApp(); - const regions = getAvailableRegions(); - const [showModal, setShowModal] = useState(false); - const [pendingRegion, setPendingRegion] = useState(null); - - const handleRegionChange = (newRegion: string) => { - if (newRegion !== region) { - setPendingRegion(newRegion); - setShowModal(true); - } - }; - - const confirmRegionChange = () => { - if (pendingRegion) { - setRegion(pendingRegion as any); - setShowModal(false); - setPendingRegion(null); - navigate("/"); - } - }; - - const cancelRegionChange = () => { - setShowModal(false); - setPendingRegion(null); - }; - return (

    {t("about.settings")}

    -
    - - -
    +
    + +
    + + + +
    +
    +
    -
    - - -
    -
    - {t("about.details_summary")} -

    {t("about.details_table")}

    -
    -
    {t("about.table_style_regular")}
    -
    {t("about.details_regular")}
    -
    {t("about.table_style_grouped")}
    -
    {t("about.details_grouped")}
    -
    {t("about.table_style_experimental_consolidated")}
    -
    {t("about.details_experimental_consolidated")}
    -
    -
    - - {showModal && ( -
    -
    e.stopPropagation()}> -

    {t("about.region_change_title", "Cambiar región")}

    -

    - {t( - "about.region_change_message", - "¿Estás seguro de que quieres cambiar la región? Serás redirigido a la lista de paradas." - )} -

    -
    - - -
    -
    -
    - )}
    ); } diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css index 4d204a7..1144584 100644 --- a/src/frontend/app/routes/stops-$id.css +++ b/src/frontend/app/routes/stops-$id.css @@ -13,7 +13,7 @@ display: flex; flex-direction: column; gap: 0.75rem; - padding-block: 0 1rem; + margin-block: 0 1rem; } .table { diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index de552bd..cdc74eb 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; +import { PullToRefresh } from "~/components/PullToRefresh"; import { StopAlert } from "~/components/StopAlert"; import { StopMapModal } from "~/components/StopMapModal"; import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; +import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import { useApp } from "../AppContext"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import "./stops-$id.css"; @@ -53,12 +53,10 @@ interface ErrorInfo { } const loadConsolidatedData = async ( - region: RegionId, stopId: string ): Promise => { - const regionConfig = getRegionConfig(region); const resp = await fetch( - `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, { headers: { Accept: "application/json", @@ -92,8 +90,6 @@ export default function Estimates() { const [selectedCirculationId, setSelectedCirculationId] = useState< string | undefined >(undefined); - const { region } = useApp(); - const regionConfig = getRegionConfig(region); // Helper function to get the display name for the stop const getStopDisplayName = useCallback(() => { @@ -131,14 +127,14 @@ export default function Estimates() { setDataLoading(true); setDataError(null); - const body = await loadConsolidatedData(region, params.id!); + const body = await loadConsolidatedData(params.id!); setData(body); setDataDate(new Date()); // Load stop data from StopDataProvider - const stop = await StopDataProvider.getStopById(region, stopIdNum); + const stop = await StopDataProvider.getStopById(stopIdNum); setStopData(stop); - setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); } catch (error) { console.error("Error loading consolidated data:", error); setDataError(parseError(error)); @@ -147,24 +143,21 @@ export default function Estimates() { } finally { setDataLoading(false); } - }, [params.id, stopIdNum, region]); + }, [params.id, stopIdNum]); const refreshData = useCallback(async () => { await Promise.all([loadData()]); }, [loadData]); - // Manual refresh function for pull-to-refresh and button const handleManualRefresh = useCallback(async () => { try { setIsManualRefreshing(true); - // Only reload real-time estimates data, not timetable await refreshData(); } finally { setIsManualRefreshing(false); } }, [refreshData]); - // Auto-refresh estimates data every 30 seconds (only if not in error state) useAutoRefresh({ onRefresh: refreshData, interval: 12000, @@ -175,18 +168,18 @@ export default function Estimates() { // Initial load loadData(); - StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); + StopDataProvider.pushRecent(parseInt(params.id ?? "")); setFavourited( - StopDataProvider.isFavourite(region, parseInt(params.id ?? "")) + StopDataProvider.isFavourite(parseInt(params.id ?? "")) ); - }, [params.id, region, loadData]); + }, [params.id, loadData]); const toggleFavourite = () => { if (favourited) { - StopDataProvider.removeFavourite(region, stopIdNum); + StopDataProvider.removeFavourite(stopIdNum); setFavourited(false); } else { - StopDataProvider.addFavourite(region, stopIdNum); + StopDataProvider.addFavourite(stopIdNum); setFavourited(true); } }; @@ -197,16 +190,16 @@ export default function Estimates() { if (input === null) return; // cancelled const trimmed = input.trim(); if (trimmed === "") { - StopDataProvider.removeCustomName(region, stopIdNum); + StopDataProvider.removeCustomName(stopIdNum); setCustomName(undefined); } else { - StopDataProvider.setCustomName(region, stopIdNum, trimmed); + StopDataProvider.setCustomName(stopIdNum, trimmed); setCustomName(trimmed); } }; return ( - <> +
    @@ -238,7 +231,7 @@ export default function Estimates() {
    {stopData.lines.map((line) => (
    - +
    ))}
    @@ -262,7 +255,6 @@ export default function Estimates() { { setSelectedCirculationId(getCirculationId(estimate)); setIsMapModalOpen(true); @@ -271,11 +263,9 @@ export default function Estimates() { ) : null}
    - {/* Map Modal - only render if we have stop data */} {stopData && ( ({ id: getCirculationId(c), line: c.line, @@ -285,8 +275,8 @@ export default function Estimates() { previousTripShapeId: c.previousTripShapeId, schedule: c.schedule ? { - shapeId: c.schedule.shapeId, - } + shapeId: c.schedule.shapeId, + } : undefined, }))} isOpen={isMapModalOpen} @@ -295,6 +285,6 @@ export default function Estimates() { /> )}
    - + ); } diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css deleted file mode 100644 index 3815982..0000000 --- a/src/frontend/app/routes/timetable-$id.css +++ /dev/null @@ -1,224 +0,0 @@ -.timetable-full-header { - margin-bottom: 2rem; -} - -.back-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: var(--link-color, #007bff); - text-decoration: none; - margin-bottom: 1rem; - font-weight: 500; - transition: color 0.2s ease; -} - -.back-link:hover { - color: var(--link-hover-color, #0056b3); - text-decoration: underline; -} - -.back-icon { - width: 1.2rem; - height: 1.2rem; -} - -.page-title .stop-name { - font-size: 1.2rem; - font-weight: 600; - color: var(--text-primary, #333); -} - -.page-title .stop-id { - font-size: 1rem; - color: var(--text-secondary, #666); - font-weight: normal; - margin-left: 0.5rem; -} - -.timetable-full-content { - margin-top: 1rem; - position: relative; - padding-bottom: 80px; /* Space for FAB */ -} - -.error-message { - text-align: center; - padding: 3rem 2rem; - background-color: var(--error-background, #f8f9fa); - border: 1px solid var(--error-border, #dee2e6); - border-radius: 8px; - margin: 2rem 0; -} - -.error-message p { - margin-bottom: 1rem; - color: var(--error-color, #dc3545); - font-weight: 500; -} - -.error-detail { - font-size: 0.9rem; - color: var(--text-secondary, #666) !important; - font-weight: normal !important; -} - -.timetable-controls { - margin-bottom: 1.5rem; - display: flex; - justify-content: center; -} - -.past-toggle { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: var(--link-color, #007bff); - text-decoration: none; - font-weight: 500; - padding: 0.5rem 1rem; - border: 1px solid var(--link-color, #007bff); - border-radius: 6px; - background: transparent; - cursor: pointer; - transition: all 0.2s ease; -} - -.past-toggle:hover { - background-color: var(--link-color, #007bff); - color: white; - text-decoration: none; -} - -.past-toggle:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.past-toggle:disabled:hover { - background: transparent; - color: var(--link-color, #007bff); -} - -.past-toggle.active { - background-color: var(--link-color, #007bff); - color: white; - border-color: var(--link-color, #007bff); -} - -.toggle-icon { - width: 1rem; - height: 1rem; -} - -/* Next entry highlight */ -.timetable-card.timetable-next { - border: 2px solid var(--accent-color, #28a745); - background: var(--surface-next, #e8f5e8) !important; -} - -/* Override timetable cards styles for full page */ -.timetable-full-content .timetable-cards { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.timetable-full-content .timetable-caption { - font-size: 1.2rem; - margin-bottom: 1.5rem; -} - -.timetable-full-content .timetable-card { - padding: 1.25rem; -} - -/* Responsive design */ -@media (max-width: 768px) { - .page-title { - font-size: 1.5rem; - } - - .page-title .stop-name { - font-size: 1.1rem; - } - - .timetable-full-content .timetable-cards { - gap: 0.75rem; - } - - .timetable-full-content .timetable-card { - 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 deleted file mode 100644 index c036cb3..0000000 --- a/src/frontend/app/routes/timetable-$id.tsx +++ /dev/null @@ -1,570 +0,0 @@ -import { - ArrowLeft, - ChevronDown, - ChevronUp, - Clock, - Eye, - EyeOff, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link, useParams } from "react-router"; -import { useApp } from "~/AppContext"; -import { ErrorDisplay } from "~/components/ErrorDisplay"; -import { type ScheduledTable } from "~/components/SchedulesTable"; -import { TimetableSkeleton } from "~/components/TimetableSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; -import LineIcon from "../components/LineIcon"; -import StopDataProvider from "../data/StopDataProvider"; -import "./timetable-$id.css"; - -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadTimetableData = async ( - region: RegionId, - stopId: string -): Promise => { - const regionConfig = getRegionConfig(region); - - // Check if timetable is available for this region - if (!regionConfig.timetableEndpoint) { - throw new Error("Timetable not available for this region"); - } - - // Add delay to see skeletons in action (remove in production) - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Use "today" to let server determine date based on Europe/Madrid timezone - 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}`); - } - - return await resp.json(); -}; - -// Utility function to compare times -const timeToMinutes = (time: string): number => { - const [hours, minutes] = time.split(":").map(Number); - return hours * 60 + minutes; -}; - -// Utility function to format GTFS time for display (handle hours >= 24) -const formatTimeForDisplay = (time: string): string => { - const [hours, minutes] = time.split(":").map(Number); - const normalizedHours = hours % 24; - return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; -}; - -// Filter past entries (keep only a few recent past ones) -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) - ); - - // Find the current position - const currentIndex = sortedData.findIndex( - (entry) => timeToMinutes(entry.calling_time) >= currentMinutes - ); - - if (currentIndex === -1) { - // All entries are in the past, show last 3 - return sortedData.slice(-3); - } - - // Show 3 past entries + all future entries - const startIndex = Math.max(0, currentIndex - 3); - return sortedData.slice(startIndex); -}; - -// 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 lastPart = parts[parts.length - 1]; - if (lastPart.length < 6) return ""; - - const last6 = lastPart.slice(-6); - const lineCode = last6.slice(0, 3); - const turnCode = last6.slice(-3); - - // Remove leading zeros from turn - const turnNumber = parseInt(turnCode, 10).toString(); - - // Parse line number with special cases - const lineNumber = parseInt(lineCode, 10); - 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}`; - } - - 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(); - const params = useParams(); - const stopIdNum = parseInt(params.id ?? ""); - const [timetableData, setTimetableData] = useState([]); - const [customName, setCustomName] = useState(undefined); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showPastEntries, setShowPastEntries] = 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 parseError = (error: any): ErrorInfo => { - if (!navigator.onLine) { - 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("HTTP")) { - const statusMatch = error.message.match(/HTTP (\d+):/); - const status = statusMatch ? parseInt(statusMatch[1]) : undefined; - return { type: "server", status }; - } - - return { type: "unknown", message: error.message }; - }; - - const loadData = async () => { - // Check if timetable is available for this region - if (!regionConfig.timetableEndpoint) { - setError({ - type: "server", - status: 501, - message: "Timetable not available for this region", - }); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - - const timetableBody = await loadTimetableData(region, params.id!); - setTimetableData(timetableBody); - - if (timetableBody.length > 0) { - // 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 nextIndex = sortedData.findIndex( - (entry) => timeToMinutes(entry.calling_time) >= currentMinutes - ); - - if (nextIndex !== -1 && nextEntryRef.current) { - nextEntryRef.current.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - }, 500); - } - } catch (err) { - console.error("Error loading timetable data:", err); - setError(parseError(err)); - setTimetableData([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); - }, [params.id, region]); - - // Scroll FABs moved to ScrollFabManager component - - if (loading) { - return ( -
    -
    -

    - {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) -

    - - - {t("timetable.backToEstimates", "Volver a estimaciones")} - -
    - -
    -
    - -
    - - -
    -
    - ); - } - - return ( -
    -
    -

    - {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) -

    - - - {t("timetable.backToEstimates", "Volver a estimaciones")} - -
    - - {error ? ( -
    - -
    - ) : timetableData.length === 0 ? ( -
    -

    - {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." - )} -

    -
    - ) : ( -
    -
    - -
    - - - - {/* Floating Action Button */} - -
    - )} -
    - ); -} - -// Custom component for the full timetable with scroll reference -const TimetableTableWithScroll: React.FC<{ - data: ScheduledTable[]; - showAll: boolean; - currentTime: string; - nextEntryRef: React.RefObject; -}> = ({ data, showAll, currentTime, nextEntryRef }) => { - const { t } = useTranslation(); - const { region } = useApp(); - const nowMinutes = timeToMinutes(currentTime); - - return ( -
    -
    - {t("timetable.fullCaption", "Horarios teóricos de la parada")} -
    - -
    - {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); - - return ( -
    -
    -
    - -
    - -
    - {entry.route && entry.route.trim() ? ( - {entry.route} - ) : ( - - {t("timetable.noDestination", "Línea")} {entry.line} - - )} -
    - -
    - - {formatTimeForDisplay(entry.calling_time)} - -
    - {parseServiceId(entry.service_id)} -
    -
    -
    -
    - {!isPast && entry.next_streets.length > 0 && ( -
    - {entry.next_streets.join(" — ")} -
    - )} -
    -
    - ); - })} -
    - - {data.length === 0 && ( -

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

    - )} -
    - ); -}; - -// Component to manage scroll-based FAB visibility globally within timetable -const ScrollFabManager: React.FC<{ - containerRef: React.RefObject; - nextEntryRef: React.RefObject; - currentTime: string; - data: ScheduledTable[]; - disabled?: boolean; -}> = ({ containerRef, nextEntryRef, currentTime, data, disabled = false }) => { - const { t } = useTranslation(); - const [showScrollTop, setShowScrollTop] = useState(false); - const [showScrollBottom, setShowScrollBottom] = useState(false); - const [showGoToNow, setShowGoToNow] = useState(false); - - // Find the actual scrollable ancestor (.main-content) if our container isn't scrollable - const getScrollContainer = () => { - let el: HTMLElement | null = containerRef.current; - while (el) { - const style = getComputedStyle(el); - const hasScroll = el.scrollHeight > el.clientHeight + 8; - const overflowY = style.overflowY; - if (hasScroll && (overflowY === "auto" || overflowY === "scroll")) { - return el; - } - el = el.parentElement; - } - return null; - }; - - useEffect(() => { - if (disabled) return; - const scrollEl = getScrollContainer(); - const useWindowScroll = !scrollEl; - - const handleScroll = () => { - const scrollTop = useWindowScroll - ? window.scrollY || document.documentElement.scrollTop || 0 - : scrollEl!.scrollTop; - const scrollHeight = useWindowScroll - ? document.documentElement.scrollHeight - : scrollEl!.scrollHeight; - const clientHeight = useWindowScroll - ? window.innerHeight - : scrollEl!.clientHeight; - - const scrollBottom = scrollHeight - scrollTop - clientHeight; - const threshold = 80; // slightly smaller threshold for responsiveness - setShowScrollTop(scrollTop > threshold); - setShowScrollBottom(scrollBottom > threshold); - - if (nextEntryRef.current) { - const rect = nextEntryRef.current.getBoundingClientRect(); - const isNextVisible = - rect.top >= 0 && rect.bottom <= window.innerHeight; - setShowGoToNow(!isNextVisible); - } - }; - - const target: any = useWindowScroll ? window : scrollEl!; - target.addEventListener("scroll", handleScroll, { passive: true }); - window.addEventListener("resize", handleScroll); - handleScroll(); - return () => { - target.removeEventListener("scroll", handleScroll); - window.removeEventListener("resize", handleScroll); - }; - }, [containerRef, nextEntryRef, disabled, data, currentTime]); - - const scrollToTop = () => { - const scrollEl = getScrollContainer(); - if (!scrollEl) { - window.scrollTo({ top: 0, behavior: "smooth" }); - } else { - scrollEl.scrollTo({ top: 0, behavior: "smooth" }); - } - }; - const scrollToBottom = () => { - const scrollEl = getScrollContainer(); - if (!scrollEl) { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior: "smooth", - }); - } else { - scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: "smooth" }); - } - }; - const scrollToNow = () => { - nextEntryRef.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }; - - if (disabled) return null; - if (!(showGoToNow || showScrollTop || showScrollBottom)) return null; - - return ( -
    - {showGoToNow && !showScrollTop && !showScrollBottom && ( - - )} - {showScrollTop && ( - - )} - {showScrollBottom && !showScrollTop && ( - - )} -
    - ); -}; diff --git a/src/frontend/app/tailwind.css b/src/frontend/app/tailwind.css new file mode 100644 index 0000000..de604f7 --- /dev/null +++ b/src/frontend/app/tailwind.css @@ -0,0 +1,6 @@ +@layer theme, base, components, utilities; + +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); -- cgit v1.3