From 5cc27f852b02446659e0ab85305916c9f5e5a5f0 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 6 Aug 2025 00:12:19 +0200 Subject: feat: Implement pull-to-refresh functionality across various components - Added `PullToRefresh` component to enable pull-to-refresh behavior in `StopList` and `Estimates` pages. - Integrated `usePullToRefresh` hook to manage pull-to-refresh state and actions. - Created `UpdateNotification` component to inform users of available updates from the service worker. - Enhanced service worker management with `ServiceWorkerManager` class for better update handling and caching strategies. - Updated CSS styles for new components and improved layout for better user experience. - Refactored API caching logic in service worker to handle multiple endpoints and dynamic cache expiration. - Added auto-refresh functionality for estimates data to keep information up-to-date. --- src/frontend/app/components/PullToRefresh.css | 72 ++++++++++ src/frontend/app/components/PullToRefresh.tsx | 53 ++++++++ src/frontend/app/components/TimetableTable.tsx | 30 ++--- src/frontend/app/components/UpdateNotification.css | 114 ++++++++++++++++ src/frontend/app/components/UpdateNotification.tsx | 63 +++++++++ src/frontend/app/hooks/useAutoRefresh.ts | 63 +++++++++ src/frontend/app/hooks/usePullToRefresh.ts | 99 ++++++++++++++ src/frontend/app/root.css | 3 + src/frontend/app/root.tsx | 23 ++-- src/frontend/app/routes/estimates-$id.css | 5 + src/frontend/app/routes/estimates-$id.tsx | 141 ++++++++++++-------- src/frontend/app/routes/stoplist.css | 5 + src/frontend/app/routes/stoplist.tsx | 148 ++++++++++++--------- src/frontend/app/routes/timetable-$id.css | 8 +- src/frontend/app/utils/serviceWorkerManager.ts | 80 +++++++++++ src/frontend/index.html | 9 -- src/frontend/public/manifest.webmanifest | 2 + src/frontend/public/sw.js | 133 +++++++++++++++--- 18 files changed, 882 insertions(+), 169 deletions(-) create mode 100644 src/frontend/app/components/PullToRefresh.css create mode 100644 src/frontend/app/components/PullToRefresh.tsx create mode 100644 src/frontend/app/components/UpdateNotification.css create mode 100644 src/frontend/app/components/UpdateNotification.tsx create mode 100644 src/frontend/app/hooks/useAutoRefresh.ts create mode 100644 src/frontend/app/hooks/usePullToRefresh.ts create mode 100644 src/frontend/app/utils/serviceWorkerManager.ts (limited to 'src/frontend') diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css new file mode 100644 index 0000000..15ca74b --- /dev/null +++ b/src/frontend/app/components/PullToRefresh.css @@ -0,0 +1,72 @@ +.pull-to-refresh-container { + position: relative; + height: 100%; + overflow: hidden; +} + +.pull-to-refresh-indicator { + position: absolute; + top: -80px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px; + z-index: 1000; + opacity: 0; + transition: opacity 0.2s ease; +} + +.pull-to-refresh-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--button-background-color); + color: white; + transition: all 0.3s ease; + transform-origin: center; +} + +.pull-to-refresh-icon.ready { + background-color: #28a745; +} + +.pull-to-refresh-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.pull-to-refresh-text { + font-size: 0.875rem; + color: var(--subtitle-color); + font-weight: 500; + text-align: center; + white-space: nowrap; +} + +.pull-to-refresh-content { + height: 100%; + overflow: auto; + transition: transform 0.2s ease; +} + +/* Disable browser's default pull-to-refresh on mobile */ +.pull-to-refresh-content { + overscroll-behavior-y: contain; +} + +@media (hover: hover) { + /* Hide pull-to-refresh on desktop devices */ + .pull-to-refresh-indicator { + display: none; + } +} diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx new file mode 100644 index 0000000..47a6f03 --- /dev/null +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -0,0 +1,53 @@ +import { type ReactNode } from "react"; +import { RotateCcw } from "lucide-react"; +import "./PullToRefresh.css"; + +interface PullToRefreshIndicatorProps { + pullDistance: number; + isRefreshing: boolean; + canRefresh: boolean; + children: ReactNode; +} + +export function PullToRefreshIndicator({ + pullDistance, + isRefreshing, + canRefresh, + children, +}: PullToRefreshIndicatorProps) { + const opacity = Math.min(pullDistance / 60, 1); + const rotation = isRefreshing ? 360 : pullDistance * 4; + const scale = Math.min(0.5 + (pullDistance / 120), 1); + + return ( +
+
+
+ +
+
+ {isRefreshing ? "Actualizando..." : canRefresh ? "Suelta para actualizar" : "Arrastra para actualizar"} +
+
+
+ {children} +
+
+ ); +} diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx index 98360bc..d03ddf4 100644 --- a/src/frontend/app/components/TimetableTable.tsx +++ b/src/frontend/app/components/TimetableTable.tsx @@ -31,21 +31,21 @@ interface TimetableTableProps { const parseServiceId = (serviceId: string): string => { const parts = serviceId.split('_'); if (parts.length === 0) return ''; - + const lastPart = parts[parts.length - 1]; if (lastPart.length < 6) return ''; - + const last6 = lastPart.slice(-6); const lineCode = last6.slice(0, 3); const turnCode = last6.slice(-3); - + // Remove leading zeros from turn const turnNumber = parseInt(turnCode, 10).toString(); - + // Parse line number with special cases const lineNumber = parseInt(lineCode, 10); let displayLine: string; - + switch (lineNumber) { case 1: displayLine = "C1"; break; case 3: displayLine = "C3"; break; @@ -57,7 +57,7 @@ const parseServiceId = (serviceId: string): string => { case 500: displayLine = "TUR"; break; default: displayLine = `L${lineNumber}`; } - + return `${displayLine}-${turnNumber}`; }; @@ -70,24 +70,24 @@ const timeToMinutes = (time: string): number => { // Utility function to find nearby entries const findNearbyEntries = (entries: TimetableEntry[], currentTime: string, before: number = 4, after: number = 4): TimetableEntry[] => { if (!currentTime) return entries.slice(0, before + after); - + const currentMinutes = timeToMinutes(currentTime); - const sortedEntries = [...entries].sort((a, b) => + const sortedEntries = [...entries].sort((a, b) => timeToMinutes(a.departure_time) - timeToMinutes(b.departure_time) ); - - let currentIndex = sortedEntries.findIndex(entry => + + let currentIndex = sortedEntries.findIndex(entry => timeToMinutes(entry.departure_time) >= currentMinutes ); - + if (currentIndex === -1) { // All entries are before current time, show last ones return sortedEntries.slice(-before - after); } - + const startIndex = Math.max(0, currentIndex - before); const endIndex = Math.min(sortedEntries.length, currentIndex + after); - + return sortedEntries.slice(startIndex, endIndex); }; @@ -128,7 +128,7 @@ export const TimetableTable: React.FC = ({
- +
{entry.trip.headsign && entry.trip.headsign.trim() ? ( {entry.trip.headsign} @@ -136,7 +136,7 @@ export const TimetableTable: React.FC = ({ {t("timetable.noDestination", "Línea")} {entry.line.name} )}
- +
{entry.departure_time.slice(0, 5)} diff --git a/src/frontend/app/components/UpdateNotification.css b/src/frontend/app/components/UpdateNotification.css new file mode 100644 index 0000000..6183194 --- /dev/null +++ b/src/frontend/app/components/UpdateNotification.css @@ -0,0 +1,114 @@ +.update-notification { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + background-color: var(--button-background-color); + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} + +.update-content { + display: flex; + align-items: center; + padding: 12px 16px; + gap: 12px; + max-width: 100%; +} + +.update-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background-color: rgba(255, 255, 255, 0.2); + border-radius: 50%; +} + +.update-text { + flex: 1; + min-width: 0; +} + +.update-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 2px; +} + +.update-description { + font-size: 0.8rem; + opacity: 0.9; + line-height: 1.2; +} + +.update-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.update-button { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.update-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.3); +} + +.update-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.update-dismiss { + background: none; + border: none; + color: white; + padding: 6px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.update-dismiss:hover { + background: rgba(255, 255, 255, 0.2); +} + +@media (min-width: 768px) { + .update-content { + max-width: 768px; + margin: 0 auto; + } +} + +@media (min-width: 1024px) { + .update-content { + max-width: 1024px; + } +} diff --git a/src/frontend/app/components/UpdateNotification.tsx b/src/frontend/app/components/UpdateNotification.tsx new file mode 100644 index 0000000..e07ee74 --- /dev/null +++ b/src/frontend/app/components/UpdateNotification.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from "react"; +import { Download, X } from "lucide-react"; +import { swManager } from "../utils/serviceWorkerManager"; +import "./UpdateNotification.css"; + +export function UpdateNotification() { + const [showUpdate, setShowUpdate] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + swManager.onUpdate(() => { + setShowUpdate(true); + }); + }, []); + + const handleUpdate = async () => { + setIsUpdating(true); + swManager.activateUpdate(); + + // Wait for the page to reload + setTimeout(() => { + window.location.reload(); + }, 500); + }; + + const handleDismiss = () => { + setShowUpdate(false); + }; + + if (!showUpdate) return null; + + return ( +
+
+
+ +
+
+
Nueva versión disponible
+
+ Actualiza para obtener las últimas mejoras +
+
+
+ + +
+
+
+ ); +} diff --git a/src/frontend/app/hooks/useAutoRefresh.ts b/src/frontend/app/hooks/useAutoRefresh.ts new file mode 100644 index 0000000..172fa94 --- /dev/null +++ b/src/frontend/app/hooks/useAutoRefresh.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface UseAutoRefreshOptions { + onRefresh: () => Promise; + interval?: number; + enabled?: boolean; +} + +export function useAutoRefresh({ + onRefresh, + interval = 30000, // 30 seconds default + enabled = true, +}: UseAutoRefreshOptions) { + const intervalRef = useRef(null); + const refreshCallbackRef = useRef(onRefresh); + + // Update callback ref when it changes + useEffect(() => { + refreshCallbackRef.current = onRefresh; + }, [onRefresh]); + + const startAutoRefresh = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + if (enabled) { + intervalRef.current = setInterval(() => { + refreshCallbackRef.current(); + }, interval); + } + }, [interval, enabled]); + + const stopAutoRefresh = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + startAutoRefresh(); + return stopAutoRefresh; + }, [startAutoRefresh, stopAutoRefresh]); + + // Handle visibility change to pause/resume auto-refresh + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + stopAutoRefresh(); + } else { + startAutoRefresh(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [startAutoRefresh, stopAutoRefresh]); + + return { startAutoRefresh, stopAutoRefresh }; +} diff --git a/src/frontend/app/hooks/usePullToRefresh.ts b/src/frontend/app/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..b34502b --- /dev/null +++ b/src/frontend/app/hooks/usePullToRefresh.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from "react"; + +interface PullToRefreshOptions { + onRefresh: () => Promise; + threshold?: number; + resistance?: number; + enabled?: boolean; +} + +export function usePullToRefresh({ + onRefresh, + threshold = 80, + resistance = 2.5, + enabled = true, +}: PullToRefreshOptions) { + const containerRef = useRef(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/root.css b/src/frontend/app/root.css index da3ab67..d72d776 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -40,6 +40,9 @@ body { height: 100dvh; width: 100%; overflow: hidden; + + /* Disable browser's native pull-to-refresh on mobile */ + overscroll-behavior-y: contain; } .main-content { diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 55c6c16..040494f 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -18,23 +18,15 @@ import "maplibre-theme/modern.css"; import { Protocol } from "pmtiles"; import maplibregl, { type LngLatLike } from "maplibre-gl"; import { AppProvider } from "./AppContext"; +import { swManager } from "./utils/serviceWorkerManager"; +import { UpdateNotification } from "./components/UpdateNotification"; +import { useEffect } from "react"; const pmtiles = new Protocol(); maplibregl.addProtocol("pmtiles", pmtiles.tile); //#endregion import "./i18n"; -if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register("/sw.js") - .then((registration) => { - console.log("Service Worker registered with scope:", registration.scope); - }) - .catch((error) => { - console.error("Service Worker registration failed:", error); - }); -} - export const links: Route.LinksFunction = () => []; export function HydrateFallback() { @@ -47,6 +39,9 @@ export function Layout({ children }: { children: React.ReactNode }) { + + + @@ -113,8 +108,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean { import NavBar from "./components/NavBar"; export default function App() { + useEffect(() => { + // Initialize service worker + swManager.initialize(); + }, []); + return ( +
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index 8906147..424c76f 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -29,6 +29,11 @@ } /* 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 b5ae91a..d9b9b47 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -1,4 +1,4 @@ -import { type JSX, useEffect, useState } from "react"; +import { type JSX, useEffect, useState, useCallback } from "react"; import { useParams, Link } from "react-router"; import StopDataProvider from "../data/StopDataProvider"; import { Star, Edit2, ExternalLink } from "lucide-react"; @@ -8,6 +8,9 @@ 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 { stop: { @@ -62,23 +65,51 @@ export default function Estimates() { const [timetableData, setTimetableData] = useState([]); const { tableStyle } = useApp(); - useEffect(() => { - // Load real-time estimates - loadData(params.id!).then((body: StopDetails) => { - setData(body); - setDataDate(new Date()); - setCustomName(StopDataProvider.getCustomName(stopIdNum)); - }); + const loadEstimatesData = useCallback(async () => { + const body: StopDetails = await loadData(params.id!); + setData(body); + setDataDate(new Date()); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); + }, [params.id, stopIdNum]); - // Load timetable data - loadTimetableData(params.id!).then((timetableBody: TimetableEntry[]) => { - setTimetableData(timetableBody); - }); + const loadTimetableDataAsync = useCallback(async () => { + const timetableBody: TimetableEntry[] = await loadTimetableData(params.id!); + setTimetableData(timetableBody); + }, [params.id]); - StopDataProvider.pushRecent(parseInt(params.id ?? "")); + const refreshData = useCallback(async () => { + await Promise.all([ + loadEstimatesData(), + loadTimetableDataAsync() + ]); + }, [loadEstimatesData, loadTimetableDataAsync]); + + const { + containerRef, + isRefreshing, + pullDistance, + canRefresh, + } = usePullToRefresh({ + onRefresh: refreshData, + threshold: 80, + enabled: true, + }); + + // Auto-refresh estimates data every 30 seconds + useAutoRefresh({ + onRefresh: loadEstimatesData, + interval: 30000, + enabled: true, + }); + + useEffect(() => { + // Initial load + loadEstimatesData(); + loadTimetableDataAsync(); + StopDataProvider.pushRecent(parseInt(params.id ?? "")); setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? ""))); - }, [params.id]); + }, [params.id, loadEstimatesData, loadTimetableDataAsync]); const toggleFavourite = () => { if (favourited) { @@ -108,45 +139,51 @@ export default function Estimates() { return

{t("common.loading")}

; return ( -
-
-

- + +
+

+ + + {customName ?? data.stop.name}{" "} + ({data.stop.id}) +

+
+ +
+ {tableStyle === "grouped" ? ( + + ) : ( + + )} +
+ +
+ - - {customName ?? data.stop.name}{" "} - ({data.stop.id}) -

-
- -
- {tableStyle === "grouped" ? ( - - ) : ( - - )} -
- -
- - - {timetableData.length > 0 && ( -
- - - {t("timetable.viewAll", "Ver todos los horarios")} - -
- )} -
+ + {timetableData.length > 0 && ( +
+ + + {t("timetable.viewAll", "Ver todos los horarios")} + +
+ )} +
+
); } diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css index 253c0ab..99c2da7 100644 --- a/src/frontend/app/routes/stoplist.css +++ b/src/frontend/app/routes/stoplist.css @@ -1,4 +1,9 @@ /* 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 58cdab4..70b1525 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -1,9 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; 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(); @@ -20,10 +22,26 @@ export default function StopList() { [data], ); - useEffect(() => { - StopDataProvider.getStops().then((stops: Stop[]) => setData(stops)); + const loadStops = useCallback(async () => { + const stops = await StopDataProvider.getStops(); + setData(stops); }, []); + const { + containerRef, + isRefreshing, + pullDistance, + canRefresh, + } = usePullToRefresh({ + onRefresh: loadStops, + threshold: 80, + enabled: true, + }); + + useEffect(() => { + loadStops(); + }, [loadStops]); + const handleStopSearch = (event: React.ChangeEvent) => { const stopName = event.target.value || ""; @@ -68,77 +86,83 @@ export default function StopList() { return

{t("common.loading")}

; return ( -
-

UrbanoVigo Web

- -
-
- - -
-
+
+ +

UrbanoVigo Web

+ +
+
+ + +
+
+ + {searchResults && searchResults.length > 0 && ( +
+

+ {t("stoplist.search_results", "Resultados de la búsqueda")} +

+
    + {searchResults.map((stop: Stop) => ( + + ))} +
+
+ )} - {searchResults && searchResults.length > 0 && (
-

- {t("stoplist.search_results", "Resultados de la búsqueda")} -

+

{t("stoplist.favourites")}

+ + {favouritedStops?.length === 0 && ( +

+ {t( + "stoplist.no_favourites", + "Accede a una parada y márcala como favorita para verla aquí.", + )} +

+ )} +
    - {searchResults.map((stop: Stop) => ( - - ))} + {favouritedStops + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => )}
- )} - -
-

{t("stoplist.favourites")}

- - {favouritedStops?.length === 0 && ( -

- {t( - "stoplist.no_favourites", - "Accede a una parada y márcala como favorita para verla aquí.", - )} -

- )} -
    - {favouritedStops - ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => )} -
-
+ {recentStops && recentStops.length > 0 && ( +
+

{t("stoplist.recents")}

+ +
    + {recentStops.map((stop: Stop) => ( + + ))} +
+
+ )} - {recentStops && recentStops.length > 0 && (
-

{t("stoplist.recents")}

+

{t("stoplist.all_stops", "Paradas")}

    - {recentStops.map((stop: Stop) => ( - - ))} + {data + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => )}
- )} - -
-

{t("stoplist.all_stops", "Paradas")}

- -
    - {data - ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => )} -
-
+
); } diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css index 5ae472c..5296615 100644 --- a/src/frontend/app/routes/timetable-$id.css +++ b/src/frontend/app/routes/timetable-$id.css @@ -62,7 +62,7 @@ } .timetable-controls { - margin-bottom: 1.5rem; + margin-bottom: 1.5rem; display: flex; justify-content: center; } @@ -124,15 +124,15 @@ .page-title { font-size: 1.5rem; } - + .page-title .stop-name { font-size: 1.1rem; } - + .timetable-full-content .timetable-cards { gap: 0.75rem; } - + .timetable-full-content .timetable-card { padding: 1rem; } diff --git a/src/frontend/app/utils/serviceWorkerManager.ts b/src/frontend/app/utils/serviceWorkerManager.ts new file mode 100644 index 0000000..cbff748 --- /dev/null +++ b/src/frontend/app/utils/serviceWorkerManager.ts @@ -0,0 +1,80 @@ +export class ServiceWorkerManager { + private registration: ServiceWorkerRegistration | null = null; + private updateAvailable = false; + private onUpdateCallback?: () => void; + + async initialize() { + if (!("serviceWorker" in navigator)) { + console.log("Service Workers not supported"); + return; + } + + 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?.(); + } + }); + } + }); + + // Listen for messages from the service worker + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data.type === "SW_UPDATED") { + this.updateAvailable = true; + this.onUpdateCallback?.(); + } + }); + + // Check for updates periodically + setInterval(() => { + this.checkForUpdates(); + }, 60 * 1000); // Check every minute + + } catch (error) { + console.error("Service Worker registration failed:", error); + } + } + + async checkForUpdates() { + if (this.registration) { + try { + await this.registration.update(); + } catch (error) { + console.error("Failed to check for updates:", error); + } + } + } + + activateUpdate() { + if (this.registration && this.registration.waiting) { + this.registration.waiting.postMessage({ type: "SKIP_WAITING" }); + this.updateAvailable = false; + } + } + + onUpdate(callback: () => void) { + this.onUpdateCallback = callback; + } + + isUpdateAvailable() { + return this.updateAvailable; + } + + async clearCache() { + if (this.registration && this.registration.active) { + this.registration.active.postMessage({ type: "CLEAR_CACHE" }); + } + } +} + +export const swManager = new ServiceWorkerManager(); diff --git a/src/frontend/index.html b/src/frontend/index.html index f208550..24697c0 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -52,14 +52,5 @@
- - diff --git a/src/frontend/public/manifest.webmanifest b/src/frontend/public/manifest.webmanifest index 5dd08fa..619dfc6 100644 --- a/src/frontend/public/manifest.webmanifest +++ b/src/frontend/public/manifest.webmanifest @@ -10,6 +10,8 @@ "lang": "es", "background_color": "#ffffff", "theme_color": "#007bff", + "categories": ["travel", "utilities", "productivity"], + "prefer_related_applications": false, "icons": [ { "src": "/logo-512.jpg", diff --git a/src/frontend/public/sw.js b/src/frontend/public/sw.js index 01d403a..ca826f6 100644 --- a/src/frontend/public/sw.js +++ b/src/frontend/public/sw.js @@ -1,52 +1,153 @@ -const API_CACHE_NAME = "api-cache-v1"; -const API_URL_PATTERN = /\/api\/(GetStopList)/; +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) => { - event.waitUntil(self.skipWaiting()); + 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) => { - event.waitUntil(self.clients.claim()); + 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); - if (event.request.method !== "GET" || !API_URL_PATTERN.test(url.pathname)) { + // Handle API requests with caching + if (event.request.method === "GET" && API_URL_PATTERN.test(url.pathname)) { + event.respondWith(handleApiRequest(event.request)); return; } - event.respondWith(apiCacheFirst(event.request)); + // Handle static assets + if (event.request.method === "GET") { + event.respondWith(handleStaticRequest(event.request)); + return; + } }); -async function apiCacheFirst(request) { +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 < API_MAX_AGE) { + 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}`); + } - 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)) + ); + }) + ); + } +}); -- cgit v1.3