diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-08-06 00:12:19 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-08-06 00:12:19 +0200 |
| commit | 5cc27f852b02446659e0ab85305916c9f5e5a5f0 (patch) | |
| tree | 622636a2a7eade5442a3efb1726d822657d30295 /src/frontend/app/routes | |
| parent | b04fd7d33d07f9eddea2eb53e1389d5ca5453413 (diff) | |
feat: Implement pull-to-refresh functionality across various components
- Added `PullToRefresh` component to enable pull-to-refresh behavior in `StopList` and `Estimates` pages.
- Integrated `usePullToRefresh` hook to manage pull-to-refresh state and actions.
- Created `UpdateNotification` component to inform users of available updates from the service worker.
- Enhanced service worker management with `ServiceWorkerManager` class for better update handling and caching strategies.
- Updated CSS styles for new components and improved layout for better user experience.
- Refactored API caching logic in service worker to handle multiple endpoints and dynamic cache expiration.
- Added auto-refresh functionality for estimates data to keep information up-to-date.
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 139 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.css | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 142 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.css | 8 |
5 files changed, 185 insertions, 114 deletions
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<TimetableEntry[]>([]); 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 <h1 className="page-title">{t("common.loading")}</h1>; return ( - <div className="page-container"> - <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 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="table-responsive"> - {tableStyle === "grouped" ? ( - <GroupedTable data={data} dataDate={dataDate} /> - ) : ( - <RegularTable data={data} dataDate={dataDate} /> - )} - </div> + <div className="table-responsive"> + {tableStyle === "grouped" ? ( + <GroupedTable data={data} dataDate={dataDate} /> + ) : ( + <RegularTable data={data} dataDate={dataDate} /> + )} + </div> + + <div className="timetable-section"> + <TimetableTable + data={timetableData} + currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS + /> - <div className="timetable-section"> - <TimetableTable - data={timetableData} - currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS - /> - - {timetableData.length > 0 && ( - <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> + {timetableData.length > 0 && ( + <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> </div> ); } 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<HTMLInputElement>) => { const stopName = event.target.value || ""; @@ -68,77 +86,83 @@ export default function StopList() { return <h1 className="page-title">{t("common.loading")}</h1>; return ( - <div className="page-container"> - <h1 className="page-title">UrbanoVigo Web</h1> + <div ref={containerRef} className="page-container stoplist-page"> + <PullToRefreshIndicator + pullDistance={pullDistance} + isRefreshing={isRefreshing} + canRefresh={canRefresh} + > + <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" - type="text" - placeholder={randomPlaceholder} - id="stopName" - onChange={handleStopSearch} - /> - </div> - </form> + <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" + onChange={handleStopSearch} + /> + </div> + </form> + + {searchResults && searchResults.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle"> + {t("stoplist.search_results", "Resultados de la búsqueda")} + </h2> + <ul className="list"> + {searchResults.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + )} - {searchResults && searchResults.length > 0 && ( <div className="list-container"> - <h2 className="page-subtitle"> - {t("stoplist.search_results", "Resultados de la búsqueda")} - </h2> + <h2 className="page-subtitle">{t("stoplist.favourites")}</h2> + + {favouritedStops?.length === 0 && ( + <p className="message"> + {t( + "stoplist.no_favourites", + "Accede a una parada y márcala como favorita para verla aquí.", + )} + </p> + )} + <ul className="list"> - {searchResults.map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} + {favouritedStops + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} </ul> </div> - )} - <div className="list-container"> - <h2 className="page-subtitle">{t("stoplist.favourites")}</h2> + {recentStops && recentStops.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle">{t("stoplist.recents")}</h2> - {favouritedStops?.length === 0 && ( - <p className="message"> - {t( - "stoplist.no_favourites", - "Accede a una parada y márcala como favorita para verla aquí.", - )} - </p> + <ul className="list"> + {recentStops.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> )} - <ul className="list"> - {favouritedStops - ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} - </ul> - </div> - - {recentStops && recentStops.length > 0 && ( <div className="list-container"> - <h2 className="page-subtitle">{t("stoplist.recents")}</h2> + <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2> <ul className="list"> - {recentStops.map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} + {data + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} </ul> </div> - )} - - <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> </div> ); } 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; } |
