aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/PullToRefresh.css72
-rw-r--r--src/frontend/app/components/PullToRefresh.tsx53
-rw-r--r--src/frontend/app/components/TimetableTable.tsx30
-rw-r--r--src/frontend/app/components/UpdateNotification.css114
-rw-r--r--src/frontend/app/components/UpdateNotification.tsx63
-rw-r--r--src/frontend/app/hooks/useAutoRefresh.ts63
-rw-r--r--src/frontend/app/hooks/usePullToRefresh.ts99
-rw-r--r--src/frontend/app/root.css3
-rw-r--r--src/frontend/app/root.tsx23
-rw-r--r--src/frontend/app/routes/estimates-$id.css5
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx139
-rw-r--r--src/frontend/app/routes/stoplist.css5
-rw-r--r--src/frontend/app/routes/stoplist.tsx142
-rw-r--r--src/frontend/app/routes/timetable-$id.css8
-rw-r--r--src/frontend/app/utils/serviceWorkerManager.ts80
15 files changed, 759 insertions, 140 deletions
diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css
new file mode 100644
index 0000000..15ca74b
--- /dev/null
+++ b/src/frontend/app/components/PullToRefresh.css
@@ -0,0 +1,72 @@
+.pull-to-refresh-container {
+ position: relative;
+ height: 100%;
+ overflow: hidden;
+}
+
+.pull-to-refresh-indicator {
+ position: absolute;
+ top: -80px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 16px;
+ z-index: 1000;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.pull-to-refresh-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: var(--button-background-color);
+ color: white;
+ transition: all 0.3s ease;
+ transform-origin: center;
+}
+
+.pull-to-refresh-icon.ready {
+ background-color: #28a745;
+}
+
+.pull-to-refresh-icon.spinning {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.pull-to-refresh-text {
+ font-size: 0.875rem;
+ color: var(--subtitle-color);
+ font-weight: 500;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.pull-to-refresh-content {
+ height: 100%;
+ overflow: auto;
+ transition: transform 0.2s ease;
+}
+
+/* Disable browser's default pull-to-refresh on mobile */
+.pull-to-refresh-content {
+ overscroll-behavior-y: contain;
+}
+
+@media (hover: hover) {
+ /* Hide pull-to-refresh on desktop devices */
+ .pull-to-refresh-indicator {
+ display: none;
+ }
+}
diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx
new file mode 100644
index 0000000..47a6f03
--- /dev/null
+++ b/src/frontend/app/components/PullToRefresh.tsx
@@ -0,0 +1,53 @@
+import { type ReactNode } from "react";
+import { RotateCcw } from "lucide-react";
+import "./PullToRefresh.css";
+
+interface PullToRefreshIndicatorProps {
+ pullDistance: number;
+ isRefreshing: boolean;
+ canRefresh: boolean;
+ children: ReactNode;
+}
+
+export function PullToRefreshIndicator({
+ pullDistance,
+ isRefreshing,
+ canRefresh,
+ children,
+}: PullToRefreshIndicatorProps) {
+ const opacity = Math.min(pullDistance / 60, 1);
+ const rotation = isRefreshing ? 360 : pullDistance * 4;
+ const scale = Math.min(0.5 + (pullDistance / 120), 1);
+
+ return (
+ <div className="pull-to-refresh-container">
+ <div
+ className="pull-to-refresh-indicator"
+ style={{
+ transform: `translateY(${Math.min(pullDistance, 80)}px)`,
+ opacity: opacity,
+ }}
+ >
+ <div
+ className={`pull-to-refresh-icon ${isRefreshing ? 'spinning' : ''} ${canRefresh ? 'ready' : ''}`}
+ style={{
+ transform: `rotate(${rotation}deg) scale(${scale})`,
+ }}
+ >
+ <RotateCcw size={24} />
+ </div>
+ <div className="pull-to-refresh-text">
+ {isRefreshing ? "Actualizando..." : canRefresh ? "Suelta para actualizar" : "Arrastra para actualizar"}
+ </div>
+ </div>
+ <div
+ className="pull-to-refresh-content"
+ style={{
+ transform: `translateY(${Math.min(pullDistance * 0.5, 40)}px)`,
+ }}
+ >
+ {children}
+ </div>
+ </div>
+ );
+}
diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx
index 98360bc..d03ddf4 100644
--- a/src/frontend/app/components/TimetableTable.tsx
+++ b/src/frontend/app/components/TimetableTable.tsx
@@ -31,21 +31,21 @@ interface TimetableTableProps {
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;
@@ -57,7 +57,7 @@ const parseServiceId = (serviceId: string): string => {
case 500: displayLine = "TUR"; break;
default: displayLine = `L${lineNumber}`;
}
-
+
return `${displayLine}-${turnNumber}`;
};
@@ -70,24 +70,24 @@ const timeToMinutes = (time: string): number => {
// Utility function to find nearby entries
const findNearbyEntries = (entries: TimetableEntry[], currentTime: string, before: number = 4, after: number = 4): TimetableEntry[] => {
if (!currentTime) return entries.slice(0, before + after);
-
+
const currentMinutes = timeToMinutes(currentTime);
- const sortedEntries = [...entries].sort((a, b) =>
+ const sortedEntries = [...entries].sort((a, b) =>
timeToMinutes(a.departure_time) - timeToMinutes(b.departure_time)
);
-
- let currentIndex = sortedEntries.findIndex(entry =>
+
+ let currentIndex = sortedEntries.findIndex(entry =>
timeToMinutes(entry.departure_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);
};
@@ -128,7 +128,7 @@ export const TimetableTable: React.FC<TimetableTableProps> = ({
<div className="line-info">
<LineIcon line={entry.line.name} />
</div>
-
+
<div className="destination-info">
{entry.trip.headsign && entry.trip.headsign.trim() ? (
<strong>{entry.trip.headsign}</strong>
@@ -136,7 +136,7 @@ export const TimetableTable: React.FC<TimetableTableProps> = ({
<strong>{t("timetable.noDestination", "Línea")} {entry.line.name}</strong>
)}
</div>
-
+
<div className="time-info">
<span className="departure-time">
{entry.departure_time.slice(0, 5)}
diff --git a/src/frontend/app/components/UpdateNotification.css b/src/frontend/app/components/UpdateNotification.css
new file mode 100644
index 0000000..6183194
--- /dev/null
+++ b/src/frontend/app/components/UpdateNotification.css
@@ -0,0 +1,114 @@
+.update-notification {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 9999;
+ background-color: var(--button-background-color);
+ color: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ animation: slideDown 0.3s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateY(-100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.update-content {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ gap: 12px;
+ max-width: 100%;
+}
+
+.update-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 50%;
+}
+
+.update-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.update-title {
+ font-size: 0.9rem;
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+
+.update-description {
+ font-size: 0.8rem;
+ opacity: 0.9;
+ line-height: 1.2;
+}
+
+.update-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.update-button {
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+.update-button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.update-button:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.update-dismiss {
+ background: none;
+ border: none;
+ color: white;
+ padding: 6px;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease;
+}
+
+.update-dismiss:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+@media (min-width: 768px) {
+ .update-content {
+ max-width: 768px;
+ margin: 0 auto;
+ }
+}
+
+@media (min-width: 1024px) {
+ .update-content {
+ max-width: 1024px;
+ }
+}
diff --git a/src/frontend/app/components/UpdateNotification.tsx b/src/frontend/app/components/UpdateNotification.tsx
new file mode 100644
index 0000000..e07ee74
--- /dev/null
+++ b/src/frontend/app/components/UpdateNotification.tsx
@@ -0,0 +1,63 @@
+import { useState, useEffect } from "react";
+import { Download, X } from "lucide-react";
+import { swManager } from "../utils/serviceWorkerManager";
+import "./UpdateNotification.css";
+
+export function UpdateNotification() {
+ const [showUpdate, setShowUpdate] = useState(false);
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ useEffect(() => {
+ swManager.onUpdate(() => {
+ setShowUpdate(true);
+ });
+ }, []);
+
+ const handleUpdate = async () => {
+ setIsUpdating(true);
+ swManager.activateUpdate();
+
+ // Wait for the page to reload
+ setTimeout(() => {
+ window.location.reload();
+ }, 500);
+ };
+
+ const handleDismiss = () => {
+ setShowUpdate(false);
+ };
+
+ if (!showUpdate) return null;
+
+ return (
+ <div className="update-notification">
+ <div className="update-content">
+ <div className="update-icon">
+ <Download size={20} />
+ </div>
+ <div className="update-text">
+ <div className="update-title">Nueva versión disponible</div>
+ <div className="update-description">
+ Actualiza para obtener las últimas mejoras
+ </div>
+ </div>
+ <div className="update-actions">
+ <button
+ className="update-button"
+ onClick={handleUpdate}
+ disabled={isUpdating}
+ >
+ {isUpdating ? "Actualizando..." : "Actualizar"}
+ </button>
+ <button
+ className="update-dismiss"
+ onClick={handleDismiss}
+ aria-label="Cerrar notificación"
+ >
+ <X size={16} />
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/frontend/app/hooks/useAutoRefresh.ts b/src/frontend/app/hooks/useAutoRefresh.ts
new file mode 100644
index 0000000..172fa94
--- /dev/null
+++ b/src/frontend/app/hooks/useAutoRefresh.ts
@@ -0,0 +1,63 @@
+import { useEffect, useRef, useCallback } from "react";
+
+interface UseAutoRefreshOptions {
+ onRefresh: () => Promise<void>;
+ interval?: number;
+ enabled?: boolean;
+}
+
+export function useAutoRefresh({
+ onRefresh,
+ interval = 30000, // 30 seconds default
+ enabled = true,
+}: UseAutoRefreshOptions) {
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+ const refreshCallbackRef = useRef(onRefresh);
+
+ // Update callback ref when it changes
+ useEffect(() => {
+ refreshCallbackRef.current = onRefresh;
+ }, [onRefresh]);
+
+ const startAutoRefresh = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+
+ if (enabled) {
+ intervalRef.current = setInterval(() => {
+ refreshCallbackRef.current();
+ }, interval);
+ }
+ }, [interval, enabled]);
+
+ const stopAutoRefresh = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ startAutoRefresh();
+ return stopAutoRefresh;
+ }, [startAutoRefresh, stopAutoRefresh]);
+
+ // Handle visibility change to pause/resume auto-refresh
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ stopAutoRefresh();
+ } else {
+ startAutoRefresh();
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ }, [startAutoRefresh, stopAutoRefresh]);
+
+ return { startAutoRefresh, stopAutoRefresh };
+}
diff --git a/src/frontend/app/hooks/usePullToRefresh.ts b/src/frontend/app/hooks/usePullToRefresh.ts
new file mode 100644
index 0000000..b34502b
--- /dev/null
+++ b/src/frontend/app/hooks/usePullToRefresh.ts
@@ -0,0 +1,99 @@
+import { useEffect, useRef, useState } from "react";
+
+interface PullToRefreshOptions {
+ onRefresh: () => Promise<void>;
+ threshold?: number;
+ resistance?: number;
+ enabled?: boolean;
+}
+
+export function usePullToRefresh({
+ onRefresh,
+ threshold = 80,
+ resistance = 2.5,
+ enabled = true,
+}: PullToRefreshOptions) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [pullDistance, setPullDistance] = useState(0);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const startY = useRef(0);
+ const currentY = useRef(0);
+ const isAtTop = useRef(true);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !enabled) return;
+
+ let rafId: number;
+
+ const checkScrollPosition = () => {
+ isAtTop.current = container.scrollTop <= 5;
+ };
+
+ const handleTouchStart = (e: TouchEvent) => {
+ if (!isAtTop.current || isRefreshing) return;
+
+ startY.current = e.touches[0].clientY;
+ currentY.current = startY.current;
+ setIsDragging(true);
+ };
+
+ const handleTouchMove = (e: TouchEvent) => {
+ if (!isDragging || isRefreshing || !isAtTop.current) return;
+
+ currentY.current = e.touches[0].clientY;
+ const deltaY = currentY.current - startY.current;
+
+ if (deltaY > 0) {
+ e.preventDefault();
+ const distance = Math.min(deltaY / resistance, threshold * 1.5);
+ setPullDistance(distance);
+ }
+ };
+
+ const handleTouchEnd = async () => {
+ if (!isDragging || isRefreshing) return;
+
+ setIsDragging(false);
+
+ if (pullDistance >= threshold) {
+ setIsRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setIsRefreshing(false);
+ }
+ }
+
+ setPullDistance(0);
+ };
+
+ const handleScroll = () => {
+ if (rafId) cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(checkScrollPosition);
+ };
+
+ container.addEventListener("touchstart", handleTouchStart, { passive: false });
+ container.addEventListener("touchmove", handleTouchMove, { passive: false });
+ container.addEventListener("touchend", handleTouchEnd);
+ container.addEventListener("scroll", handleScroll, { passive: true });
+
+ return () => {
+ if (rafId) cancelAnimationFrame(rafId);
+ container.removeEventListener("touchstart", handleTouchStart);
+ container.removeEventListener("touchmove", handleTouchMove);
+ container.removeEventListener("touchend", handleTouchEnd);
+ container.removeEventListener("scroll", handleScroll);
+ };
+ }, [enabled, isRefreshing, pullDistance, threshold, resistance, onRefresh, isDragging]);
+
+ return {
+ containerRef,
+ isRefreshing,
+ pullDistance,
+ isDragging,
+ canRefresh: pullDistance >= threshold,
+ };
+}
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index da3ab67..d72d776 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -40,6 +40,9 @@ body {
height: 100dvh;
width: 100%;
overflow: hidden;
+
+ /* Disable browser's native pull-to-refresh on mobile */
+ overscroll-behavior-y: contain;
}
.main-content {
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 55c6c16..040494f 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -18,23 +18,15 @@ import "maplibre-theme/modern.css";
import { Protocol } from "pmtiles";
import maplibregl, { type LngLatLike } from "maplibre-gl";
import { AppProvider } from "./AppContext";
+import { swManager } from "./utils/serviceWorkerManager";
+import { UpdateNotification } from "./components/UpdateNotification";
+import { useEffect } from "react";
const pmtiles = new Protocol();
maplibregl.addProtocol("pmtiles", pmtiles.tile);
//#endregion
import "./i18n";
-if ("serviceWorker" in navigator) {
- navigator.serviceWorker
- .register("/sw.js")
- .then((registration) => {
- console.log("Service Worker registered with scope:", registration.scope);
- })
- .catch((error) => {
- console.error("Service Worker registration failed:", error);
- });
-}
-
export const links: Route.LinksFunction = () => [];
export function HydrateFallback() {
@@ -47,6 +39,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="icon" type="image/jpg" href="/logo-512.jpg" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
@@ -113,8 +108,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean {
import NavBar from "./components/NavBar";
export default function App() {
+ useEffect(() => {
+ // Initialize service worker
+ swManager.initialize();
+ }, []);
+
return (
<AppProvider>
+ <UpdateNotification />
<main className="main-content">
<Outlet />
</main>
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
index 8906147..424c76f 100644
--- a/src/frontend/app/routes/estimates-$id.css
+++ b/src/frontend/app/routes/estimates-$id.css
@@ -29,6 +29,11 @@
}
/* Estimates page specific styles */
+.estimates-page {
+ height: 100%;
+ overflow: hidden;
+}
+
.estimates-header {
display: flex;
align-items: center;
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index b5ae91a..d9b9b47 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -1,4 +1,4 @@
-import { type JSX, useEffect, useState } from "react";
+import { type JSX, useEffect, useState, useCallback } from "react";
import { useParams, Link } from "react-router";
import StopDataProvider from "../data/StopDataProvider";
import { Star, Edit2, ExternalLink } from "lucide-react";
@@ -8,6 +8,9 @@ import { useApp } from "../AppContext";
import { GroupedTable } from "../components/GroupedTable";
import { useTranslation } from "react-i18next";
import { TimetableTable, type TimetableEntry } from "../components/TimetableTable";
+import { usePullToRefresh } from "../hooks/usePullToRefresh";
+import { PullToRefreshIndicator } from "../components/PullToRefresh";
+import { useAutoRefresh } from "../hooks/useAutoRefresh";
export interface StopDetails {
stop: {
@@ -62,23 +65,51 @@ export default function Estimates() {
const [timetableData, setTimetableData] = useState<TimetableEntry[]>([]);
const { tableStyle } = useApp();
- useEffect(() => {
- // Load real-time estimates
- loadData(params.id!).then((body: StopDetails) => {
- setData(body);
- setDataDate(new Date());
- setCustomName(StopDataProvider.getCustomName(stopIdNum));
- });
+ const loadEstimatesData = useCallback(async () => {
+ const body: StopDetails = await loadData(params.id!);
+ setData(body);
+ setDataDate(new Date());
+ setCustomName(StopDataProvider.getCustomName(stopIdNum));
+ }, [params.id, stopIdNum]);
- // Load timetable data
- loadTimetableData(params.id!).then((timetableBody: TimetableEntry[]) => {
- setTimetableData(timetableBody);
- });
+ const loadTimetableDataAsync = useCallback(async () => {
+ const timetableBody: TimetableEntry[] = await loadTimetableData(params.id!);
+ setTimetableData(timetableBody);
+ }, [params.id]);
- StopDataProvider.pushRecent(parseInt(params.id ?? ""));
+ const refreshData = useCallback(async () => {
+ await Promise.all([
+ loadEstimatesData(),
+ loadTimetableDataAsync()
+ ]);
+ }, [loadEstimatesData, loadTimetableDataAsync]);
+
+ const {
+ containerRef,
+ isRefreshing,
+ pullDistance,
+ canRefresh,
+ } = usePullToRefresh({
+ onRefresh: refreshData,
+ threshold: 80,
+ enabled: true,
+ });
+
+ // Auto-refresh estimates data every 30 seconds
+ useAutoRefresh({
+ onRefresh: loadEstimatesData,
+ interval: 30000,
+ enabled: true,
+ });
+
+ useEffect(() => {
+ // Initial load
+ loadEstimatesData();
+ loadTimetableDataAsync();
+ StopDataProvider.pushRecent(parseInt(params.id ?? ""));
setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? "")));
- }, [params.id]);
+ }, [params.id, loadEstimatesData, loadTimetableDataAsync]);
const toggleFavourite = () => {
if (favourited) {
@@ -108,45 +139,51 @@ export default function Estimates() {
return <h1 className="page-title">{t("common.loading")}</h1>;
return (
- <div className="page-container">
- <div className="estimates-header">
- <h1 className="page-title">
- <Star
- className={`star-icon ${favourited ? "active" : ""}`}
- onClick={toggleFavourite}
- />
- <Edit2 className="edit-icon" onClick={handleRename} />
- {customName ?? data.stop.name}{" "}
- <span className="estimates-stop-id">({data.stop.id})</span>
- </h1>
- </div>
+ <div ref={containerRef} className="page-container estimates-page">
+ <PullToRefreshIndicator
+ pullDistance={pullDistance}
+ isRefreshing={isRefreshing}
+ canRefresh={canRefresh}
+ >
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star
+ className={`star-icon ${favourited ? "active" : ""}`}
+ onClick={toggleFavourite}
+ />
+ <Edit2 className="edit-icon" onClick={handleRename} />
+ {customName ?? data.stop.name}{" "}
+ <span className="estimates-stop-id">({data.stop.id})</span>
+ </h1>
+ </div>
- <div className="table-responsive">
- {tableStyle === "grouped" ? (
- <GroupedTable data={data} dataDate={dataDate} />
- ) : (
- <RegularTable data={data} dataDate={dataDate} />
- )}
- </div>
+ <div className="table-responsive">
+ {tableStyle === "grouped" ? (
+ <GroupedTable data={data} dataDate={dataDate} />
+ ) : (
+ <RegularTable data={data} dataDate={dataDate} />
+ )}
+ </div>
+
+ <div className="timetable-section">
+ <TimetableTable
+ data={timetableData}
+ currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS
+ />
- <div className="timetable-section">
- <TimetableTable
- data={timetableData}
- currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS
- />
-
- {timetableData.length > 0 && (
- <div className="timetable-actions">
- <Link
- to={`/timetable/${params.id}`}
- className="view-all-link"
- >
- <ExternalLink className="external-icon" />
- {t("timetable.viewAll", "Ver todos los horarios")}
- </Link>
- </div>
- )}
- </div>
+ {timetableData.length > 0 && (
+ <div className="timetable-actions">
+ <Link
+ to={`/timetable/${params.id}`}
+ className="view-all-link"
+ >
+ <ExternalLink className="external-icon" />
+ {t("timetable.viewAll", "Ver todos los horarios")}
+ </Link>
+ </div>
+ )}
+ </div>
+ </PullToRefreshIndicator>
</div>
);
}
diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css
index 253c0ab..99c2da7 100644
--- a/src/frontend/app/routes/stoplist.css
+++ b/src/frontend/app/routes/stoplist.css
@@ -1,4 +1,9 @@
/* Common page styles */
+.stoplist-page {
+ height: 100%;
+ overflow: hidden;
+}
+
.page-title {
font-size: 1.8rem;
margin-bottom: 1rem;
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
index 58cdab4..70b1525 100644
--- a/src/frontend/app/routes/stoplist.tsx
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -1,9 +1,11 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import StopItem from "../components/StopItem";
import Fuse from "fuse.js";
import "./stoplist.css";
import { useTranslation } from "react-i18next";
+import { usePullToRefresh } from "../hooks/usePullToRefresh";
+import { PullToRefreshIndicator } from "../components/PullToRefresh";
export default function StopList() {
const { t } = useTranslation();
@@ -20,10 +22,26 @@ export default function StopList() {
[data],
);
- useEffect(() => {
- StopDataProvider.getStops().then((stops: Stop[]) => setData(stops));
+ const loadStops = useCallback(async () => {
+ const stops = await StopDataProvider.getStops();
+ setData(stops);
}, []);
+ const {
+ containerRef,
+ isRefreshing,
+ pullDistance,
+ canRefresh,
+ } = usePullToRefresh({
+ onRefresh: loadStops,
+ threshold: 80,
+ enabled: true,
+ });
+
+ useEffect(() => {
+ loadStops();
+ }, [loadStops]);
+
const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const stopName = event.target.value || "";
@@ -68,77 +86,83 @@ export default function StopList() {
return <h1 className="page-title">{t("common.loading")}</h1>;
return (
- <div className="page-container">
- <h1 className="page-title">UrbanoVigo Web</h1>
+ <div ref={containerRef} className="page-container stoplist-page">
+ <PullToRefreshIndicator
+ pullDistance={pullDistance}
+ isRefreshing={isRefreshing}
+ canRefresh={canRefresh}
+ >
+ <h1 className="page-title">UrbanoVigo Web</h1>
- <form className="search-form">
- <div className="form-group">
- <label className="form-label" htmlFor="stopName">
- {t("stoplist.search_label", "Buscar paradas")}
- </label>
- <input
- className="form-input"
- type="text"
- placeholder={randomPlaceholder}
- id="stopName"
- onChange={handleStopSearch}
- />
- </div>
- </form>
+ <form className="search-form">
+ <div className="form-group">
+ <label className="form-label" htmlFor="stopName">
+ {t("stoplist.search_label", "Buscar paradas")}
+ </label>
+ <input
+ className="form-input"
+ type="text"
+ placeholder={randomPlaceholder}
+ id="stopName"
+ onChange={handleStopSearch}
+ />
+ </div>
+ </form>
+
+ {searchResults && searchResults.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">
+ {t("stoplist.search_results", "Resultados de la búsqueda")}
+ </h2>
+ <ul className="list">
+ {searchResults.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
- {searchResults && searchResults.length > 0 && (
<div className="list-container">
- <h2 className="page-subtitle">
- {t("stoplist.search_results", "Resultados de la búsqueda")}
- </h2>
+ <h2 className="page-subtitle">{t("stoplist.favourites")}</h2>
+
+ {favouritedStops?.length === 0 && (
+ <p className="message">
+ {t(
+ "stoplist.no_favourites",
+ "Accede a una parada y márcala como favorita para verla aquí.",
+ )}
+ </p>
+ )}
+
<ul className="list">
- {searchResults.map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
+ {favouritedStops
+ ?.sort((a, b) => a.stopId - b.stopId)
+ .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
</ul>
</div>
- )}
- <div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.favourites")}</h2>
+ {recentStops && recentStops.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">{t("stoplist.recents")}</h2>
- {favouritedStops?.length === 0 && (
- <p className="message">
- {t(
- "stoplist.no_favourites",
- "Accede a una parada y márcala como favorita para verla aquí.",
- )}
- </p>
+ <ul className="list">
+ {recentStops.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
)}
- <ul className="list">
- {favouritedStops
- ?.sort((a, b) => a.stopId - b.stopId)
- .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
- </ul>
- </div>
-
- {recentStops && recentStops.length > 0 && (
<div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.recents")}</h2>
+ <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2>
<ul className="list">
- {recentStops.map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
+ {data
+ ?.sort((a, b) => a.stopId - b.stopId)
+ .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
</ul>
</div>
- )}
-
- <div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2>
-
- <ul className="list">
- {data
- ?.sort((a, b) => a.stopId - b.stopId)
- .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
- </ul>
- </div>
+ </PullToRefreshIndicator>
</div>
);
}
diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css
index 5ae472c..5296615 100644
--- a/src/frontend/app/routes/timetable-$id.css
+++ b/src/frontend/app/routes/timetable-$id.css
@@ -62,7 +62,7 @@
}
.timetable-controls {
- margin-bottom: 1.5rem;
+ margin-bottom: 1.5rem;
display: flex;
justify-content: center;
}
@@ -124,15 +124,15 @@
.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;
}
diff --git a/src/frontend/app/utils/serviceWorkerManager.ts b/src/frontend/app/utils/serviceWorkerManager.ts
new file mode 100644
index 0000000..cbff748
--- /dev/null
+++ b/src/frontend/app/utils/serviceWorkerManager.ts
@@ -0,0 +1,80 @@
+export class ServiceWorkerManager {
+ private registration: ServiceWorkerRegistration | null = null;
+ private updateAvailable = false;
+ private onUpdateCallback?: () => void;
+
+ async initialize() {
+ if (!("serviceWorker" in navigator)) {
+ console.log("Service Workers not supported");
+ return;
+ }
+
+ try {
+ this.registration = await navigator.serviceWorker.register("/sw.js");
+ console.log("Service Worker registered with scope:", this.registration.scope);
+
+ // Listen for updates
+ this.registration.addEventListener("updatefound", () => {
+ const newWorker = this.registration!.installing;
+ if (newWorker) {
+ newWorker.addEventListener("statechange", () => {
+ if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
+ // New service worker is installed and ready
+ this.updateAvailable = true;
+ this.onUpdateCallback?.();
+ }
+ });
+ }
+ });
+
+ // Listen for messages from the service worker
+ navigator.serviceWorker.addEventListener("message", (event) => {
+ if (event.data.type === "SW_UPDATED") {
+ this.updateAvailable = true;
+ this.onUpdateCallback?.();
+ }
+ });
+
+ // Check for updates periodically
+ setInterval(() => {
+ this.checkForUpdates();
+ }, 60 * 1000); // Check every minute
+
+ } catch (error) {
+ console.error("Service Worker registration failed:", error);
+ }
+ }
+
+ async checkForUpdates() {
+ if (this.registration) {
+ try {
+ await this.registration.update();
+ } catch (error) {
+ console.error("Failed to check for updates:", error);
+ }
+ }
+ }
+
+ activateUpdate() {
+ if (this.registration && this.registration.waiting) {
+ this.registration.waiting.postMessage({ type: "SKIP_WAITING" });
+ this.updateAvailable = false;
+ }
+ }
+
+ onUpdate(callback: () => void) {
+ this.onUpdateCallback = callback;
+ }
+
+ isUpdateAvailable() {
+ return this.updateAvailable;
+ }
+
+ async clearCache() {
+ if (this.registration && this.registration.active) {
+ this.registration.active.postMessage({ type: "CLEAR_CACHE" });
+ }
+ }
+}
+
+export const swManager = new ServiceWorkerManager();