From 575a180f5a45f5f70fa704d9b3c6f4037ae0ab15 Mon Sep 17 00:00:00 2001 From: Noel Georgi Date: Tue, 31 Mar 2026 15:19:53 +0530 Subject: [PATCH] Revert "feat(dashboard): add Bunkx shortcut in overflow menu" --- .../src/app/api/bunkx/session/[sid]/route.ts | 31 --- .../src/app/api/bunkx/session/route.ts | 58 ----- bunkialo-landing/src/app/bunkialo/page.tsx | 85 ------- bunkialo-landing/src/lib/bunkx-payload.ts | 68 ----- .../src/lib/bunkx-session-store.ts | 73 ------ src/app/(tabs)/index.tsx | 238 ++++++++---------- src/services/bunkx-session.ts | 64 ----- src/types/attendance.ts | 18 -- src/utils/bunkx-payload.ts | 188 -------------- 9 files changed, 98 insertions(+), 725 deletions(-) delete mode 100644 bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts delete mode 100644 bunkialo-landing/src/app/api/bunkx/session/route.ts delete mode 100644 bunkialo-landing/src/app/bunkialo/page.tsx delete mode 100644 bunkialo-landing/src/lib/bunkx-payload.ts delete mode 100644 bunkialo-landing/src/lib/bunkx-session-store.ts delete mode 100644 src/services/bunkx-session.ts delete mode 100644 src/utils/bunkx-payload.ts diff --git a/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts b/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts deleted file mode 100644 index 7b0581e..0000000 --- a/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { consumeSession } from "@/lib/bunkx-session-store"; -import { NextResponse } from "next/server"; - -export async function GET( - _request: Request, - context: { params: Promise<{ sid: string }> }, -) { - const params = await context.params; - const payload = consumeSession(params.sid); - - if (!payload) { - return NextResponse.json( - { - error: "Session not found or expired", - }, - { - status: 404, - headers: { - "Cache-Control": "no-store", - }, - }, - ); - } - - return NextResponse.json(payload, { - status: 200, - headers: { - "Cache-Control": "no-store", - }, - }); -} diff --git a/bunkialo-landing/src/app/api/bunkx/session/route.ts b/bunkialo-landing/src/app/api/bunkx/session/route.ts deleted file mode 100644 index 2190f5c..0000000 --- a/bunkialo-landing/src/app/api/bunkx/session/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { parseBunkxAttendancePayload } from "@/lib/bunkx-payload"; -import { createSession } from "@/lib/bunkx-session-store"; -import { NextResponse } from "next/server"; - -const toSafeErrorMessage = (error: unknown): string => { - if (!(error instanceof Error)) { - return "Invalid request payload"; - } - - const knownValidationMessages = new Set([ - "Payload must be an object", - "attendance_rows must be an array", - "Invalid attendance row", - "Invalid period_date", - "Invalid session_time", - "Invalid course_code", - "Invalid subject_name", - "Invalid faculty", - "Invalid faculty_email", - "Invalid score", - ]); - - if (knownValidationMessages.has(error.message)) { - return error.message; - } - - return "An unexpected error occurred"; -}; - -export async function POST(request: Request) { - try { - const payload = parseBunkxAttendancePayload( - (await request.json()) as unknown, - ); - const session = createSession(payload); - - return NextResponse.json(session, { - status: 201, - headers: { - "Cache-Control": "no-store", - }, - }); - } catch (error) { - const message = toSafeErrorMessage(error); - - return NextResponse.json( - { - error: message, - }, - { - status: 400, - headers: { - "Cache-Control": "no-store", - }, - }, - ); - } -} diff --git a/bunkialo-landing/src/app/bunkialo/page.tsx b/bunkialo-landing/src/app/bunkialo/page.tsx deleted file mode 100644 index 9e11a89..0000000 --- a/bunkialo-landing/src/app/bunkialo/page.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { BunkxAttendancePayload } from "@/lib/bunkx-payload"; -import { headers } from "next/headers"; - -interface BunkialoPageProps { - searchParams: Promise<{ sid?: string }>; -} - -export default async function BunkialoPage({ - searchParams, -}: BunkialoPageProps) { - const params = await searchParams; - const sid = params.sid?.trim(); - - if (!sid) { - return ( -
-

Missing session id

-

- Open this page from the app so attendance can be transferred securely. -

-
- ); - } - - const requestHeaders = await headers(); - const host = - requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); - const proto = requestHeaders.get("x-forwarded-proto") ?? "https"; - const fallbackHost = process.env.DEFAULT_HOST?.trim(); - - if (!host && !fallbackHost) { - return ( -
-

Session unavailable

-

- Unable to resolve server host. Please try again in a moment. -

-
- ); - } - - const resolvedHost = host ?? fallbackHost!; - const apiUrl = `${proto}://${resolvedHost}/api/bunkx/session/${encodeURIComponent(sid)}`; - - const response = await fetch(apiUrl, { - cache: "no-store", - }); - - let payload: BunkxAttendancePayload | null = null; - if (response.ok) { - try { - payload = (await response.json()) as BunkxAttendancePayload; - } catch { - payload = null; - } - } - - if (!payload) { - return ( -
-

Session unavailable

-

- This session has expired or was already used. Please launch Bunkx - again from the app. -

-
- ); - } - - const attendanceCount = Array.isArray(payload.attendance_rows) - ? payload.attendance_rows.length - : 0; - - return ( -
-

Bunkialo attendance handoff

-

- Received {attendanceCount} records from the mobile app. -

-
-        {JSON.stringify(payload, null, 2)}
-      
-
- ); -} diff --git a/bunkialo-landing/src/lib/bunkx-payload.ts b/bunkialo-landing/src/lib/bunkx-payload.ts deleted file mode 100644 index f9048fd..0000000 --- a/bunkialo-landing/src/lib/bunkx-payload.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface BunkxAttendanceRow { - period_date: string; - session_time: string; - course_code: string; - subject_name: string; - faculty: string; - faculty_email: string; - course?: string; - score: string; - record_id?: string; -} - -export interface BunkxAttendancePayload { - attendance_rows: BunkxAttendanceRow[]; - dataset_id?: string; - dataset_expires_at?: string; -} - -const parseString = (value: unknown, field: string): string => { - if (typeof value !== "string") { - throw new Error(`Invalid ${field}`); - } - return value; -}; - -const parseOptionalString = (value: unknown): string | undefined => { - return typeof value === "string" ? value : undefined; -}; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const parseRow = (value: unknown): BunkxAttendanceRow => { - if (!isRecord(value)) { - throw new Error("Invalid attendance row"); - } - - return { - period_date: parseString(value.period_date, "period_date"), - session_time: parseString(value.session_time, "session_time"), - course_code: parseString(value.course_code, "course_code"), - subject_name: parseString(value.subject_name, "subject_name"), - faculty: parseString(value.faculty, "faculty"), - faculty_email: parseString(value.faculty_email, "faculty_email"), - course: parseOptionalString(value.course), - score: parseString(value.score, "score"), - record_id: parseOptionalString(value.record_id), - }; -}; - -export const parseBunkxAttendancePayload = ( - value: unknown, -): BunkxAttendancePayload => { - if (!isRecord(value)) { - throw new Error("Payload must be an object"); - } - - const rows = value.attendance_rows; - if (!Array.isArray(rows)) { - throw new Error("attendance_rows must be an array"); - } - - return { - attendance_rows: rows.map(parseRow), - dataset_id: parseOptionalString(value.dataset_id), - dataset_expires_at: parseOptionalString(value.dataset_expires_at), - }; -}; diff --git a/bunkialo-landing/src/lib/bunkx-session-store.ts b/bunkialo-landing/src/lib/bunkx-session-store.ts deleted file mode 100644 index e9ca8fe..0000000 --- a/bunkialo-landing/src/lib/bunkx-session-store.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { BunkxAttendancePayload } from "@/lib/bunkx-payload"; - -interface SessionRecord { - payload: BunkxAttendancePayload; - expiresAtMs: number; -} - -interface CreatedSession { - sid: string; - expiresAt: string; -} - -const DEFAULT_TTL_MS = 10 * 60 * 1000; -const sessionStore = new Map(); - -const cleanupExpiredSessions = (): void => { - const now = Date.now(); - for (const [sid, record] of sessionStore.entries()) { - if (record.expiresAtMs <= now) { - sessionStore.delete(sid); - } - } -}; - -const generateSessionId = (): string => { - return crypto.randomUUID(); -}; - -const generateUniqueSessionId = (): string => { - const maxAttempts = 8; - - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const sid = generateSessionId(); - if (!sessionStore.has(sid)) { - return sid; - } - } - - throw new Error("Could not generate unique session id"); -}; - -export const createSession = ( - payload: BunkxAttendancePayload, - ttlMs: number = DEFAULT_TTL_MS, -): CreatedSession => { - cleanupExpiredSessions(); - - const now = Date.now(); - const expiresAtMs = now + Math.max(60_000, ttlMs); - const sid = generateUniqueSessionId(); - - sessionStore.set(sid, { - payload, - expiresAtMs, - }); - - return { - sid, - expiresAt: new Date(expiresAtMs).toISOString(), - }; -}; - -export const consumeSession = (sid: string): BunkxAttendancePayload | null => { - cleanupExpiredSessions(); - - const existing = sessionStore.get(sid); - if (!existing) { - return null; - } - - sessionStore.delete(sid); - return existing.payload; -}; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index b8512e0..3421694 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,25 +1,22 @@ import { startBackgroundRefresh } from "@/background/dashboard-background"; -import { Toast } from "@/components"; import { EventCard } from "@/components/dashboard/event-card"; -import { NoticePopup } from "@/components/dashboard/notice-popup"; -import { NoticesModal } from "@/components/dashboard/notices-modal"; import { TimelineSection } from "@/components/dashboard/timeline-section"; import { UpNextSection } from "@/components/dashboard/up-next-section"; +import { NoticePopup } from "@/components/dashboard/notice-popup"; +import { NoticesModal } from "@/components/dashboard/notices-modal"; import { DevInfoModal } from "@/components/modals/dev-info-modal"; import { Container } from "@/components/ui/container"; -import { Colors } from "@/constants/theme"; import { POPUP_NOTICES } from "@/data/popups"; +import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; -import { createBunkxSession } from "@/services/bunkx-session"; -import { useAttendanceStore } from "@/stores/attendance-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useAttendanceStore } from "@/stores/attendance-store"; import { useDashboardStore } from "@/stores/dashboard-store"; import { useLmsResourcesStore } from "@/stores/lms-resources-store"; -import { usePopupStore } from "@/stores/popup-store"; import { useSettingsStore } from "@/stores/settings-store"; -import { buildBunkxAttendancePayload } from "@/utils/bunkx-payload"; -import { initializeNotifications } from "@/utils/notifications"; +import { usePopupStore } from "@/stores/popup-store"; import { scheduleIdleTask } from "@/utils/scheduling"; +import { initializeNotifications } from "@/utils/notifications"; import { Ionicons } from "@expo/vector-icons"; import { useIsFocused } from "@react-navigation/native"; import { router, useFocusEffect } from "expo-router"; @@ -65,13 +62,11 @@ export default function DashboardScreen() { hasHydrated, } = useDashboardStore(); const fetchAttendance = useAttendanceStore((state) => state.fetchAttendance); - const attendanceCourses = useAttendanceStore((state) => state.courses); - const attendanceLastSyncTime = useAttendanceStore( - (state) => state.lastSyncTime, - ); const { isOffline, setOffline, username } = useAuthStore(); - const { hasHydrated: resourcesHydrated, prefetchEnrolledCourseResources } = - useLmsResourcesStore(); + const { + hasHydrated: resourcesHydrated, + prefetchEnrolledCourseResources, + } = useLmsResourcesStore(); const refreshIntervalMinutes = useSettingsStore( (state) => state.refreshIntervalMinutes, ); @@ -97,14 +92,11 @@ export default function DashboardScreen() { isAttendanceRefreshQueued.current = true; const interactionTask = InteractionManager.runAfterInteractions(() => { - const cancelIdleTask = scheduleIdleTask( - () => { - void fetchAttendance({ background: true }).finally(() => { - isAttendanceRefreshQueued.current = false; - }); - }, - { timeoutMs: 1500, fallbackDelayMs: 120 }, - ); + const cancelIdleTask = scheduleIdleTask(() => { + void fetchAttendance({ background: true }).finally(() => { + isAttendanceRefreshQueued.current = false; + }); + }, { timeoutMs: 1500, fallbackDelayMs: 120 }); return cancelIdleTask; }); @@ -206,9 +198,8 @@ export default function DashboardScreen() { lastSyncTime !== null && Date.now() - lastSyncTime > staleAfterMs; - let task: ReturnType< - typeof InteractionManager.runAfterInteractions - > | null = null; + let task: ReturnType | null = + null; if (shouldRefreshOnFocus) { task = InteractionManager.runAfterInteractions(() => { @@ -248,25 +239,6 @@ export default function DashboardScreen() { })(); }, [fetchDashboard, queueInvisibleAttendanceRefresh]); - const handleOpenBunkx = useCallback(() => { - setShowFabMenu(false); - - void (async () => { - try { - const payload = buildBunkxAttendancePayload( - attendanceCourses, - attendanceLastSyncTime, - ); - - const launchUrl = await createBunkxSession(payload); - await Linking.openURL(launchUrl); - } catch (error) { - const errorMessage = - error instanceof Error && error.message ? ` (${error.message})` : ""; - Toast.show(`Could not open Bunkx${errorMessage}`, { type: "error" }); - } - })(); - }, [attendanceCourses, attendanceLastSyncTime]); const hasOverdue = overdueEvents.length > 0; const isEmpty = upcomingEvents.length === 0 && overdueEvents.length === 0; const isHydratingFromCache = !hasHydrated && isEmpty; @@ -283,10 +255,10 @@ export default function DashboardScreen() { paddingHorizontal: 8, paddingVertical: 4, }; - const themeIconName = isDark ? "moon-outline" : "sunny-outline"; + const themeIconName = + isDark ? "moon-outline" : "sunny-outline"; - const isOldBatch = - username?.startsWith("2022") || username?.startsWith("2023"); + const isOldBatch = username?.startsWith("2022") || username?.startsWith("2023"); const fabActions = [ { @@ -313,82 +285,73 @@ export default function DashboardScreen() { router.push("/gpa"); }, }, - { - icon: "open-in-new", - label: "Bunkx", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: handleOpenBunkx, - }, ...(isOldBatch ? [ - { - icon: "open-in-new", - label: "Outpass-RFID", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: () => { - setShowFabMenu(false); - Linking.openURL("https://outpass.iiitkottayam.ac.in/app"); - }, + { + icon: "open-in-new", + label: "Outpass-RFID", + color: theme.text, + style: { backgroundColor: theme.backgroundSecondary }, + labelStyle: actionLabelStyle, + containerStyle: actionContainerStyle, + onPress: () => { + setShowFabMenu(false); + Linking.openURL("https://outpass.iiitkottayam.ac.in/app"); }, - { - icon: "open-in-new", - label: "Outpass-fingerprint", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: () => { - setShowFabMenu(false); - Linking.openURL( - "https://gatepassstud.iiitkottayam.ac.in/index.php", - ); - }, + }, + { + icon: "open-in-new", + label: "Outpass-fingerprint", + color: theme.text, + style: { backgroundColor: theme.backgroundSecondary }, + labelStyle: actionLabelStyle, + containerStyle: actionContainerStyle, + onPress: () => { + setShowFabMenu(false); + Linking.openURL( + "https://gatepassstud.iiitkottayam.ac.in/index.php", + ); }, - { - icon: "food", - label: "Feaston", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: () => { - setShowFabMenu(false); - Linking.openURL("https://feaston.iiitkottayam.ac.in/dashboard"); - }, + }, + { + icon: "food", + label: "Feaston", + color: theme.text, + style: { backgroundColor: theme.backgroundSecondary }, + labelStyle: actionLabelStyle, + containerStyle: actionContainerStyle, + onPress: () => { + setShowFabMenu(false); + Linking.openURL("https://feaston.iiitkottayam.ac.in/dashboard"); }, - ] + }, + ] : [ - { - icon: "open-in-new", - label: "Outpass", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: () => { - setShowFabMenu(false); - Linking.openURL("https://outpass.iiitkottayam.ac.in/app"); - }, + { + icon: "open-in-new", + label: "Outpass", + color: theme.text, + style: { backgroundColor: theme.backgroundSecondary }, + labelStyle: actionLabelStyle, + containerStyle: actionContainerStyle, + onPress: () => { + setShowFabMenu(false); + Linking.openURL("https://outpass.iiitkottayam.ac.in/app"); }, - { - icon: "food", - label: "Feaston", - color: theme.text, - style: { backgroundColor: theme.backgroundSecondary }, - labelStyle: actionLabelStyle, - containerStyle: actionContainerStyle, - onPress: () => { - setShowFabMenu(false); - Linking.openURL("https://feaston.iiitkottayam.ac.in/dashboard"); - }, + }, + { + icon: "food", + label: "Feaston", + color: theme.text, + style: { backgroundColor: theme.backgroundSecondary }, + labelStyle: actionLabelStyle, + containerStyle: actionContainerStyle, + onPress: () => { + setShowFabMenu(false); + Linking.openURL("https://feaston.iiitkottayam.ac.in/dashboard"); }, - ]), + }, + ]), { icon: "calendar-month", label: "Academic Calendar", @@ -418,17 +381,16 @@ export default function DashboardScreen() { {/* Header */} - + Dashboard {lastSyncTime && ( {hasUnseenPopups && ( - + )} - setShowDevInfo(true)} className="p-2"> + setShowDevInfo(true)} + className="p-2" + > - router.push("/settings")} className="p-2"> + router.push("/settings")} + className="p-2" + > - - {overdueEvents.length} overdue task - {overdueEvents.length > 1 ? "s" : ""} + + {overdueEvents.length} overdue task{overdueEvents.length > 1 ? "s" : ""} - + Upcoming @@ -585,9 +543,9 @@ export default function DashboardScreen() { onClose={() => setShowDevInfo(false)} /> - setShowNoticesModal(false)} + setShowNoticesModal(false)} /> diff --git a/src/services/bunkx-session.ts b/src/services/bunkx-session.ts deleted file mode 100644 index c5bb54f..0000000 --- a/src/services/bunkx-session.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BunkxAttendancePayload } from "@/types"; - -const BUNKX_BASE_URL = "https://bunkx-iiitk.vercel.app"; -const SESSION_CREATE_ENDPOINT = `${BUNKX_BASE_URL}/api/bunkx/session`; - -interface SessionCreateResponse { - sid: string; - expiresAt?: string; -} - -const parseSessionCreateResponse = (value: unknown): SessionCreateResponse => { - if (!value || typeof value !== "object") { - throw new Error("Invalid Bunkx session response"); - } - - const sidValue = (value as { sid?: unknown }).sid; - const expiresAtValue = (value as { expiresAt?: unknown }).expiresAt; - - if (!sidValue || typeof sidValue !== "string") { - throw new Error("Missing session id in Bunkx response"); - } - - return { - sid: sidValue, - expiresAt: typeof expiresAtValue === "string" ? expiresAtValue : undefined, - }; -}; - -export const createBunkxSession = async ( - payload: BunkxAttendancePayload, -): Promise => { - const abortController = new AbortController(); - const timeoutId = setTimeout(() => { - abortController.abort(); - }, 15000); - - try { - const response = await fetch(SESSION_CREATE_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Session creation failed (${response.status})`); - } - - const parsed = parseSessionCreateResponse( - (await response.json()) as unknown, - ); - - return `${BUNKX_BASE_URL}/bunkialo?sid=${encodeURIComponent(parsed.sid)}`; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error("Session request timed out"); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -}; diff --git a/src/types/attendance.ts b/src/types/attendance.ts index 472862b..c4b9a2c 100644 --- a/src/types/attendance.ts +++ b/src/types/attendance.ts @@ -58,21 +58,3 @@ export interface CourseStats { totalAttended: number; overallPercentage: number; } - -export interface BunkxAttendanceRow { - period_date: string; - session_time: string; - course_code: string; - subject_name: string; - faculty: string; - faculty_email: string; - course?: string; - score: string; - record_id?: string; -} - -export interface BunkxAttendancePayload { - attendance_rows: BunkxAttendanceRow[]; - dataset_id?: string; - dataset_expires_at?: string; -} diff --git a/src/utils/bunkx-payload.ts b/src/utils/bunkx-payload.ts deleted file mode 100644 index bacc638..0000000 --- a/src/utils/bunkx-payload.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { - BunkxAttendancePayload, - BunkxAttendanceRow, - CourseAttendance, -} from "@/types"; -import { extractCourseCode, extractCourseName } from "@/utils/course-name"; - -const MONTHS: Record = { - jan: 0, - feb: 1, - mar: 2, - apr: 3, - may: 4, - jun: 5, - jul: 6, - aug: 7, - sep: 8, - oct: 9, - nov: 10, - dec: 11, -}; - -const pad2 = (value: number): string => (value < 10 ? `0${value}` : `${value}`); - -const toIsoDate = (date: Date): string => { - const year = date.getFullYear(); - const month = pad2(date.getMonth() + 1); - const day = pad2(date.getDate()); - return `${year}-${month}-${day}`; -}; - -const parsePeriodDate = (rawDate: string, fallbackMs: number): string => { - const dateMatch = rawDate.match(/(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})/); - if (!dateMatch) { - return toIsoDate(new Date(fallbackMs)); - } - - const day = Number(dateMatch[1]); - const month = MONTHS[dateMatch[2].toLowerCase()]; - const year = Number(dateMatch[3]); - if (month === undefined) { - return toIsoDate(new Date(fallbackMs)); - } - - const parsed = new Date(year, month, day); - if (isNaN(parsed.getTime())) { - return toIsoDate(new Date(fallbackMs)); - } - - if ( - parsed.getFullYear() !== year || - parsed.getMonth() !== month || - parsed.getDate() !== day - ) { - return toIsoDate(new Date(fallbackMs)); - } - - return toIsoDate(parsed); -}; - -const parseSessionTime = (rawDate: string): string => { - const timeMatch = rawDate.match( - /(\d{1,2}(?::\d{2})?\s*(?:AM|PM))\s*-\s*(\d{1,2}(?::\d{2})?\s*(?:AM|PM))/i, - ); - if (!timeMatch) { - return ""; - } - - const start = timeMatch[1].replace(/\s+/g, " ").trim().toUpperCase(); - const end = timeMatch[2].replace(/\s+/g, " ").trim().toUpperCase(); - return `${start} - ${end}`; -}; - -const parseFacultyDetails = ( - description: string, - remarks?: string, -): { faculty: string; faculty_email: string } => { - const source = `${description} ${remarks ?? ""}`.trim(); - - const facultyLabelMatch = source.match( - /(?:faculty|teacher|staff|by)\s*[:\-]\s*([^,;|]+)/i, - ); - const facultyFromLabel = facultyLabelMatch?.[1]?.trim(); - - return { - faculty: facultyFromLabel || "Unknown", - faculty_email: "", - }; -}; - -const normalizeScore = (points: string, status: string): string => { - const compact = points.replace(/\s+/g, "").trim(); - if (compact) { - if (compact.indexOf("?") !== -1) return "?/1"; - return compact; - } - - if (status === "Present") return "1/1"; - if (status === "Unknown") return "?/1"; - return "0/1"; -}; - -const toAttendanceRows = ( - courses: CourseAttendance[], - fallbackMs: number, -): BunkxAttendanceRow[] => { - const rows: BunkxAttendanceRow[] = []; - - courses.forEach((course: CourseAttendance) => { - const course_code = extractCourseCode(course.courseName); - const subject_name = extractCourseName(course.courseName); - const mergedCourseName = `${course_code} ${subject_name}`.trim(); - - course.records.forEach((record, recordIndex) => { - const { faculty, faculty_email } = parseFacultyDetails( - record.description, - record.remarks, - ); - - rows.push({ - period_date: parsePeriodDate(record.date, fallbackMs), - session_time: parseSessionTime(record.date), - course_code, - subject_name, - faculty, - faculty_email, - course: mergedCourseName, - score: normalizeScore(record.points, record.status), - record_id: `${course.courseId}-${recordIndex + 1}`, - }); - }); - }); - return rows; -}; - -export const buildBunkxAttendancePayload = ( - courses: CourseAttendance[], - lastSyncTime: number | null, -): BunkxAttendancePayload => { - const nowMs = Date.now(); - const referenceMs = Math.max(nowMs, lastSyncTime ?? nowMs); - const expiresAtMs = referenceMs + 30 * 60 * 1000; - - return { - attendance_rows: toAttendanceRows(courses, referenceMs), - dataset_id: `bunkialo-${referenceMs}`, - dataset_expires_at: new Date(expiresAtMs).toISOString(), - }; -}; - -const encodeBytesToBase64 = (bytes: number[]): string => { - const base64Chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let encoded = ""; - - for (let i = 0; i < bytes.length; i += 3) { - const chunk = - (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); - - encoded += base64Chars[(chunk >> 18) & 63]; - encoded += base64Chars[(chunk >> 12) & 63]; - encoded += i + 1 < bytes.length ? base64Chars[(chunk >> 6) & 63] : "="; - encoded += i + 2 < bytes.length ? base64Chars[chunk & 63] : "="; - } - - return encoded; -}; - -export const encodeBunkxPayload = (payload: BunkxAttendancePayload): string => { - const json = JSON.stringify(payload); - const uriEncoded = encodeURIComponent(json); - const bytes: number[] = []; - - let index = 0; - while (index < uriEncoded.length) { - const char = uriEncoded.charAt(index); - if (char === "%" && index + 2 < uriEncoded.length) { - bytes.push(parseInt(uriEncoded.slice(index + 1, index + 3), 16)); - index += 3; - continue; - } - - bytes.push(uriEncoded.charCodeAt(index)); - index += 1; - } - - return encodeBytesToBase64(bytes); -};