From 5cc27f852b02446659e0ab85305916c9f5e5a5f0 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 6 Aug 2025 00:12:19 +0200 Subject: feat: Implement pull-to-refresh functionality across various components - Added `PullToRefresh` component to enable pull-to-refresh behavior in `StopList` and `Estimates` pages. - Integrated `usePullToRefresh` hook to manage pull-to-refresh state and actions. - Created `UpdateNotification` component to inform users of available updates from the service worker. - Enhanced service worker management with `ServiceWorkerManager` class for better update handling and caching strategies. - Updated CSS styles for new components and improved layout for better user experience. - Refactored API caching logic in service worker to handle multiple endpoints and dynamic cache expiration. - Added auto-refresh functionality for estimates data to keep information up-to-date. --- src/frontend/app/components/PullToRefresh.css | 72 +++++++++++++ src/frontend/app/components/PullToRefresh.tsx | 53 ++++++++++ src/frontend/app/components/TimetableTable.tsx | 30 +++--- src/frontend/app/components/UpdateNotification.css | 114 +++++++++++++++++++++ src/frontend/app/components/UpdateNotification.tsx | 63 ++++++++++++ 5 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 src/frontend/app/components/PullToRefresh.css create mode 100644 src/frontend/app/components/PullToRefresh.tsx create mode 100644 src/frontend/app/components/UpdateNotification.css create mode 100644 src/frontend/app/components/UpdateNotification.tsx (limited to 'src/frontend/app/components') 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 ( +
+
+
+ +
+
+ {isRefreshing ? "Actualizando..." : canRefresh ? "Suelta para actualizar" : "Arrastra para actualizar"} +
+
+
+ {children} +
+
+ ); +} 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 = ({
- +
{entry.trip.headsign && entry.trip.headsign.trim() ? ( {entry.trip.headsign} @@ -136,7 +136,7 @@ export const TimetableTable: React.FC = ({ {t("timetable.noDestination", "Línea")} {entry.line.name} )}
- +
{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 ( +
+
+
+ +
+
+
Nueva versión disponible
+
+ Actualiza para obtener las últimas mejoras +
+
+
+ + +
+
+
+ ); +} -- cgit v1.3