aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-08-06 00:12:19 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2025-08-06 00:12:19 +0200
commit5cc27f852b02446659e0ab85305916c9f5e5a5f0 (patch)
tree622636a2a7eade5442a3efb1726d822657d30295 /src/frontend/app/components
parentb04fd7d33d07f9eddea2eb53e1389d5ca5453413 (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.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
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>
+ );
+}