From 887c263599588544b672fb43ccdc56d83b919145 Mon Sep 17 00:00:00 2001 From: Vishal-770 Date: Fri, 7 Aug 2026 12:21:10 +0530 Subject: [PATCH 1/3] refactor: simplify mobile data fetching with useOfflineData hook --- src/hooks/use-offline-data.ts | 125 ++++++++++++++ .../dashboard/academic-calendar.tsx | 115 ++++--------- src/pages-mobile/dashboard/attendance.tsx | 58 ++----- .../dashboard/attendance/[classId].tsx | 62 ++----- src/pages-mobile/dashboard/contact.tsx | 54 ++---- src/pages-mobile/dashboard/courses.tsx | 58 ++----- .../dashboard/curriculum/[categoryId].tsx | 76 ++------- .../dashboard/curriculum/index.tsx | 53 ++---- src/pages-mobile/dashboard/exams.tsx | 92 +++------- src/pages-mobile/dashboard/grades.tsx | 121 ++++--------- src/pages-mobile/dashboard/hod-dean.tsx | 54 ++---- src/pages-mobile/dashboard/index.tsx | 160 +++++++----------- src/pages-mobile/dashboard/marks.tsx | 62 ++----- .../dashboard/payment-receipts.tsx | 59 ++----- src/pages-mobile/dashboard/profile.tsx | 49 +----- src/pages-mobile/dashboard/timetable.tsx | 107 +++++------- 16 files changed, 444 insertions(+), 861 deletions(-) create mode 100644 src/hooks/use-offline-data.ts diff --git a/src/hooks/use-offline-data.ts b/src/hooks/use-offline-data.ts new file mode 100644 index 0000000..b498bd4 --- /dev/null +++ b/src/hooks/use-offline-data.ts @@ -0,0 +1,125 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { fetchWithTimeout } from "@/lib/utils"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + [key: string]: unknown; +} + +export interface UseOfflineDataOptions { + /** localStorage key to read/write cache */ + cacheKey: string; + /** Async function that returns an ApiResponse-shaped object */ + fetcher: () => Promise>; + /** Only fetch when true — e.g. isLoggedIn && !authLoading */ + enabled?: boolean; + /** Timeout in ms (default: 15000) */ + timeout?: number; + /** Optional: filter or transform raw data before storing */ + transform?: (raw: T) => T; + /** Optional: check if cached value is non-empty (default: truthy check) */ + isEmpty?: (value: T) => boolean; +} + +export interface UseOfflineDataResult { + data: T | null; + loading: boolean; + error: string | null; + /** True when showing cached data but last fetch failed */ + isStale: boolean; + /** Manually trigger a re-fetch */ + retry: () => void; +} + +function readCache(cacheKey: string): T | null { + try { + const raw = localStorage.getItem(cacheKey); + if (raw) return JSON.parse(raw) as T; + } catch {} + return null; +} + +function writeCache(cacheKey: string, value: T): void { + try { + localStorage.setItem(cacheKey, JSON.stringify(value)); + } catch {} +} + +export function useOfflineData({ + cacheKey, + fetcher, + enabled = true, + timeout = 15000, + transform, + isEmpty, +}: UseOfflineDataOptions): UseOfflineDataResult { + const hasData = (value: T | null): boolean => { + if (value === null || value === undefined) return false; + if (isEmpty) return !isEmpty(value); + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value as object).length > 0; + return !!value; + }; + + const [data, setData] = useState(() => readCache(cacheKey)); + const [loading, setLoading] = useState(() => !hasData(readCache(cacheKey))); + const [error, setError] = useState(null); + const [isStale, setIsStale] = useState(false); + + // Use ref to avoid stale closure issues in fetch + const dataRef = useRef(data); + dataRef.current = data; + + const fetchData = useCallback(async () => { + if (!enabled) return; + + const currentData = dataRef.current; + const hasCache = hasData(currentData); + + setError(null); + setLoading(!hasCache); + + try { + const res = await fetchWithTimeout(fetcher(), timeout); + + if (res?.success && res.data !== undefined && res.data !== null) { + const processed = transform ? transform(res.data) : res.data; + setData(processed); + writeCache(cacheKey, processed); + setIsStale(false); + setError(null); + } else { + const errMsg = res?.error ?? "Failed to fetch data."; + if (!hasCache) { + setError(errMsg); + } else { + // silently mark stale — still showing cached data + setIsStale(true); + } + } + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + if (!hasCache) { + setError(errMsg); + } else { + setIsStale(true); + } + } finally { + setLoading(false); + } + }, [enabled, cacheKey, timeout]); + + useEffect(() => { + if (enabled) { + fetchData(); + } + }, [enabled]); + + const retry = useCallback(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, isStale, retry }; +} diff --git a/src/pages-mobile/dashboard/academic-calendar.tsx b/src/pages-mobile/dashboard/academic-calendar.tsx index f5f8b27..6ee4666 100644 --- a/src/pages-mobile/dashboard/academic-calendar.tsx +++ b/src/pages-mobile/dashboard/academic-calendar.tsx @@ -11,7 +11,8 @@ import { ErrorDisplay } from "@/components/error-display"; import { DrawerSelect } from "@/components/ui/drawer-select"; import { useOnlineStatus } from "@/hooks/use-online-status"; import { OfflineDisplay } from "@/components/offline-display"; -import { isNetworkError, fetchWithTimeout } from "@/lib/utils"; +import { isNetworkError } from "@/lib/utils"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { Info } from "lucide-react"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import calendarImg from "@/assets/calender.png"; @@ -92,6 +93,18 @@ type CalendarCell = { export default function AcademicCalendarPage() { const { loading: authLoading } = useAuth(); const isOnline = useOnlineStatus(); + const { + data: optionsData, + loading: optionsLoading, + error: optionsError, + retry: fetchOptions, + } = useOfflineData({ + cacheKey: "deskly::cache::calendar_options", + fetcher: getAcademicCalendarOptions, + }); + + const options = optionsData || null; + const initialOptions = useMemo(() => { try { const cachedOptions = localStorage.getItem("deskly::cache::calendar_options"); @@ -103,93 +116,29 @@ export default function AcademicCalendarPage() { return null; }, []); - const [options, setOptions] = useState(initialOptions); const [selectedOption, setSelectedOption] = useState(initialOptions?.[0] ?? null); - const [schedule, setSchedule] = useState(null); - const [loading, setLoading] = useState(!initialOptions); - const [error, setError] = useState(null); const [selectedCell, setSelectedCell] = useState(null); - // Load monthly view from cache first when selectedOption changes - useEffect(() => { - if (selectedOption) { - const cachedView = localStorage.getItem(`deskly::cache::calendar_view_${selectedOption.dateValue}`); - if (cachedView) { - try { - const parsed = JSON.parse(cachedView); - if (parsed && parsed.days && parsed.days.length > 0) { - setSchedule(parsed); - setLoading(false); - return; - } - } catch (e) { - console.error("Failed to parse cached calendar view", e); - } - } - setSchedule(null); - setLoading(true); - } - }, [selectedOption]); - - const fetchOptions = async () => { - const hasCache = !!(options && options.length > 0); - setLoading(!hasCache); - setError(null); - try { - const res = await fetchWithTimeout(getAcademicCalendarOptions(), 15000); - if (res.success && res.data && res.data.length > 0) { - setOptions(res.data); - localStorage.setItem("deskly::cache::calendar_options", JSON.stringify(res.data)); - if (!selectedOption) { - setSelectedOption(res.data[0]); - } - } else { - if (!hasCache) { - setError(res.error ?? "No academic calendar semesters found."); - } - setLoading(false); - } - } catch (e) { - if (!hasCache) { - setError(e instanceof Error ? e.message : String(e)); - } - setLoading(false); - } - }; - - const fetchView = async (dateVal: string) => { - const hasCache = !!(schedule && schedule.days && schedule.days.length > 0); - setLoading(!hasCache); - setError(null); - try { - const res = await fetchWithTimeout(getAcademicCalendarView(dateVal), 15000); - if (res.success && res.data) { - setSchedule(res.data); - localStorage.setItem(`deskly::cache::calendar_view_${dateVal}`, JSON.stringify(res.data)); - setSelectedCell(null); - } else { - if (!hasCache) { - setError(res.error ?? "Failed to load academic calendar view."); - } - } - } catch (e) { - if (!hasCache) { - setError(e instanceof Error ? e.message : String(e)); - } - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchOptions(); - }, []); - + // Sync selectedOption with options if it is null useEffect(() => { - if (selectedOption) { - fetchView(selectedOption.dateValue); + if (!selectedOption && options && options.length > 0) { + setSelectedOption(options[0]); } - }, [selectedOption]); + }, [options, selectedOption]); + + const { + data: viewData, + loading: viewLoading, + error: viewError, + } = useOfflineData({ + cacheKey: selectedOption ? `deskly::cache::calendar_view_${selectedOption.dateValue}` : "", + fetcher: () => getAcademicCalendarView(selectedOption!.dateValue), + enabled: !!selectedOption, + }); + + const schedule = viewData || null; + const loading = optionsLoading || viewLoading; + const error = optionsError || viewError; // Calendar Math const calendarCells = useMemo(() => { diff --git a/src/pages-mobile/dashboard/attendance.tsx b/src/pages-mobile/dashboard/attendance.tsx index 2fb2f7e..3c23902 100644 --- a/src/pages-mobile/dashboard/attendance.tsx +++ b/src/pages-mobile/dashboard/attendance.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useAuth } from "@/hooks/useAuth"; import { getCurrentAttendance, AttendanceRecord } from "@/lib/attendance"; -import { isNetworkError, fetchWithTimeout } from "@/lib/utils"; +import { isNetworkError } from "@/lib/utils"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { ErrorDisplay } from "@/components/error-display"; import { useOnlineStatus } from "@/hooks/use-online-status"; import { OfflineDisplay } from "@/components/offline-display"; @@ -381,51 +382,16 @@ export default function AttendancePage() { const isDetailRoute = useMatch("/dashboard/attendance/:classId"); const isOnline = useOnlineStatus(); - const [attendance, setAttendance] = useState(() => { - try { - const cached = localStorage.getItem("deskly::cache::attendance"); - if (cached) { - const parsed = JSON.parse(cached) as AttendanceRecord[]; - if (Array.isArray(parsed) && parsed.length > 0) return parsed; - } - } catch {} - return []; + const { data: rawData, loading, error, retry: load } = useOfflineData({ + cacheKey: "deskly::cache::attendance", + fetcher: getCurrentAttendance, + enabled: isLoggedIn && !authLoading, }); - const [loading, setLoading] = useState(attendance.length === 0); - const [error, setError] = useState(null); + const attendance = rawData || []; + const [selected, setSelected] = useState(null); const [filterType, setFilterType] = useState("all"); - async function load() { - if (authLoading || !isLoggedIn) return; - setError(null); - const hasCache = attendance.length > 0; - setLoading(!hasCache); - try { - const res = await fetchWithTimeout(getCurrentAttendance(), 15000); - if (res.success && res.data) { - setAttendance(res.data); - const sem = res.semesterId ?? ""; - localStorage.setItem("deskly::cache::attendance", JSON.stringify(res.data)); - localStorage.setItem("deskly::cache::attendance_semester", sem); - } else { - if (!hasCache) { - setError(res.error ?? "Failed to fetch attendance."); - } - } - } catch (e) { - if (!hasCache) { - setError(e instanceof Error ? e.message : String(e)); - } - } finally { - setLoading(false); - } - } - - useEffect(() => { - if (isLoggedIn) load(); - }, [isLoggedIn, authLoading]); - const stats = useMemo(() => { let totalAttended = 0; let totalClasses = 0; @@ -458,17 +424,17 @@ export default function AttendancePage() { return ; } - const showOffline = attendance.length === 0 && (isOnline === false || isNetworkError(error, isOnline)); + const showOffline = !rawData && !loading && (isOnline === false || isNetworkError(error, isOnline)); if (showOffline) { return ; } - if (authLoading || (loading && attendance.length === 0)) { + if (authLoading || (loading && !rawData)) { return ; } - if (error && attendance.length === 0) { + if (error && !rawData) { return (
diff --git a/src/pages-mobile/dashboard/attendance/[classId].tsx b/src/pages-mobile/dashboard/attendance/[classId].tsx index b70cbc9..cf46faa 100644 --- a/src/pages-mobile/dashboard/attendance/[classId].tsx +++ b/src/pages-mobile/dashboard/attendance/[classId].tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { useLocation } from "react-router-dom"; import { useParams, useNavigate } from "@/router"; import { getAttendanceDetail, AttendanceDetailRecord, AttendanceRecord } from "@/lib/attendance"; -import { fetchWithTimeout, isNetworkError } from "@/lib/utils"; +import { isNetworkError } from "@/lib/utils"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { Separator } from "@/components/ui/separator"; import { Calendar, WifiOff, CheckCircle2, XCircle, Award, Clock } from "lucide-react"; import { OfflineDisplay } from "@/components/offline-display"; @@ -115,21 +116,6 @@ export default function AttendanceDetailPage() { const isOnline = useOnlineStatus(); const [record, setRecord] = useState(location.state?.record); - const [details, setDetails] = useState(() => { - if (!classId) return []; - try { - const cacheKey = `deskly::cache::attendance_detail_${classId}`; - const cached = localStorage.getItem(cacheKey); - if (cached) { - const parsed = JSON.parse(cached); - if (Array.isArray(parsed) && parsed.length > 0) return parsed; - } - } catch {} - return []; - }); - const [loading, setLoading] = useState(details.length === 0); - const [isRetrying, setIsRetrying] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!record && classId) { @@ -148,37 +134,19 @@ export default function AttendanceDetailPage() { } }, [classId, record]); - async function load(isManualRetry = false) { - if (!classId || !record) return; - if (isManualRetry && !isOnline) return; - - const hasCache = details.length > 0; - if (isManualRetry) { - setIsRetrying(true); - } else { - setLoading(!hasCache); - } - - try { - const cacheKey = `deskly::cache::attendance_detail_${classId}`; - const res = await fetchWithTimeout(getAttendanceDetail(classId!, record!.slot), 15000); - if (res.success && res.data) { - setDetails(res.data); - localStorage.setItem(cacheKey, JSON.stringify(res.data)); - } else { - if (!hasCache) setError(res.error ?? "Failed to load attendance details."); - } - } catch (e) { - if (!hasCache) setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - setIsRetrying(false); - } - } + const { + data: detailsData, + loading, + error, + retry: load, + } = useOfflineData({ + cacheKey: `deskly::cache::attendance_detail_${classId}`, + fetcher: () => getAttendanceDetail(classId!, record!.slot), + enabled: !!classId && !!record, + }); - useEffect(() => { - load(); - }, [classId, record]); + const details = detailsData || []; + const isRetrying = loading; if (loading && !record) { return ; @@ -299,7 +267,7 @@ export default function AttendanceDetailPage() {

Session logs unavailable offline

Connect to the internet to load detailed session logs for this class.