aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-08-06 21:52:21 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2025-08-06 21:52:21 +0200
commitebfb7c1c8bc0a9ec50bde72eb9a0859c6e5dcee5 (patch)
tree35353c15726d7d036907df731b00d390c1d1f538 /src/frontend
parent5cc27f852b02446659e0ab85305916c9f5e5a5f0 (diff)
Fix this fucking pile of steaming garbage
Diffstat (limited to 'src/frontend')
-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/UpdateNotification.tsx2
-rw-r--r--src/frontend/app/hooks/usePullToRefresh.ts99
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json16
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json16
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json16
-rw-r--r--src/frontend/app/root.css2
-rw-r--r--src/frontend/app/routes/estimates-$id.css5
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx59
-rw-r--r--src/frontend/app/routes/map.css1
-rw-r--r--src/frontend/app/routes/settings.css102
-rw-r--r--src/frontend/app/routes/settings.tsx114
-rw-r--r--src/frontend/app/routes/stoplist.css5
-rw-r--r--src/frontend/app/routes/stoplist.tsx49
-rw-r--r--src/frontend/app/utils/serviceWorkerManager.ts125
-rw-r--r--src/frontend/public/manifest.webmanifest2
-rw-r--r--src/frontend/public/pwa-worker.js145
-rw-r--r--src/frontend/public/sw.js153
19 files changed, 546 insertions, 490 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;
}
}
}
diff --git a/src/frontend/public/manifest.webmanifest b/src/frontend/public/manifest.webmanifest
index 619dfc6..fe0cf39 100644
--- a/src/frontend/public/manifest.webmanifest
+++ b/src/frontend/public/manifest.webmanifest
@@ -4,7 +4,7 @@
"name": "UrbanoVigo Web",
"description": "Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España.",
"short_name": "UrbanoVigo",
- "start_url": "/",
+ "start_url": "/stops",
"display": "standalone",
"orientation": "portrait-primary",
"lang": "es",
diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js
new file mode 100644
index 0000000..f47ede0
--- /dev/null
+++ b/src/frontend/public/pwa-worker.js
@@ -0,0 +1,145 @@
+const CACHE_VERSION = "2025-0806a";
+const API_CACHE_NAME = `api-cache-${CACHE_VERSION}`;
+const STATIC_CACHE_NAME = `static-cache-${CACHE_VERSION}`;
+
+const API_URL_PATTERN = /\/api\/(GetStopList|GetStopEstimates|GetStopTimetable)/;
+const API_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
+const ESTIMATES_MIN_AGE = 15 * 1000;
+const ESTIMATES_MAX_AGE = 30 * 1000;
+
+self.addEventListener("install", (event) => {
+ console.log("SW: Installing new version");
+ event.waitUntil(
+ caches.open(STATIC_CACHE_NAME).then((cache) =>
+ cache.addAll([
+ "/favicon.ico",
+ "/logo-256.png",
+ "/logo-512.jpg",
+ "/stops.json"
+ ])
+ ).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ console.log("SW: Activating new version");
+ event.waitUntil(
+ (async () => {
+ const cacheNames = await caches.keys();
+ await Promise.all(
+ cacheNames.map((name) => {
+ if (name !== API_CACHE_NAME && name !== STATIC_CACHE_NAME) {
+ console.log("SW: Deleting old cache:", name);
+ return caches.delete(name);
+ }
+ })
+ );
+ await self.clients.claim();
+ const clients = await self.clients.matchAll();
+ clients.forEach((client) =>
+ client.postMessage({ type: "SW_UPDATED" })
+ );
+ })()
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+ const url = new URL(request.url);
+
+ // Ignore requests with unsupported schemes
+ if (!url.protocol.startsWith('http')) {
+ return;
+ }
+
+ // API
+ if (request.method === "GET" && API_URL_PATTERN.test(url.pathname)) {
+ event.respondWith(handleApiRequest(request));
+ return;
+ }
+
+ // Navegación -> network first
+ if (request.mode === "navigate") {
+ event.respondWith(handleNavigationRequest(request));
+ return;
+ }
+
+ // Estáticos -> cache first
+ if (request.method === "GET") {
+ event.respondWith(handleStaticRequest(request));
+ }
+});
+
+async function handleApiRequest(request) {
+ const url = new URL(request.url);
+ const isEstimates = url.pathname.includes("GetStopEstimates");
+ const maxAge = isEstimates
+ ? ESTIMATES_MIN_AGE + Math.random() * (ESTIMATES_MAX_AGE - ESTIMATES_MIN_AGE)
+ : API_MAX_AGE;
+
+ const cache = await caches.open(API_CACHE_NAME);
+ const cachedResponse = await cache.match(request);
+
+ if (cachedResponse) {
+ const dateHeader = cachedResponse.headers.get("date");
+ const age = dateHeader ? Date.now() - new Date(dateHeader).getTime() : 0;
+ if (age && age < maxAge) {
+ console.debug("SW: Cache HIT", request.url);
+ return cachedResponse;
+ }
+ cache.delete(request);
+ }
+
+ try {
+ const netResponse = await fetch(request);
+ if (netResponse.ok) cache.put(request, netResponse.clone());
+ return netResponse;
+ } catch (error) {
+ if (cachedResponse) return cachedResponse;
+ throw error;
+ }
+}
+
+async function handleStaticRequest(request) {
+ const cache = await caches.open(STATIC_CACHE_NAME);
+ const cachedResponse = await cache.match(request);
+ if (cachedResponse) return cachedResponse;
+
+ try {
+ const netResponse = await fetch(request);
+ if (netResponse.ok) cache.put(request, netResponse.clone());
+ return netResponse;
+ } catch (err) {
+ return new Response("Offline asset not available", {
+ status: 503,
+ headers: { "Content-Type": "text/plain" }
+ });
+ }
+}
+
+async function handleNavigationRequest(request) {
+ try {
+ const netResponse = await fetch(request);
+ return netResponse;
+ } catch (err) {
+ // Si no hay red, intenta fallback con caché estática
+ const cache = await caches.open(STATIC_CACHE_NAME);
+ const offline = await cache.match("/stops.json");
+ return (
+ offline ||
+ new Response("App offline", {
+ status: 503,
+ headers: { "Content-Type": "text/plain" }
+ })
+ );
+ }
+}
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
+ if (event.data?.type === "CLEAR_CACHE") {
+ event.waitUntil(
+ caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n))))
+ );
+ }
+});
diff --git a/src/frontend/public/sw.js b/src/frontend/public/sw.js
deleted file mode 100644
index ca826f6..0000000
--- a/src/frontend/public/sw.js
+++ /dev/null
@@ -1,153 +0,0 @@
-const CACHE_VERSION = "v2";
-const API_CACHE_NAME = `api-cache-${CACHE_VERSION}`;
-const STATIC_CACHE_NAME = `static-cache-${CACHE_VERSION}`;
-const API_URL_PATTERN = /\/api\/(GetStopList|GetStopEstimates|GetStopTimetable)/;
-const API_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours
-const ESTIMATES_MIN_AGE = 15 * 1000; // 15 seconds minimum
-const ESTIMATES_MAX_AGE = 30 * 1000; // 30 seconds maximum
-
-self.addEventListener("install", (event) => {
- console.log("SW: Installing new version");
- event.waitUntil(
- caches.open(STATIC_CACHE_NAME).then((cache) => {
- return cache.addAll([
- "/",
- "/manifest.webmanifest",
- "/stops.json",
- "/favicon.ico",
- "/logo-256.png",
- "/logo-512.jpg"
- ]);
- }).then(() => {
- return self.skipWaiting();
- })
- );
-});
-
-self.addEventListener("activate", (event) => {
- console.log("SW: Activating new version");
- event.waitUntil(
- Promise.all([
- // Clean up old caches
- caches.keys().then((cacheNames) => {
- return Promise.all(
- cacheNames.map((cacheName) => {
- if (cacheName !== API_CACHE_NAME && cacheName !== STATIC_CACHE_NAME) {
- console.log("SW: Deleting old cache:", cacheName);
- return caches.delete(cacheName);
- }
- })
- );
- }),
- // Take control of all clients
- self.clients.claim(),
- // Notify clients about the update
- self.clients.matchAll().then((clients) => {
- clients.forEach((client) => {
- client.postMessage({ type: "SW_UPDATED" });
- });
- })
- ])
- );
-});
-
-self.addEventListener("fetch", async (event) => {
- const url = new URL(event.request.url);
-
- // Handle API requests with caching
- if (event.request.method === "GET" && API_URL_PATTERN.test(url.pathname)) {
- event.respondWith(handleApiRequest(event.request));
- return;
- }
-
- // Handle static assets
- if (event.request.method === "GET") {
- event.respondWith(handleStaticRequest(event.request));
- return;
- }
-});
-
-async function handleApiRequest(request) {
- const url = new URL(request.url);
- const isEstimates = url.pathname.includes("GetStopEstimates");
- // Random cache age between 15-30 seconds for estimates to prevent thundering herd
- const maxAge = isEstimates
- ? ESTIMATES_MIN_AGE + Math.random() * (ESTIMATES_MAX_AGE - ESTIMATES_MIN_AGE)
- : API_MAX_AGE;
-
- const cache = await caches.open(API_CACHE_NAME);
- const cachedResponse = await cache.match(request);
-
- if (cachedResponse) {
- const age = Date.now() - new Date(cachedResponse.headers.get("date")).getTime();
- if (age < maxAge) {
- console.debug(`SW: Cache HIT for ${request.url}`);
- return cachedResponse;
- }
- // Cache is too old, fetch a fresh copy
- cache.delete(request);
- }
-
- try {
- const netResponse = await fetch(request);
-
- if (netResponse.ok) {
- const responseToCache = netResponse.clone();
- cache.put(request, responseToCache);
- console.debug(`SW: Cache MISS for ${request.url}`);
- }
-
- return netResponse;
- } catch (error) {
- // If network fails and we have a cached response (even if old), return it
- if (cachedResponse) {
- console.debug(`SW: Network failed, returning stale cache for ${request.url}`);
- return cachedResponse;
- }
- throw error;
- }
-}
-
-async function handleStaticRequest(request) {
- const cache = await caches.open(STATIC_CACHE_NAME);
- const cachedResponse = await cache.match(request);
-
- if (cachedResponse) {
- return cachedResponse;
- }
-
- try {
- const netResponse = await fetch(request);
- if (netResponse.ok) {
- cache.put(request, netResponse.clone());
- }
- return netResponse;
- } catch (error) {
- // Return a basic offline page for navigation requests
- if (request.mode === 'navigate') {
- return new Response('App is offline', {
- status: 503,
- statusText: 'Service Unavailable',
- headers: { 'Content-Type': 'text/plain' }
- });
- }
- throw error;
- }
-}
-
-// Handle messages from the main thread
-self.addEventListener("message", (event) => {
- if (event.data && event.data.type === "SKIP_WAITING") {
- self.skipWaiting();
- }
-
- if (event.data && event.data.type === "CLEAR_CACHE") {
- event.waitUntil(
- caches.keys().then((cacheNames) => {
- return Promise.all(
- cacheNames.map((cacheName) => caches.delete(cacheName))
- );
- })
- );
- }
-});