diff options
Diffstat (limited to 'src/frontend/app')
| -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/UpdateNotification.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePullToRefresh.ts | 99 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 16 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 16 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 16 | ||||
| -rw-r--r-- | src/frontend/app/root.css | 2 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 59 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.css | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.css | 102 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 114 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.css | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 49 | ||||
| -rw-r--r-- | src/frontend/app/utils/serviceWorkerManager.ts | 125 |
16 files changed, 400 insertions, 336 deletions
diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css index 15ca74b..e69de29 100644 --- a/src/frontend/app/components/PullToRefresh.css +++ b/src/frontend/app/components/PullToRefresh.css @@ -1,72 +0,0 @@ -.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 index 47a6f03..e69de29 100644 --- a/src/frontend/app/components/PullToRefresh.tsx +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -1,53 +0,0 @@ -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/UpdateNotification.tsx b/src/frontend/app/components/UpdateNotification.tsx index e07ee74..c8e21ea 100644 --- a/src/frontend/app/components/UpdateNotification.tsx +++ b/src/frontend/app/components/UpdateNotification.tsx @@ -16,7 +16,7 @@ export function UpdateNotification() { const handleUpdate = async () => { setIsUpdating(true); swManager.activateUpdate(); - + // Wait for the page to reload setTimeout(() => { window.location.reload(); diff --git a/src/frontend/app/hooks/usePullToRefresh.ts b/src/frontend/app/hooks/usePullToRefresh.ts index b34502b..e69de29 100644 --- a/src/frontend/app/hooks/usePullToRefresh.ts +++ b/src/frontend/app/hooks/usePullToRefresh.ts @@ -1,99 +0,0 @@ -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/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index e51310e..ee8ec7a 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -17,6 +17,22 @@ "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.", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 30eca41..a152564 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -17,6 +17,22 @@ "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.", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 756a106..b683fc0 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -17,6 +17,22 @@ "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.", diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index d72d776..b77f44d 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -40,7 +40,7 @@ body { height: 100dvh; width: 100%; overflow: hidden; - + /* Disable browser's native pull-to-refresh on mobile */ overscroll-behavior-y: contain; } diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index 424c76f..8906147 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -29,11 +29,6 @@ } /* 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 d9b9b47..1582275 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -8,8 +8,6 @@ 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 { @@ -84,17 +82,6 @@ export default function Estimates() { ]); }, [loadEstimatesData, loadTimetableDataAsync]); - const { - containerRef, - isRefreshing, - pullDistance, - canRefresh, - } = usePullToRefresh({ - onRefresh: refreshData, - threshold: 80, - enabled: true, - }); - // Auto-refresh estimates data every 30 seconds useAutoRefresh({ onRefresh: loadEstimatesData, @@ -139,23 +126,18 @@ export default function Estimates() { return <h1 className="page-title">{t("common.loading")}</h1>; return ( - <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="page-container estimates-page"> + <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" ? ( @@ -175,15 +157,14 @@ export default function Estimates() { <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> + className="view-all-link" + > + <ExternalLink className="external-icon" /> + {t("timetable.viewAll", "Ver todos los horarios")} + </Link> + </div> + )} + </div> </div> ); } diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css index 0b3ebe5..7d32de7 100644 --- a/src/frontend/app/routes/map.css +++ b/src/frontend/app/routes/map.css @@ -16,7 +16,6 @@ padding: 0; margin: 0; max-width: none; - overflow: hidden; } .fullscreen-map { diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css index 8c612d3..47de391 100644 --- a/src/frontend/app/routes/settings.css +++ b/src/frontend/app/routes/settings.css @@ -92,3 +92,105 @@ .settings-section p { margin-top: 0.5em; } + +/* Update controls styles */ +.update-controls { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.update-button, +.clear-cache-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.update-button { + background-color: var(--button-background-color); + color: white; +} + +.update-button:hover:not(:disabled) { + background-color: var(--button-hover-background-color); +} + +.update-button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.clear-cache-button { + background-color: #6c757d; + color: white; +} + +.clear-cache-button:hover { + background-color: #5a6268; +} + +.reset-pwa-button { + background-color: #dc3545; + color: white; + font-weight: bold; +} + +.reset-pwa-button:hover { + background-color: #c82333; +} + +.update-message { + padding: 0.75rem; + border-radius: 6px; + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.update-message.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.update-message.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.update-help-text { + font-size: 0.85rem; + color: var(--subtitle-color); + line-height: 1.4; + margin: 0; +} + +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (max-width: 768px) { + .update-controls { + flex-direction: column; + } + + .update-button, + .clear-cache-button { + justify-content: center; + } +} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index c08b2c9..b75434d 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,6 +1,9 @@ import { useApp } from "../AppContext"; import "./settings.css"; import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { swManager } from "../utils/serviceWorkerManager"; +import { RotateCcw, Download } from "lucide-react"; export default function Settings() { const { t, i18n } = useTranslation(); @@ -13,6 +16,66 @@ export default function Settings() { setMapPositionMode, } = useApp(); + const [isCheckingUpdates, setIsCheckingUpdates] = useState(false); + const [updateMessage, setUpdateMessage] = useState<string | null>(null); + + const handleCheckForUpdates = async () => { + setIsCheckingUpdates(true); + setUpdateMessage(null); + + try { + // Check if service worker is supported + if (!("serviceWorker" in navigator)) { + setUpdateMessage(t("about.sw_not_supported", "Service Workers no son compatibles en este navegador")); + return; + } + + // Force check for updates + await swManager.checkForUpdates(); + + // Wait a moment for the update check to complete + setTimeout(() => { + if (swManager.isUpdateAvailable()) { + setUpdateMessage(t("about.update_available", "¡Nueva versión disponible! Aparecerá una notificación para actualizar.")); + } else { + setUpdateMessage(t("about.up_to_date", "Ya tienes la versión más reciente.")); + } + }, 2000); + + } catch (error) { + console.error("Error checking for updates:", error); + setUpdateMessage(t("about.update_error", "Error al comprobar actualizaciones. Intenta recargar la página.")); + } finally { + setIsCheckingUpdates(false); + } + }; + + const handleClearCache = async () => { + if (confirm(t("about.clear_cache_confirm", "¿Estás seguro de que quieres limpiar la caché? Esto eliminará todos los datos guardados localmente."))) { + try { + await swManager.clearCache(); + setUpdateMessage(t("about.cache_cleared", "Caché limpiada. La página se recargará para aplicar los cambios.")); + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (error) { + console.error("Error clearing cache:", error); + setUpdateMessage(t("about.cache_error", "Error al limpiar la caché.")); + } + } + }; + + const handleResetPWA = async () => { + if (confirm(t("about.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é."))) { + try { + await swManager.resetPWA(); + } catch (error) { + console.error("Error resetting PWA:", error); + setUpdateMessage(t("about.reset_pwa_error", "Error al reiniciar la PWA.")); + } + } + }; + return ( <div className="page-container"> <h1 className="page-title">{t("about.title")}</h1> @@ -67,7 +130,7 @@ export default function Settings() { </div> <div className="settings-content-inline"> <label htmlFor="language" className="form-label-inline"> - Idioma: + {t("about.language", "Idioma")}: </label> <select id="language" @@ -90,6 +153,55 @@ export default function Settings() { <dd>{t("about.details_grouped")}</dd> </dl> </details> + + <div className="settings-section"> + <h3>{t("about.app_updates", "Actualizaciones de la aplicación")}</h3> + <div className="update-controls"> + <button + className="update-button" + onClick={handleCheckForUpdates} + disabled={isCheckingUpdates} + > + {isCheckingUpdates ? ( + <> + <RotateCcw className="spinning" size={18} /> + {t("about.checking_updates", "Comprobando...")} + </> + ) : ( + <> + <Download size={18} /> + {t("about.check_updates", "Comprobar actualizaciones")} + </> + )} + </button> + + <button + className="clear-cache-button" + onClick={handleClearCache} + > + <RotateCcw size={18} /> + {t("about.clear_cache", "Limpiar caché")} + </button> + + <button + className="reset-pwa-button" + onClick={handleResetPWA} + > + <RotateCcw size={18} /> + {t("about.reset_pwa", "Reiniciar PWA (Nuclear)")} + </button> + </div> + + {updateMessage && ( + <div className={`update-message ${updateMessage.includes("Error") || updateMessage.includes("error") ? 'error' : 'success'}`}> + {updateMessage} + </div> + )} + + <p className="update-help-text"> + {t("about.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.")} + </p> + </div> </section> <h2>{t("about.credits")}</h2> <p> diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css index 99c2da7..253c0ab 100644 --- a/src/frontend/app/routes/stoplist.css +++ b/src/frontend/app/routes/stoplist.css @@ -1,9 +1,4 @@ /* 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 70b1525..1e55dc9 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -4,8 +4,6 @@ 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(); @@ -27,17 +25,6 @@ export default function StopList() { setData(stops); }, []); - const { - containerRef, - isRefreshing, - pullDistance, - canRefresh, - } = usePullToRefresh({ - onRefresh: loadStops, - threshold: 80, - enabled: true, - }); - useEffect(() => { loadStops(); }, [loadStops]); @@ -86,21 +73,16 @@ export default function StopList() { return <h1 className="page-title">{t("common.loading")}</h1>; return ( - <div ref={containerRef} className="page-container stoplist-page"> - <PullToRefreshIndicator - pullDistance={pullDistance} - isRefreshing={isRefreshing} - canRefresh={canRefresh} - > - <h1 className="page-title">UrbanoVigo Web</h1> + <div className="page-container stoplist-page"> + <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" + <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" @@ -156,13 +138,12 @@ export default function StopList() { <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> + <ul className="list"> + {data + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} + </ul> + </div> </div> ); } diff --git a/src/frontend/app/utils/serviceWorkerManager.ts b/src/frontend/app/utils/serviceWorkerManager.ts index cbff748..a1ddbab 100644 --- a/src/frontend/app/utils/serviceWorkerManager.ts +++ b/src/frontend/app/utils/serviceWorkerManager.ts @@ -10,41 +10,81 @@ export class ServiceWorkerManager { } 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?.(); - } - }); + // First, unregister any old service workers to start fresh + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + if (registration.scope.includes(window.location.origin)) { + console.log("Unregistering old service worker:", registration.scope); + await registration.unregister(); } - }); + } - // Listen for messages from the service worker - navigator.serviceWorker.addEventListener("message", (event) => { - if (event.data.type === "SW_UPDATED") { - this.updateAvailable = true; - this.onUpdateCallback?.(); - } + // Register the new worker with a fresh name + this.registration = await navigator.serviceWorker.register("/pwa-worker.js", { + updateViaCache: 'none' // Disable caching for the SW file itself }); + console.log("PWA Worker registered with scope:", this.registration.scope); + + // Implement proper updatefound detection (web.dev pattern) + await this.detectSWUpdate(); // Check for updates periodically setInterval(() => { this.checkForUpdates(); - }, 60 * 1000); // Check every minute + }, 30 * 1000); + + // Check when page becomes visible + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + this.checkForUpdates(); + } + }); } catch (error) { console.error("Service Worker registration failed:", error); } } + private async detectSWUpdate() { + if (!this.registration) return; + + // Listen for new service worker discovery + this.registration.addEventListener("updatefound", () => { + const newSW = this.registration!.installing; + if (!newSW) return; + + console.log("New service worker found, monitoring installation..."); + + newSW.addEventListener("statechange", () => { + console.log("New SW state:", newSW.state); + + if (newSW.state === "installed") { + if (navigator.serviceWorker.controller) { + // New service worker is installed, but old one is still controlling + // This means an update is available + console.log("New service worker installed - update available!"); + this.updateAvailable = true; + this.onUpdateCallback?.(); + } else { + // First install, no controller yet + console.log("Service worker installed for the first time"); + } + } + + if (newSW.state === "activated") { + console.log("New service worker activated"); + // Optionally notify about successful update + } + }); + }); + + // Also listen for controller changes + navigator.serviceWorker.addEventListener("controllerchange", () => { + console.log("Service worker controller changed - reloading page"); + window.location.reload(); + }); + } + async checkForUpdates() { if (this.registration) { try { @@ -70,9 +110,44 @@ export class ServiceWorkerManager { return this.updateAvailable; } - async clearCache() { - if (this.registration && this.registration.active) { - this.registration.active.postMessage({ type: "CLEAR_CACHE" }); + async clearCache(): Promise<void> { + try { + // Delete all caches + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); + console.log("All caches cleared"); + } catch (error) { + console.error("Failed to clear cache:", error); + throw error; + } + } + + // Nuclear option: completely reset the PWA + async resetPWA(): Promise<void> { + try { + console.log("Resetting PWA completely..."); + + // 1. Unregister ALL service workers + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(reg => reg.unregister())); + + // 2. Clear all caches + await this.clearCache(); + + // 3. Clear local storage (optional) + localStorage.clear(); + sessionStorage.clear(); + + console.log("PWA reset complete - reloading..."); + + // 4. Force reload after a short delay + setTimeout(() => { + window.location.reload(); + }, 500); + + } catch (error) { + console.error("Failed to reset PWA:", error); + throw error; } } } |
