diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-08-06 00:12:19 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-08-06 00:12:19 +0200 |
| commit | 5cc27f852b02446659e0ab85305916c9f5e5a5f0 (patch) | |
| tree | 622636a2a7eade5442a3efb1726d822657d30295 /src/frontend/app/components | |
| parent | b04fd7d33d07f9eddea2eb53e1389d5ca5453413 (diff) | |
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.
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.css | 72 | ||||
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.tsx | 53 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableTable.tsx | 30 | ||||
| -rw-r--r-- | src/frontend/app/components/UpdateNotification.css | 114 | ||||
| -rw-r--r-- | src/frontend/app/components/UpdateNotification.tsx | 63 |
5 files changed, 317 insertions, 15 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> + ); +} |
