diff --git a/src/pages-desktop/dashboard/academic-calendar.tsx b/src/pages-desktop/dashboard/academic-calendar.tsx index 993c0c6..a445390 100644 --- a/src/pages-desktop/dashboard/academic-calendar.tsx +++ b/src/pages-desktop/dashboard/academic-calendar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { useAuth } from "@/hooks/useAuth"; import { getAcademicCalendarOptions, @@ -6,6 +6,7 @@ import { CalendarMonthOption, MonthlySchedule, } from "@/lib/features"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { ErrorDisplay } from "@/components/error-display"; import { @@ -104,101 +105,47 @@ type CalendarCell = { export default function AcademicCalendarPage() { const { loading: authLoading } = useAuth(); - const [options, setOptions] = useState(null); + const { + data: optionsData, + loading: optionsLoading, + error: optionsError, + retry: fetchOptions, + } = useOfflineData({ + cacheKey: "deskly::cache::calendar_options", + fetcher: getAcademicCalendarOptions, + }); + + const options = optionsData; const [selectedOption, setSelectedOption] = useState(null); - const [schedule, setSchedule] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - // Selected cell details in side panel - const [selectedCell, setSelectedCell] = useState(null); - - // Load options from cache first useEffect(() => { - const cachedOptions = localStorage.getItem("deskly::cache::calendar_options"); - if (cachedOptions) { - try { - const parsed = JSON.parse(cachedOptions); - if (parsed && parsed.length > 0) { - setOptions(parsed); - setSelectedOption(parsed[0]); - } - } catch (e) { - console.error("Failed to parse cached academic calendar options", e); - } + if (optionsData && optionsData.length > 0 && !selectedOption) { + setSelectedOption(optionsData[0]); } - }, []); + }, [optionsData, selectedOption]); + + const { + data: scheduleData, + loading: viewLoading, + error: viewError, + retry: retryView, + } = useOfflineData({ + cacheKey: selectedOption ? `deskly::cache::calendar_view_${selectedOption.dateValue}` : "", + fetcher: () => getAcademicCalendarView(selectedOption!.dateValue), + enabled: !!selectedOption, + }); + + const schedule = scheduleData; + const loading = optionsLoading || viewLoading; + const error = optionsError || viewError; - // 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 () => { - setLoading(options && options.length > 0 ? false : true); - setError(null); - try { - const res = await getAcademicCalendarOptions(); - 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 { - setError(res.error ?? "No academic calendar semesters found."); - setLoading(false); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setLoading(false); - } - }; - - const fetchView = async (dateVal: string) => { - setLoading(schedule ? false : true); - setError(null); - try { - const res = await getAcademicCalendarView(dateVal); - if (res.success && res.data) { - setSchedule(res.data); - localStorage.setItem(`deskly::cache::calendar_view_${dateVal}`, JSON.stringify(res.data)); - setSelectedCell(null); // Reset day details view on month change - } else { - setError(res.error ?? "Failed to load academic calendar view."); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchOptions(); - }, []); + // Selected cell details in side panel + const [selectedCell, setSelectedCell] = useState(null); - useEffect(() => { - if (selectedOption) { - fetchView(selectedOption.dateValue); - } - }, [selectedOption]); + const fetchView = useCallback((_dateVal?: string) => { + setSelectedCell(null); + retryView(); + }, [retryView]); // Calendar Math: construct 35-42 grid cells based on loaded schedule and options const calendarCells = useMemo(() => { diff --git a/src/pages-desktop/dashboard/attendance.tsx b/src/pages-desktop/dashboard/attendance.tsx index 29d2e7e..b614f88 100644 --- a/src/pages-desktop/dashboard/attendance.tsx +++ b/src/pages-desktop/dashboard/attendance.tsx @@ -1,11 +1,12 @@ -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 { ErrorDisplay } from "@/components/error-display"; import { useOnlineStatus } from "@/hooks/use-online-status"; import { OfflineDisplay } from "@/components/offline-display"; import { Outlet, useMatch, useNavigate } from "react-router-dom"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { UserCheck, CalendarDays, @@ -247,49 +248,25 @@ 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; + const { + data: attendanceRaw, + loading, + error, + retry, + } = useOfflineData({ + cacheKey: "deskly::cache::attendance", + fetcher: async () => { + const res = await getCurrentAttendance(); + if (res.success && res.semesterId) { + localStorage.setItem("deskly::cache::attendance_semester", res.semesterId); } - } catch {} - return []; + return res; + }, + enabled: isLoggedIn && !authLoading, }); - const [loading, setLoading] = useState(attendance.length === 0); - const [error, setError] = 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 attendance = useMemo(() => attendanceRaw || [], [attendanceRaw]); + const [filterType, setFilterType] = useState("all"); const stats = useMemo(() => { let totalAttended = 0; @@ -326,7 +303,7 @@ export default function AttendancePage() { const showOffline = attendance.length === 0 && (isOnline === false || isNetworkError(error, isOnline)); if (showOffline) { - return ; + return ; } if (authLoading || (loading && attendance.length === 0)) { @@ -336,7 +313,7 @@ export default function AttendancePage() { if (error && attendance.length === 0) { return (
- +
); } @@ -349,7 +326,7 @@ export default function AttendancePage() { {error && !isNetworkError(error, isOnline) && (

Sync failed — {error}

-
diff --git a/src/pages-desktop/dashboard/attendance/%5BclassId%5D.tsx b/src/pages-desktop/dashboard/attendance/%5BclassId%5D.tsx deleted file mode 100644 index 5412b3f..0000000 --- a/src/pages-desktop/dashboard/attendance/%5BclassId%5D.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useEffect } from "react"; -import { useLocation } from "react-router-dom"; -import { useParams, useNavigate } from "@/router"; -import { getAttendanceDetail, AttendanceDetailRecord, AttendanceRecord } from "@/lib/attendance"; - -import { - ArrowLeft, - User, - CheckCircle2, - XCircle, - Clock, - Calendar, - - School, - MinusCircle, -} from "lucide-react"; - -// ─── Circular Arc Progress ──────────────────────────────────────────────────── - -function BigCircularProgress({ percentage }: { percentage: number }) { - const size = 68; - const radius = 28; - const circumference = 2 * Math.PI * radius; - const offset = circumference - (Math.min(percentage, 100) / 100) * circumference; - - let stroke = "stroke-destructive"; - if (percentage >= 75) stroke = "stroke-emerald-500"; - else if (percentage >= 50) stroke = "stroke-amber-500"; - - return ( -
- - - - - {percentage}% -
- ); -} - -// ─── Status Badge ───────────────────────────────────────────────────────────── - -function StatusBadge({ status }: { status: string }) { - const s = status.trim().toLowerCase(); - const isPresent = s === "present" || s === "p" || s === "1"; - const isAbsent = s === "absent" || s === "a" || s === "0"; - const isOd = s.includes("od") || s.includes("duty") || s === "on duty"; - - if (isPresent) { - return ( - - - Present - - ); - } - if (isAbsent) { - return ( - - - Absent - - ); - } - if (isOd) { - return ( - - OD - {status} - - ); - } - return ( - - - {status} - - ); -} - -// ─── Skeleton ───────────────────────────────────────────────────────────────── - -function Sk({ className = "" }: { className?: string }) { - return
; -} - -function DetailSkeleton() { - return ( -
-
- -
-
-
- - - -
- -
- -
- - -
-
- ); -} - -// ─── Main Page ──────────────────────────────────────────────────────────────── - -export default function AttendanceDetailPage() { - const { classId } = useParams("/dashboard/attendance/:classId"); - const location = useLocation(); - const navigate = useNavigate(); - - const [record, setRecord] = useState(location.state?.record); - const [details, setDetails] = useState([]); - const [loading, setLoading] = useState(true); - const [, setError] = useState(null); - - useEffect(() => { - if (!record && classId) { - const cached = localStorage.getItem("deskly::cache::attendance"); - if (cached) { - try { - const parsed = JSON.parse(cached) as AttendanceRecord[]; - const found = parsed.find((r) => String(r.classId) === String(classId)); - if (found) { - setRecord(found); - } - } catch (e) { - console.error("Failed to parse cached attendance for fallback", e); - } - } - } - }, [classId, record]); - - useEffect(() => { - if (!classId || !record) return; - - async function load() { - try { - const res = await getAttendanceDetail(classId!, record!.slot); - if (res.success && res.data) { - setDetails(res.data); - } else { - setError(res.error ?? "Failed to load attendance details."); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - } - - load(); - }, [classId, record]); - - if (loading && !record) { - return ; - } - - if (!record) { - return ( -
-

No course data found.

- -
- ); - } - - const isLab = record.courseType.toLowerCase().includes("lab"); - const displayType = isLab ? "Lab Only" : "Theory Only"; - const badgeStyle = isLab - ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/10" - : "bg-sky-500/10 text-sky-400 border border-sky-500/10"; - - // Calculate detailed fractions from logs - - const odSlots = details.filter((d) => { - const s = d.status.trim().toLowerCase(); - return s.includes("od") || s.includes("duty") || s === "on duty"; - }).length; - - const totalClasses = record.totalClasses; - const attendedClasses = record.attendedClasses; - const absentClasses = totalClasses - attendedClasses; - - return ( -
- {/* Google Font Saira Injection */} - - - {/* ── Header Row (Back Chevron + Title) ─────────────────────────────────── */} -
- -
- - {/* ── Course Hero Block ─────────────────────────────────────────────────── */} -
-
-
- - {record.courseCode} - - - {displayType} - -
-

- {record.courseTitle} -

-
- {record.faculty?.name && ( - - - {record.faculty.name} - - )} - {record.faculty?.school && ( - <> - | - - - {record.faculty.school} - - - )} -
-
- - {/* Attendance circular progress */} -
- -

- Attendance -

-
-
- - {/* ── 2x2 Grid Stats Card ───────────────────────────────────────────────── */} -
- {/* Inner vertical separator line */} -
- {/* Inner horizontal separator line */} -
- - {/* cell 1: Total Classes */} -
-
- - Total Classes -
- {totalClasses} -
- - {/* cell 2: Present */} -
-
- - Present -
- {attendedClasses} -
- - {/* cell 3: On Leave */} -
-
- - On Leave -
- {odSlots} -
- - {/* cell 4: Absent */} -
-
- - Absent -
- {absentClasses} -
-
- - {/* ── Session Log Section ───────────────────────────────────────────────── */} -
-
-

- Session Log -

-

- {details.length || record.totalClasses} sessions recorded -

-
- - {loading ? ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ) : details.length === 0 ? ( -
- -

No session logs available

-

Detailed logs haven't been synchronized.

-
- ) : ( -
- {details.map((row, i) => ( -
- {/* Left: Serial Number */} - - {row.serialNo ?? i + 1} - - - {/* Middle: Date and Time Range */} -
-

- {row.date} -

-

- {row.dayAndTime || "10:30 AM – 11:30 AM"} -

-
- - {/* Slot Code (in blue) */} - - {row.slot || record.slot} - - - {/* Right: Status badge */} -
- -
-
- ))} -
- )} -
-
- ); -} diff --git a/src/pages-desktop/dashboard/attendance/[classId].tsx b/src/pages-desktop/dashboard/attendance/[classId].tsx index 7b00a14..6ae0e86 100644 --- a/src/pages-desktop/dashboard/attendance/[classId].tsx +++ b/src/pages-desktop/dashboard/attendance/[classId].tsx @@ -1,11 +1,12 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } 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 { OfflineDisplay } from "@/components/offline-display"; import { useOnlineStatus } from "@/hooks/use-online-status"; import { ErrorDisplay } from "@/components/error-display"; +import { useOfflineData } from "@/hooks/use-offline-data"; import { ArrowLeft, User, @@ -164,21 +165,6 @@ export default function AttendanceDetailPage() { const navigate = useNavigate(); 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) { @@ -197,37 +183,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: detailsRaw, + loading, + error, + retry: load, + } = useOfflineData({ + cacheKey: classId ? `deskly::cache::attendance_detail_${classId}` : "", + fetcher: () => getAttendanceDetail(classId!, record!.slot), + enabled: !!classId && !!record, + }); - useEffect(() => { - load(); - }, [classId, record]); + const details = useMemo(() => detailsRaw || [], [detailsRaw]); + const isRetrying = loading && details.length > 0; const isLab = record?.courseType.trim().toUpperCase().includes("LAB"); const multiplier = isLab ? 2 : 1; @@ -432,7 +400,7 @@ export default function AttendanceDetailPage() {

Session logs unavailable offline

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