diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 407ebb5f..7aae00ae 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,7 +4,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04@sha256:81380e4c9c14e8a629 USER root # Copy pinned artifacts so they can be used as defaults when build-args are not provided -COPY .devcontainer/pinned-artifacts.json /tmp/pinned-artifacts.json +COPY pinned-artifacts.json /tmp/pinned-artifacts.json # 1. System Dependencies, Java 17, and SSH Setup RUN apt-get update && apt-get install -y \ diff --git a/.example.env b/.example.env index 5c3ac5ee..c13e7fe6 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.4.5 +NEXT_PUBLIC_APP_VERSION=4.4.6 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -111,6 +111,12 @@ NEXT_PUBLIC_SUPABASE_URL= # 🔨 Build-time (Infisical `/build-time` folder — optional) NEXT_PUBLIC_SUPABASE_DEV_URL= +# â„šī¸ Supabase browser proxy — Development override (optional) +# Used by the browser client and CSP checks in local development when you want +# to point the app at a different proxy target than production. +# 🔨 Build-time (Infisical `/build-time` folder — optional) +NEXT_PUBLIC_SUPABASE_DEV_PROXY_URL= + # â„šī¸ Supabase browser proxy — Tier 1: Cloudflare Worker (ISP bypass, optional) # Deploy workers/supabase-proxy/index.js as a CF Worker, then set this to the # worker URL. The browser client tries CF first, falls back to AWS (Tier 2), @@ -359,7 +365,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.4.5 +MIN_APP_VERSION=4.4.6 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index d60258b4..0d13629d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:debuggable="false" + android:enableOnBackInvokedCallback="false" tools:ignore="HardcodedDebugMode"> { - result.success(false) + result.success(isCurrentWindowObscured) } else -> { result.notImplemented() @@ -42,3 +56,4 @@ class MainActivity : FlutterActivity() { } } } + diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index a4ea7bbe..10c700d4 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -43,7 +43,8 @@ class AppConfig { /// The Supabase Origin used to bypass "Forbidden: missing Origin header" errors. /// Spoofed to match the official app domain. - static String get supabaseOrigin => webUrl; + static String get supabaseOrigin => + AppSecrets.isDev ? 'https://localhost:3000' : webUrl; // ─── Backend & Bridge Config ─────────────────────────────────────────────── @@ -75,7 +76,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.5'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.6'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => @@ -188,6 +189,9 @@ class AppConfig { return decoded; } on Object { + if (!AppSecrets.isDev) { + assert(false, 'AppConfig._d: value appears to be unencoded: $encoded'); + } // Return the raw string as fallback if it's not actually base64 // This allows migration and reduces breakage for unencoded dev strings final fallback = encoded.trim(); diff --git a/mobile/lib/logic/attendance_utils.dart b/mobile/lib/logic/attendance_utils.dart index f73172f6..70e42805 100644 --- a/mobile/lib/logic/attendance_utils.dart +++ b/mobile/lib/logic/attendance_utils.dart @@ -93,74 +93,14 @@ String normalizeDate(dynamic date) { // 2. Dash-separated (YYYY-MM-DD or DD-MM-YYYY) if (base.contains('-')) { - final parts = base.split('-'); - if (parts.length == 3) { - final a = parts[0].trim(); - final b = parts[1].trim(); - final c = parts[2].trim(); - - if (RegExp(r'^\d+$').hasMatch(a) && - RegExp(r'^\d+$').hasMatch(b) && - RegExp(r'^\d+$').hasMatch(c)) { - var year = (a.length == 4) ? int.parse(a) : int.parse(c); - if (year < 100) year += 2000; - final month = int.parse(b); - final day = (a.length == 4) ? int.parse(c) : int.parse(a); - - if (month < 1 || month > 12 || day < 1 || day > 31) return ''; - - final parsed = DateTime.tryParse( - "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", - ); - if (parsed == null || - parsed.year != year || - parsed.month != month || - parsed.day != day) { - return ''; - } - - final yearStr = year.toString().padLeft(4, '0'); - final monthStr = month.toString().padLeft(2, '0'); - final dayStr = day.toString().padLeft(2, '0'); - return '$yearStr$monthStr$dayStr'; - } - } + final parsedDate = _parseSeparatedDate(base, '-'); + if (parsedDate != null) return parsedDate; } // 3. Slash-separated (DD/MM/YYYY or YYYY/MM/DD) if (base.contains('/')) { - final parts = base.split('/'); - if (parts.length == 3) { - final a = parts[0].trim(); - final b = parts[1].trim(); - final c = parts[2].trim(); - - if (RegExp(r'^\d+$').hasMatch(a) && - RegExp(r'^\d+$').hasMatch(b) && - RegExp(r'^\d+$').hasMatch(c)) { - var year = (a.length == 4) ? int.parse(a) : int.parse(c); - if (year < 100) year += 2000; - final month = int.parse(b); - final day = (a.length == 4) ? int.parse(c) : int.parse(a); - - if (month < 1 || month > 12 || day < 1 || day > 31) return ''; - - final parsed = DateTime.tryParse( - "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", - ); - if (parsed == null || - parsed.year != year || - parsed.month != month || - parsed.day != day) { - return ''; - } - - final yearStr = year.toString().padLeft(4, '0'); - final monthStr = month.toString().padLeft(2, '0'); - final dayStr = day.toString().padLeft(2, '0'); - return '$yearStr$monthStr$dayStr'; - } - } + final parsedDate = _parseSeparatedDate(base, '/'); + if (parsedDate != null) return parsedDate; } AppLogger.e( @@ -170,6 +110,43 @@ String normalizeDate(dynamic date) { return ''; } +String? _parseSeparatedDate(String base, String sep) { + final parts = base.split(sep); + if (parts.length != 3) return null; + + final a = parts[0].trim(); + final b = parts[1].trim(); + final c = parts[2].trim(); + + if (!RegExp(r'^\d+$').hasMatch(a) || + !RegExp(r'^\d+$').hasMatch(b) || + !RegExp(r'^\d+$').hasMatch(c)) { + return null; + } + + var year = (a.length == 4) ? int.parse(a) : int.parse(c); + if (year < 100) year += 2000; + final month = int.parse(b); + final day = (a.length == 4) ? int.parse(c) : int.parse(a); + + if (month < 1 || month > 12 || day < 1 || day > 31) return ''; + + final parsed = DateTime.tryParse( + "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", + ); + if (parsed == null || + parsed.year != year || + parsed.month != month || + parsed.day != day) { + return ''; + } + + final yearStr = year.toString().padLeft(4, '0'); + final monthStr = month.toString().padLeft(2, '0'); + final dayStr = day.toString().padLeft(2, '0'); + return '$yearStr$monthStr$dayStr'; +} + /// Normalizes session identifiers (e.g., "1st Hour", "Session I") to a numeric string. String normalizeSession(dynamic session) { if (session == null) return ''; @@ -386,7 +363,12 @@ bool isValidCourseName(String text) { } String standardizeCourseCode(String input) { - return input.trim().toUpperCase().replaceAll(RegExp(r'[\s\u00A0-]'), ''); + return input + .trim() + .toUpperCase() + .replaceAll(RegExp(r'\s'), '') + .replaceAll('\u00A0', '') + .replaceAll('-', ''); } const Set remarkPlaceholders = { diff --git a/mobile/lib/logic/encrypted_value.dart b/mobile/lib/logic/encrypted_value.dart index 50392e5f..7d991b82 100644 --- a/mobile/lib/logic/encrypted_value.dart +++ b/mobile/lib/logic/encrypted_value.dart @@ -60,11 +60,12 @@ class EncryptedValue { /// Overwrite entropy buffers to reduce risk of key reconstruction after logout. /// Call this when the app is performing a full logout or memory wipe. static void clearEntropy() { + final random = Random.secure(); for (var i = 0; i < _entropyA.length; i++) { - _entropyA[i] = 0; + _entropyA[i] = random.nextInt(256); } for (var i = 0; i < _entropyB.length; i++) { - _entropyB[i] = 0; + _entropyB[i] = random.nextInt(256); } } diff --git a/mobile/lib/logic/error_handler.dart b/mobile/lib/logic/error_handler.dart index 626108e3..8e5ca278 100644 --- a/mobile/lib/logic/error_handler.dart +++ b/mobile/lib/logic/error_handler.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -53,7 +55,13 @@ mixin ErrorHandlerMixin on State { message: dialogMessage, technicalDetails: error.message, retryLabel: isCritical ? 'Close App' : 'Restart App', - onRetry: SystemNavigator.pop, + onRetry: () async { + if (Platform.isAndroid) { + await SystemNavigator.pop(); + } else { + exit(0); + } + }, ); return; } @@ -78,7 +86,13 @@ mixin ErrorHandlerMixin on State { technicalDetails: '${error.type.name.toUpperCase()}: ${error.message}', retryLabel: 'Restart App', - onRetry: SystemNavigator.pop, + onRetry: () async { + if (Platform.isAndroid) { + await SystemNavigator.pop(); + } else { + exit(0); + } + }, ); return; } diff --git a/mobile/lib/logic/ezygo_batch_fetcher.dart b/mobile/lib/logic/ezygo_batch_fetcher.dart index a73a25e3..e4395853 100644 --- a/mobile/lib/logic/ezygo_batch_fetcher.dart +++ b/mobile/lib/logic/ezygo_batch_fetcher.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:ghostclass/logic/app_exception.dart'; import 'package:ghostclass/services/logger.dart'; @@ -33,20 +34,26 @@ class EzygoBatchFetcher { static const Duration _cacheTtl = Duration(seconds: 60); // In-flight request map for deduplication - static final Map>> _inFlight = {}; + final Map>> _inFlight = {}; // Rate limiting (parity with Next.js MAX_CONCURRENT = 3) static const int _maxConcurrent = 3; - static int _activeRequests = 0; - static final List> _queue = []; + int _activeRequests = 0; + final List> _queue = []; // Local result cache - static final Map _cache = {}; + final Map _cache = {}; // Tracker for log throttling - static DateTime? _lastCircuitBreakerLog; + DateTime? _lastCircuitBreakerLog; - static int _generation = 0; + int _generation = 0; + + String _hashToken(String token) { + if (token.isEmpty) return ''; + final bytes = utf8.encode(token); + return sha256.convert(bytes).toString(); + } /// Executes an authenticated request with deduplication and caching. /// @@ -63,7 +70,8 @@ class EzygoBatchFetcher { // Generate a unique cache key based on the request identity // We include method, path, token, and a hash of the body data to avoid collisions. final dataKey = data != null ? _encodeStableRequestData(data) : ''; - final cacheKey = '$method|$path|$token|$dataKey'; + final hashedToken = _hashToken(token); + final cacheKey = '$method|$path|$hashedToken|$dataKey'; final startGeneration = _generation; // 0. Security Barrier: If the backend connection is compromised, block immediately. @@ -233,7 +241,7 @@ class EzygoBatchFetcher { } void _releaseSlot() { - _activeRequests--; + _activeRequests = (_activeRequests - 1).clamp(0, _maxConcurrent); if (_queue.isNotEmpty) { _activeRequests++; _queue.removeAt(0).complete(); @@ -267,10 +275,12 @@ class EzygoBatchFetcher { } /// Manually clears the local cache (e.g. on logout or manual refresh). - void clearAll() { + void clearAll({bool setOutageState = true}) { _cache.clear(); _inFlight.clear(); - _setOutage(false); + if (setOutageState) { + _setOutage(false); + } _generation++; // Reject all pending completers in the queue with a cancellation error diff --git a/mobile/lib/models/leave.dart b/mobile/lib/models/leave.dart index 8263e2f6..e72f6e0a 100644 --- a/mobile/lib/models/leave.dart +++ b/mobile/lib/models/leave.dart @@ -149,6 +149,7 @@ class UserSubgroup { class LeaveSession { LeaveSession({ required this.id, + required this.leaveId, required this.date, this.session, this.course, @@ -157,6 +158,9 @@ class LeaveSession { factory LeaveSession.fromJson(Map json) { return LeaveSession( id: int.parse(json['id'].toString()), + leaveId: json['student_leave_id'] != null + ? int.parse(json['student_leave_id'].toString()) + : 0, date: json['date'] as String, session: json['session'] != null ? Session.fromJson(json['session'] as Map) @@ -167,6 +171,7 @@ class LeaveSession { ); } final int id; + final int leaveId; final String date; final Session? session; final Course? course; diff --git a/mobile/lib/models/score.dart b/mobile/lib/models/score.dart index 66a625fb..df2c012d 100644 --- a/mobile/lib/models/score.dart +++ b/mobile/lib/models/score.dart @@ -47,6 +47,7 @@ class Exam { .toList(); final settingsMap = json['settings'] as Map? ?? {}; + final fromSettings = _toDouble(settingsMap['questionPaperMaximumMark']); return Exam( id: toInt(json['id']) ?? 0, @@ -60,9 +61,9 @@ class Exam { endsAt: json['end_at'] != null ? DateTime.tryParse(json['end_at'] as String) : null, - maximumMark: - _toDouble(json['maximum_mark']) ?? - _toDouble(settingsMap['questionPaperMaximumMark']), + maximumMark: (fromSettings != null && fromSettings != 0.0) + ? fromSettings + : _toDouble(json['maximum_mark']), apiScore: pivotScore, courses: coursesList, ); diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart index bf5435b2..91896db6 100644 --- a/mobile/lib/models/user.dart +++ b/mobile/lib/models/user.dart @@ -127,20 +127,21 @@ class UserProfile { classField == other.classField; @override - int get hashCode => - firstName.hashCode ^ - lastName.hashCode ^ - avatarUrl.hashCode ^ - email.hashCode ^ - phone.hashCode ^ - birthDate.hashCode ^ - gender.hashCode ^ - lastSyncedAt.hashCode ^ - currentSemester.hashCode ^ - currentYear.hashCode ^ - createdAt.hashCode ^ - ezygoCreatedAt.hashCode ^ - classField.hashCode; + int get hashCode => Object.hash( + firstName, + lastName, + avatarUrl, + email, + phone, + birthDate, + gender, + lastSyncedAt, + currentSemester, + currentYear, + createdAt, + ezygoCreatedAt, + classField, + ); Map toJson() => { 'first_name': firstName, @@ -273,12 +274,13 @@ class UserSettings { _mapsEqual(disabledCourses, other.disabledCourses); @override - int get hashCode => - bunkCalculatorEnabled.hashCode ^ - targetPercentage.hashCode ^ - semester.hashCode ^ - academicYear.hashCode ^ - disabledCourses.hashCode; + int get hashCode => Object.hash( + bunkCalculatorEnabled, + targetPercentage, + semester, + academicYear, + disabledCourses, + ); bool _mapsEqual(Map m1, Map m2) { if (m1.length != m2.length) return false; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 95826493..552c4032 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -562,11 +562,13 @@ class AuthNotifier extends AsyncNotifier ezygoToken: ezygoToken ?? '', ); - // Synchronously await critical profile sync to block authProvider initialization - // until server profile sync returns 200 successfully. - await _runBackgroundStartupHydration(user); + // Trigger profile sync in parallel without blocking startup/splash screen + AppLogger.safeUnawait( + _runBackgroundStartupHydration(user), + 'AuthNotifier: background startup hydration', + ); - return state.value ?? user; + return user.copyWith(isSyncing: true); } Future _runBackgroundStartupHydration( @@ -892,7 +894,8 @@ class AuthNotifier extends AsyncNotifier await ref.read(cacheManagerProvider).clearAllCaches(); ref ..invalidate(institutionsProvider) - ..invalidate(academicProvider); + ..invalidate(academicProvider) + ..invalidate(startupFlowServiceProvider); state = const AsyncValue.data(null); // Stop periodic refreshes and prevent in-flight refresh continuations @@ -1486,26 +1489,42 @@ class AuthNotifier extends AsyncNotifier ]; await Future.wait(saves); + final newSem = profile.currentSemester; + final newYear = profile.currentYear; + final newClassLabel = profile.classField?.name; + + final oldSem = currentUser.profile?.currentSemester; + final oldYear = currentUser.profile?.currentYear; + final oldClassLabel = currentUser.profile?.classField?.name; + + final classChanged = + oldClassLabel != null && oldClassLabel != newClassLabel; final academicChanged = - nextAcademic != null && - (currentUser.profile?.currentSemester != nextAcademic.semester || - currentUser.profile?.currentYear != nextAcademic.year); + (oldSem != null && oldSem != newSem) || + (oldYear != null && oldYear != newYear); - if (academicChanged) { + if (academicChanged || classChanged) { + AppLogger.i( + 'AuthNotifier: Academic context or class changed (sem: $oldSem->$newSem, year: $oldYear->$newYear, class: $oldClassLabel->$newClassLabel). ' + 'Purging caches and invalidating page providers.', + ); ref.read(apiServiceProvider).clearCaches(); - } + await storage.clearAllCachedData(); - if (nextAcademic != null) { - AppLogger.safeUnawait( - Future.delayed(Duration.zero, () { - ref.read(academicProvider.notifier).state = AsyncValue.data( - nextAcademic, - ); - }).catchError((Object e, StackTrace st) { - AppLogger.e('AuthNotifier: Deferred academic set failed', e, st); - }), - 'AuthNotifier: deferred academic set', - ); + ref.invalidate(academicProvider); + } else { + if (nextAcademic != null) { + AppLogger.safeUnawait( + Future.delayed(Duration.zero, () { + ref.read(academicProvider.notifier).state = AsyncValue.data( + nextAcademic, + ); + }).catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: Deferred academic set failed', e, st); + }), + 'AuthNotifier: deferred academic set', + ); + } } if (updateState) state = AsyncValue.data(mergedUser); diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 1d0e0b52..2c2b0be3 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -49,6 +49,7 @@ class DashboardNotifier extends AsyncNotifier { AttendanceReportDetailed? _cachedAttendance; List? _cachedInstructors; AcademicState? _lastAcademic; + AttendanceReportDetailed? _pendingTrackedAttendance; bool _needsRevalidate = true; @@ -57,11 +58,15 @@ class DashboardNotifier extends AsyncNotifier { // 1. Reactive Dependency: Rebuild when auth user ID or academic status changes final userAsync = ref.watch(authProvider); final academicAsync = ref.watch(academicProvider); + final trackingFuture = ref.watch(trackingProvider.future); // If either core dependency is actively reloading (e.g. changing semester), // suspend the dashboard build to prevent showing a split-second stale UI. if (userAsync.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (userAsync.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final user = userAsync.value; @@ -145,23 +150,8 @@ class DashboardNotifier extends AsyncNotifier { } } - // BLOCKER: Do not fire server queries until Cron Sync is finished - if (user.isSyncing) { - if (_cachedCourses != null && _cachedAttendance != null) { - return _processData( - _cachedCourses!, - _cachedAttendance!, - [], - academic, - _cachedInstructors ?? [], - ); - } - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - // 2. Wait for tracking data - final tracking = await ref.watch(trackingProvider.future); + final tracking = await trackingFuture; // Proactively update official report cache if tracking has a fresher one (e.g. from a sync) if (tracking.officialReport != null) { @@ -217,6 +207,8 @@ class DashboardNotifier extends AsyncNotifier { final storage = ref.read(secureStorageProvider); final classId = ref.read(authProvider).value?.profile?.classField?.id; + final attendanceToUse = trackedAttendance ?? _pendingTrackedAttendance; + _pendingTrackedAttendance = null; late final Response coursesResponse; late final AttendanceReportDetailed attendance; @@ -225,8 +217,8 @@ class DashboardNotifier extends AsyncNotifier { await Future.wait([ api.fetchCourses(storage).then((res) => coursesResponse = res), - (trackedAttendance != null - ? Future.value(trackedAttendance) + (attendanceToUse != null + ? Future.value(attendanceToUse) : _fetchAttendanceOnce(api: api, storage: storage)) .then((res) { if (res == null) throw Exception('No attendance data'); @@ -234,42 +226,32 @@ class DashboardNotifier extends AsyncNotifier { }), if (classId != null) ...[ // Fetch Class Courses - ref - .read(supabaseClientProvider) - .from('class_courses') - .select() - .eq('class_id', classId) - .then((coursesRes) { - if (coursesRes.isNotEmpty) { - sharedCourses = (coursesRes as List).map((raw) { - final c = raw as Map; - return CourseDetails( - id: 0, // Mark as shared/custom - name: c['course_name'] as String? ?? 'Unnamed Course', - code: c['course_code'] as String?, - academicYear: academic.year, - academicSemester: academic.semester, - ); - }).toList(); - } - }), + api.fetchClassCourses(classId).then((coursesRes) { + if (coursesRes.isNotEmpty) { + sharedCourses = coursesRes.map((raw) { + final c = raw as Map; + return CourseDetails( + id: 0, // Mark as shared/custom + name: c['course_name'] as String? ?? 'Unnamed Course', + code: c['course_code'] as String?, + academicYear: academic.year, + academicSemester: academic.semester, + ); + }).toList(); + } + }), // Fetch Instructor Mappings - ref - .read(supabaseClientProvider) - .from('course_instructors') - .select() - .eq('class_id', classId) - .then((instructorsRes) { - if (instructorsRes.isNotEmpty) { - sharedInstructors = (instructorsRes as List) - .map( - (json) => CourseInstructor.fromJson( - json as Map, - ), - ) - .toList(); - } - }), + api.fetchCourseInstructors(classId).then((instructorsRes) { + if (instructorsRes.isNotEmpty) { + sharedInstructors = instructorsRes + .map( + (json) => CourseInstructor.fromJson( + json as Map, + ), + ) + .toList(); + } + }), ], ]); @@ -432,52 +414,18 @@ class DashboardNotifier extends AsyncNotifier { // Pre-calculate sorting criteria to avoid redundant math during sort final target = (auth?.settings.targetPercentage ?? 75).toDouble(); - int getTier(CourseStat? s, {required bool disabled}) { - if (disabled) return 2; // Absolute bottom - if (s == null || s.finalTotal == 0) return 1; - return 0; - } - final metaMap = < String, ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) >{ for (final c in courses) - c.safeId: (() { - final s = stats.courseStats[c.safeId]; - final disabled = disabledCodes.contains( - utils.standardizeCourseCode(c.code ?? ''), - ); - final tier = getTier(s, disabled: disabled); - - if (s == null) { - return ( - tier: tier, - canBunk: 0, - safeCanBunk: 0, - requiredToAttend: 0, - ); - } - - final bunkRes = utils.calculateAttendance( - s.finalPresent, - s.finalTotal, - targetPercentage: target, - ); - final safeRes = utils.calculateAttendance( - s.officialPresent, - s.officialTotal, - targetPercentage: target, - ); - - return ( - tier: tier, - canBunk: bunkRes.canBunk, - safeCanBunk: safeRes.canBunk, - requiredToAttend: bunkRes.requiredToAttend, - ); - })(), + c.safeId: _computeCourseMeta( + course: c, + stats: stats, + disabledCodes: disabledCodes, + targetPercentage: target, + ), }; final sortedCourses = List.from(courses) @@ -518,14 +466,69 @@ class DashboardNotifier extends AsyncNotifier { ); } + ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) + _computeCourseMeta({ + required CourseDetails course, + required DashboardStats stats, + required Set disabledCodes, + required double targetPercentage, + }) { + final s = stats.courseStats[course.safeId]; + final disabled = disabledCodes.contains( + utils.standardizeCourseCode(course.code ?? ''), + ); + + final int tier; + if (disabled) { + tier = 2; // Absolute bottom + } else if (s == null || s.finalTotal == 0) { + tier = 1; + } else { + tier = 0; + } + + if (s == null) { + return ( + tier: tier, + canBunk: 0, + safeCanBunk: 0, + requiredToAttend: 0, + ); + } + + final bunkRes = utils.calculateAttendance( + s.finalPresent, + s.finalTotal, + targetPercentage: targetPercentage, + ); + final safeRes = utils.calculateAttendance( + s.officialPresent, + s.officialTotal, + targetPercentage: targetPercentage, + ); + + return ( + tier: tier, + canBunk: bunkRes.canBunk, + safeCanBunk: safeRes.canBunk, + requiredToAttend: bunkRes.requiredToAttend, + ); + } + Future _silentRevalidate( List tracking, AcademicState academic, ) async { try { - final freshData = await _fetchAndProcess(tracking, academic, null); + final trackingState = ref.read(trackingProvider).value; + final freshData = await _fetchAndProcess( + tracking, + academic, + trackingState?.officialReport, + ); final currentAcademic = ref.read(academicProvider).value; - if (academic == currentAcademic) { + if (academic.semester == currentAcademic?.semester && + academic.year == currentAcademic?.year) { state = AsyncValue.data(freshData); } else { AppLogger.i( @@ -542,7 +545,6 @@ class DashboardNotifier extends AsyncNotifier { } Future refresh() async { - ref.invalidate(notificationsProvider); final user = ref.read(authProvider).value; // 0. Set local loading state @@ -554,6 +556,7 @@ class DashboardNotifier extends AsyncNotifier { if (user != null) { try { await runUnifiedPullToRefresh( + invalidateNotifications: () => ref.invalidate(notificationsProvider), logLabel: 'DashboardNotifier', refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), @@ -570,24 +573,30 @@ class DashboardNotifier extends AsyncNotifier { }, refreshData: () => ref.read(trackingProvider.notifier).refresh(), ); + _pendingTrackedAttendance = ref + .read(trackingProvider) + .value + ?.officialReport; } on Object catch (e, st) { AppLogger.e('DashboardNotifier: refresh coordinator failed', e, st); refreshError = e; refreshStackTrace = st; } finally { - // Clear disk cache even when refresh fails so the next launch or retry - // cannot silently reuse stale dashboard data. - final storage = ref.read(secureStorageProvider); - final academicAsync = ref.read(academicProvider); - final academic = academicAsync.value; - if (academic != null) { - final suffix = - '${user.supabaseUserId}_${academic.semester}_${academic.year}'; - await Future.wait([ - storage.deleteCachedData('dashboard_courses_$suffix'), - storage.deleteCachedData('dashboard_attendance_$suffix'), - storage.deleteCachedData('dashboard_instructors_$suffix'), - ]); + // Clear disk cache only when refresh succeeds to avoid leaving the app + // with no fallback data during connectivity issues. + if (refreshError == null) { + final storage = ref.read(secureStorageProvider); + final academicAsync = ref.read(academicProvider); + final academic = academicAsync.value; + if (academic != null) { + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await Future.wait([ + storage.deleteCachedData('dashboard_courses_$suffix'), + storage.deleteCachedData('dashboard_attendance_$suffix'), + storage.deleteCachedData('dashboard_instructors_$suffix'), + ]); + } } } } diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index f0c23b20..480f5d33 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/leave.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; @@ -27,19 +26,16 @@ class LeaveNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final academic = academicAsync.value; if (authState.value == null || academic == null) return LeaveState.empty(); - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - final api = ref.read(apiServiceProvider); final storage = ref.read(secureStorageProvider); @@ -47,8 +43,32 @@ class LeaveNotifier extends AsyncNotifier { final data = res.data as Map? ?? {}; final studentLeaves = data['studentLeaves'] as Map? ?? {}; final rawLeaves = studentLeaves['student_leaves'] as List? ?? []; - final rawSessions = - studentLeaves['student_leave_sessions'] as Map? ?? {}; + + final rawSessionsRaw = studentLeaves['student_leave_sessions']; + final sessions = >{}; + if (rawSessionsRaw is Map) { + for (final entry in rawSessionsRaw.entries) { + final keyStr = entry.key.toString(); + final leaveId = int.tryParse(keyStr); + if (leaveId == null) continue; + + final value = entry.value; + if (value is List) { + for (final raw in value.whereType>()) { + final session = LeaveSession.fromJson(raw.cast()); + sessions.putIfAbsent(leaveId, () => []).add(session); + } + } else if (value is Map) { + final session = LeaveSession.fromJson(value.cast()); + sessions.putIfAbsent(leaveId, () => []).add(session); + } + } + } else if (rawSessionsRaw is List) { + for (final raw in rawSessionsRaw.whereType>()) { + final session = LeaveSession.fromJson(raw.cast()); + sessions.putIfAbsent(session.leaveId, () => []).add(session); + } + } final leaves = rawLeaves .whereType>() @@ -60,23 +80,11 @@ class LeaveNotifier extends AsyncNotifier { ) .toList(); - final sessions = >{}; - rawSessions.forEach((key, value) { - final parsedKey = int.tryParse(key.toString()); - if (parsedKey != null && value is List) { - sessions[parsedKey] = value - .whereType>() - .map((s) => LeaveSession.fromJson(s.cast())) - .toList(); - } - }); - return LeaveState(leaves: leaves, sessions: sessions); } Future refresh() async { - ref.invalidate(notificationsProvider); - state = const AsyncValue.loading(); ref.invalidateSelf(); + await future; } } diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index 7575be48..26d52182 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -4,7 +4,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class AppNotification { - const AppNotification({ + AppNotification({ required this.id, required this.title, required this.description, @@ -18,7 +18,7 @@ class AppNotification { id: json['id'] as int, title: json['title'] as String? ?? 'Notification', description: json['description'] as String? ?? '', - createdAt: json['created_at'] as String, + createdAt: DateTime.parse(json['created_at'] as String), topic: json['topic'] as String?, isRead: json['is_read'] as bool? ?? false, ); @@ -26,7 +26,7 @@ class AppNotification { final int id; final String title; final String description; - final String createdAt; + final DateTime createdAt; final String? topic; final bool isRead; } @@ -37,6 +37,7 @@ class NotificationsState { required this.regularNotifications, required this.unreadCount, this.hasNextPage = false, + this.isFetchingNextPage = false, }); factory NotificationsState.empty() => const NotificationsState( @@ -48,11 +49,28 @@ class NotificationsState { final List regularNotifications; final int unreadCount; final bool hasNextPage; + final bool isFetchingNextPage; List get allNotifications => [ ...actionNotifications, ...regularNotifications, ]; + + NotificationsState copyWith({ + List? actionNotifications, + List? regularNotifications, + int? unreadCount, + bool? hasNextPage, + bool? isFetchingNextPage, + }) { + return NotificationsState( + actionNotifications: actionNotifications ?? this.actionNotifications, + regularNotifications: regularNotifications ?? this.regularNotifications, + unreadCount: unreadCount ?? this.unreadCount, + hasNextPage: hasNextPage ?? this.hasNextPage, + isFetchingNextPage: isFetchingNextPage ?? this.isFetchingNextPage, + ); + } } final notificationsProvider = @@ -63,15 +81,22 @@ final notificationsProvider = class NotificationsNotifier extends AsyncNotifier { int _currentPage = 0; static const _pageSize = 20; + final _toggleReadInFlight = {}; + String? _lastUserId; @override Future build() async { final user = ref.watch(authProvider).value; - if (user == null) return NotificationsState.empty(); + if (user == null) { + _lastUserId = null; + return NotificationsState.empty(); + } - // BLOCKER: Do not fire queries until Cron Sync is finished - if (user.isSyncing) return NotificationsState.empty(); + if (_lastUserId == user.supabaseUserId && state.hasValue) { + return state.value!; + } + _lastUserId = user.supabaseUserId; return _fetchInitialData(user.supabaseUserId); } @@ -185,122 +210,132 @@ class NotificationsNotifier extends AsyncNotifier { ); } - bool _isFetchingNextPage = false; Future fetchNextPage() async { final current = state.value; - if (current == null || !current.hasNextPage || _isFetchingNextPage) return; + if (current == null || !current.hasNextPage || current.isFetchingNextPage) { + return; + } - _isFetchingNextPage = true; + state = AsyncValue.data(current.copyWith(isFetchingNextPage: true)); final page = _currentPage + 1; try { final nextState = await _fetchNextPage(page: page); - // Only advance the current page after a successful fetch to avoid - // skipping pages when a network call fails. + if (!ref.mounted) return; _currentPage = page; - state = AsyncValue.data(nextState); + state = AsyncValue.data(nextState.copyWith(isFetchingNextPage: false)); } finally { - _isFetchingNextPage = false; + if (ref.mounted && (state.value?.isFetchingNextPage ?? false)) { + final latest = state.value ?? current; + state = AsyncValue.data(latest.copyWith(isFetchingNextPage: false)); + } } } Future toggleRead(int id, {required bool wasRead}) async { - final previousState = state.value; - if (previousState == null) return; - - final newIsRead = !wasRead; - - // 1. Update in actionNotifications - final updatedActions = []; - AppNotification? movedToRegular; - - for (final n in previousState.actionNotifications) { - if (n.id == id) { - final updated = AppNotification( - id: n.id, - title: n.title, - description: n.description, - createdAt: n.createdAt, - topic: n.topic, - isRead: newIsRead, - ); - if (newIsRead) { - movedToRegular = updated; + if (_toggleReadInFlight.contains(id)) return; + _toggleReadInFlight.add(id); + + try { + final previousState = state.value; + if (previousState == null) return; + + final newIsRead = !wasRead; + + // 1. Update in actionNotifications + final updatedActions = []; + AppNotification? movedToRegular; + + for (final n in previousState.actionNotifications) { + if (n.id == id) { + final updated = AppNotification( + id: n.id, + title: n.title, + description: n.description, + createdAt: n.createdAt, + topic: n.topic, + isRead: newIsRead, + ); + if (newIsRead) { + movedToRegular = updated; + } else { + updatedActions.add(updated); + } } else { - updatedActions.add(updated); + updatedActions.add(n); } - } else { - updatedActions.add(n); } - } - // 2. Update in regularNotifications - final updatedRegular = []; - AppNotification? movedToAction; - - for (final n in previousState.regularNotifications) { - if (n.id == id) { - final updated = AppNotification( - id: n.id, - title: n.title, - description: n.description, - createdAt: n.createdAt, - topic: n.topic, - isRead: newIsRead, - ); + // 2. Update in regularNotifications + final updatedRegular = []; + AppNotification? movedToAction; - // If a conflict is marked as UNREAD, it must move back to actionNotifications - if (!newIsRead && - (n.topic?.toLowerCase().contains('conflict') ?? false)) { - movedToAction = updated; + for (final n in previousState.regularNotifications) { + if (n.id == id) { + final updated = AppNotification( + id: n.id, + title: n.title, + description: n.description, + createdAt: n.createdAt, + topic: n.topic, + isRead: newIsRead, + ); + + // If a conflict is marked as UNREAD, it must move back to actionNotifications + if (!newIsRead && + (n.topic?.toLowerCase().contains('conflict') ?? false)) { + movedToAction = updated; + } else { + updatedRegular.add(updated); + } } else { - updatedRegular.add(updated); + updatedRegular.add(n); } - } else { - updatedRegular.add(n); } - } - - if (movedToAction != null) { - updatedActions - ..insert(0, movedToAction) - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - } - if (movedToRegular != null) { - updatedRegular - ..insert(0, movedToRegular) - // Re-sort regular by date if needed, but inserting at 0 is fine for now - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - } + if (movedToAction != null) { + updatedActions + ..insert(0, movedToAction) + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } - final unreadChange = wasRead ? 1 : -1; - state = AsyncValue.data( - NotificationsState( - actionNotifications: updatedActions, - regularNotifications: updatedRegular, - unreadCount: previousState.unreadCount + unreadChange, - hasNextPage: previousState.hasNextPage, - ), - ); + if (movedToRegular != null) { + updatedRegular + ..insert(0, movedToRegular) + // Re-sort regular by date if needed, but inserting at 0 is fine for now + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } - try { - final supabase = Supabase.instance.client; - await supabase - .from('notification') - .update({'is_read': newIsRead}) - .eq('id', id); - } on Object catch (_) { - state = AsyncValue.data(previousState); - rethrow; + final unreadChange = wasRead ? 1 : -1; + state = AsyncValue.data( + NotificationsState( + actionNotifications: updatedActions, + regularNotifications: updatedRegular, + unreadCount: previousState.unreadCount + unreadChange, + hasNextPage: previousState.hasNextPage, + ), + ); + + try { + final supabase = Supabase.instance.client; + await supabase + .from('notification') + .update({'is_read': newIsRead}) + .eq('id', id); + } on Object catch (_) { + state = AsyncValue.data(previousState); + rethrow; + } + } finally { + _toggleReadInFlight.remove(id); } } Future markAllAsRead() async { - final previousState = state.value; - if (previousState == null) return; + final snapshotBeforeUpdate = state.value; + if (snapshotBeforeUpdate == null) return; // Move all unread actions to regular notifications as read - final readActions = previousState.actionNotifications + final readActions = snapshotBeforeUpdate.actionNotifications .map( (n) => AppNotification( id: n.id, @@ -313,7 +348,7 @@ class NotificationsNotifier extends AsyncNotifier { ) .toList(); - final readRegular = previousState.regularNotifications + final readRegular = snapshotBeforeUpdate.regularNotifications .map( (n) => AppNotification( id: n.id, @@ -329,19 +364,26 @@ class NotificationsNotifier extends AsyncNotifier { final allReadRegular = [...readActions, ...readRegular] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - state = AsyncValue.data( - NotificationsState( - actionNotifications: [], - regularNotifications: allReadRegular, - unreadCount: 0, - hasNextPage: previousState.hasNextPage, - ), + final optimisticState = NotificationsState( + actionNotifications: [], + regularNotifications: allReadRegular, + unreadCount: 0, + hasNextPage: snapshotBeforeUpdate.hasNextPage, ); + state = AsyncValue.data(optimisticState); + try { final supabase = Supabase.instance.client; - final userId = supabase.auth.currentUser?.id; - if (userId == null) return; + final userId = + ref.read(authProvider).value?.supabaseUserId ?? + supabase.auth.currentUser?.id; + if (userId == null) { + // If there's no authenticated user, treat this as an error so callers/tests + // observing failure semantics can react accordingly instead of silently + // returning and leaving optimistic state applied. + throw Exception('No authenticated user for markAllAsRead'); + } await supabase .from('notification') @@ -349,7 +391,49 @@ class NotificationsNotifier extends AsyncNotifier { .eq('auth_user_id', userId) .eq('is_read', false); } on Object catch (_) { - state = AsyncValue.data(previousState); + final currentState = state.value; + if (currentState == null || currentState == optimisticState) { + state = AsyncValue.data(snapshotBeforeUpdate); + } else { + // State mutated concurrently (e.g. fetchNextPage). + // Revert the read status of notifications that were present in snapshotBeforeUpdate + // and restore actionNotifications. + final originalActionIds = snapshotBeforeUpdate.actionNotifications + .map((n) => n.id) + .toSet(); + + final originalIsRead = { + for (final n in snapshotBeforeUpdate.allNotifications) n.id: n.isRead, + }; + + final revertedRegular = currentState.regularNotifications + .where((n) => !originalActionIds.contains(n.id)) + .map((n) { + final wasRead = originalIsRead[n.id]; + if (wasRead != null) { + return AppNotification( + id: n.id, + title: n.title, + description: n.description, + createdAt: n.createdAt, + topic: n.topic, + isRead: wasRead, + ); + } + return n; + }) + .toList(); + + state = AsyncValue.data( + NotificationsState( + actionNotifications: snapshotBeforeUpdate.actionNotifications, + regularNotifications: revertedRegular, + unreadCount: snapshotBeforeUpdate.unreadCount, + hasNextPage: currentState.hasNextPage, + isFetchingNextPage: currentState.isFetchingNextPage, + ), + ); + } rethrow; } } diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index 6bd08a52..401ca56a 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -5,8 +5,8 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/score.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/secure_storage.dart'; final scoreProvider = AsyncNotifierProvider( @@ -73,28 +73,27 @@ class ScoreNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } - final academic = academicAsync.value; - - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; + final user = authState.value; + if (user == null) { + throw Exception('Unauthorized'); } - return _initialFetch(academic: academic); + final academic = academicAsync.value; + + return _initialFetch(user: user, academic: academic); } Future _initialFetch({ + required AuthenticatedUser user, AcademicState? academic, bool bypassCache = false, }) async { - final authState = ref.watch(authProvider); - final user = authState.value; - if (user == null) throw Exception('Unauthorized'); - final api = ref.read(apiServiceProvider); final storage = ref.read(secureStorageProvider); @@ -128,15 +127,25 @@ class ScoreNotifier extends AsyncNotifier { final slice = targetExams.skip(i).take(poolSize); await Future.wait( slice.map( - (exam) => _loadExamDetails( - exam: exam, - api: api, - storage: storage, - questionsMap: questionsMap, - answersMap: answersMap, - resolvedScores: resolvedScores, - bypassCache: bypassCache, - ), + (exam) async { + try { + await _loadExamDetails( + exam: exam, + api: api, + storage: storage, + questionsMap: questionsMap, + answersMap: answersMap, + resolvedScores: resolvedScores, + bypassCache: bypassCache, + ); + } on Object catch (err, st) { + AppLogger.e( + 'Failed to load details for exam ${exam.id}', + err, + st, + ); + } + }, ), ); } @@ -170,13 +179,25 @@ class ScoreNotifier extends AsyncNotifier { pendingCount: pending, ); - return _applyFilter(state, 'all'); + return _applyFilter( + state, + 'all', + totalExams: visibleExams.length, + scoredCount: scored, + pendingCount: pending, + ); } on Object catch (e) { throw Exception('Failed to load internal marks: $e'); } } - ScoreState _applyFilter(ScoreState baseState, String type) { + ScoreState _applyFilter( + ScoreState baseState, + String type, { + int? totalExams, + int? scoredCount, + int? pendingCount, + }) { var filtered = baseState.rawExams; if (type != 'all') { filtered = baseState.rawExams @@ -193,11 +214,13 @@ class ScoreNotifier extends AsyncNotifier { .map((entry) => CourseGroup(label: entry.key, exams: entry.value)) .toList(); - final total = filtered.length; - final scored = filtered - .where((e) => baseState.resolvedScores.containsKey(e.id)) - .length; - final pending = total - scored; + final total = totalExams ?? filtered.length; + final scored = + scoredCount ?? + filtered + .where((e) => baseState.resolvedScores.containsKey(e.id)) + .length; + final pending = pendingCount ?? (total - scored); return baseState.copyWith( filterType: type, @@ -215,12 +238,16 @@ class ScoreNotifier extends AsyncNotifier { } Future refresh() async { - ref.invalidate(notificationsProvider); + final user = ref.read(authProvider).value; + if (user == null) { + state = AsyncValue.error(Exception('Unauthorized'), StackTrace.current); + return; + } final academicAsync = ref.read(academicProvider); final academic = academicAsync.value; state = const AsyncValue.loading(); state = await AsyncValue.guard( - () => _initialFetch(academic: academic, bypassCache: true), + () => _initialFetch(user: user, academic: academic, bypassCache: true), ); } @@ -269,10 +296,10 @@ class ScoreNotifier extends AsyncNotifier { qsData = results[0].data; ansData = results[1].data; - if (results[0].statusCode == 200 && qsData != null) { + if (results[0].statusCode == 200 && qsData is List) { await storage.saveCachedData(cacheKeyQs, qsData); } - if (results[1].statusCode == 200 && ansData != null) { + if (results[1].statusCode == 200 && ansData is List) { await storage.saveCachedData(cacheKeyAns, ansData); } } diff --git a/mobile/lib/providers/tracking_provider.dart b/mobile/lib/providers/tracking_provider.dart index 6a4ea2d1..fab15894 100644 --- a/mobile/lib/providers/tracking_provider.dart +++ b/mobile/lib/providers/tracking_provider.dart @@ -6,7 +6,6 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/analytics_service.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; @@ -52,12 +51,6 @@ final trackingProvider = AsyncNotifierProvider( ); class TrackingNotifier extends AsyncNotifier { - static bool _isSyncingExternal = false; - - String _canonicalTrackerCourseCode(String courseId) { - return utils.standardizeCourseCode(courseId); - } - @override FutureOr build() async { // 1. Reactive Dependency: Clear data immediately on logout OR Semester Change @@ -65,7 +58,10 @@ class TrackingNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final academic = academicAsync.value; @@ -79,12 +75,6 @@ class TrackingNotifier extends AsyncNotifier { ); } - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - // 2. Initial Load return _fetchAndProcess(academic: academic, isInitial: true); } @@ -107,23 +97,7 @@ class TrackingNotifier extends AsyncNotifier { ); } - var syncCompleted = false; - if (forceSync) { - if (_isSyncingExternal) { - syncCompleted = true; - } else { - _isSyncingExternal = true; - try { - // Sync is now primarily triggered by DashboardNotifier.refresh - // or NavigationShell. Individual triggers here are redundant. - } finally { - _isSyncingExternal = false; - syncCompleted = true; - } - } - } else { - syncCompleted = true; - } + const syncCompleted = true; late final AttendanceReportDetailed officialReport; final records = []; @@ -220,7 +194,6 @@ class TrackingNotifier extends AsyncNotifier { /// DashboardNotifier.refresh() already handles the primary sync, so pass /// forceSync: false when calling from that context to avoid redundant requests. Future refresh({bool forceSync = false}) async { - ref.invalidate(notificationsProvider); final academicAsync = ref.read(academicProvider); final user = ref.read(authProvider).value; final supabaseToken = ref @@ -260,7 +233,7 @@ class TrackingNotifier extends AsyncNotifier { final academic = academicAsync.value; if (auth == null || academic == null) return; - final canonicalCourseId = _canonicalTrackerCourseCode(courseId); + final canonicalCourseId = utils.standardizeCourseCode(courseId); try { final response = await ref diff --git a/mobile/lib/screens/accept_terms_screen.dart b/mobile/lib/screens/accept_terms_screen.dart index dbe2cfcd..67caff92 100644 --- a/mobile/lib/screens/accept_terms_screen.dart +++ b/mobile/lib/screens/accept_terms_screen.dart @@ -298,64 +298,69 @@ class _AcceptTermsScreenState extends ConsumerState { const SizedBox(height: 32), // Acceptance - InkWell( - onTap: () => setState(() => _accepted = !_accepted), - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _accepted - ? primary.withValues(alpha: 0.05) - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.02), - borderRadius: BorderRadius.circular(16), - border: Border.all( + Semantics( + checked: _accepted, + label: + 'I have read and accept the above Disclaimer and all Policies.', + child: InkWell( + onTap: () => setState(() => _accepted = !_accepted), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( color: _accepted - ? primary.withValues(alpha: 0.2) + ? primary.withValues(alpha: 0.05) : Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.08), + ).colorScheme.onSurface.withValues(alpha: 0.02), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _accepted + ? primary.withValues(alpha: 0.2) + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + ), ), - ), - child: Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: _accepted ? primary : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: _accepted - ? primary - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.45), - width: 2, + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _accepted ? primary : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _accepted + ? primary + : Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.45), + width: 2, + ), ), + child: _accepted + ? const Icon( + Icons.check, + size: 14, + color: Colors.white, + ) + : null, ), - child: _accepted - ? const Icon( - Icons.check, - size: 14, - color: Colors.white, - ) - : null, - ), - const SizedBox(width: 16), - Expanded( - child: Text( - 'I have read and accept the above Disclaimer and all Policies.', - style: TextStyle( - fontSize: 13, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - height: 1.4, + const SizedBox(width: 16), + Expanded( + child: Text( + 'I have read and accept the above Disclaimer and all Policies.', + style: TextStyle( + fontSize: 13, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.4, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index a5f47610..459bcca3 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/leave.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/leave_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; @@ -33,9 +34,131 @@ class LeavesScreen extends ConsumerWidget { return '${(bytes / 1048576).toStringAsFixed(1)} MB'; } + List _buildWorkflowHistory(BuildContext context, Leave leave) { + final filteredApprovers = {}; + + for (final approver in leave.approvers) { + if (approver.actionByUser == null) continue; + + final actionByUser = approver.actionByUser!; + final dedupeKey = [ + actionByUser.firstName, + actionByUser.lastName, + approver.actionType, + approver.actionAt, + ].join('|'); + + filteredApprovers.putIfAbsent(dedupeKey, () => approver); + } + + final sortedApprovers = filteredApprovers.values.toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); + + return sortedApprovers.map((approver) { + final isApproved = approver.actionType == 'approve'; + final isRejected = approver.actionType == 'reject'; + final isForwarded = approver.actionType == 'forward'; + final isRecommended = approver.actionType == 'recommend'; + final color = isApproved + ? successGreen + : isRejected + ? Colors.red + : isForwarded + ? Colors.indigo + : isRecommended + ? Colors.blue + : Colors.grey; + final label = isApproved + ? 'Approved' + : isForwarded + ? 'Forwarded' + : isRecommended + ? 'Recommended' + : isRejected + ? 'Rejected' + : (approver.actionType ?? 'Action'); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.user, + size: 12, + color: color, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '${approver.actionByUser!.firstName} ${approver.actionByUser!.lastName}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.manrope( + fontSize: 9, + fontWeight: FontWeight.w900, + color: color, + ), + ), + ), + const SizedBox(width: 8), + Text( + _formatDate( + approver.actionAt ?? approver.updatedAt, + ), + style: GoogleFonts.manrope( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ), + ); + }).toList(); + } + @override Widget build(BuildContext context, WidgetRef ref) { final leaveState = ref.watch(leaveProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; + + if (isSyncing) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: const LoadingOverlay(isFullScreen: false, showLogo: false), + ); + } return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -76,6 +199,8 @@ class LeavesScreen extends ConsumerWidget { onRefresh: () async { try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'LeavesScreen', refreshProfile: () => ref .read(authProvider.notifier) @@ -127,6 +252,8 @@ class LeavesScreen extends ConsumerWidget { child: ServiceErrorView( error: err, onRetry: () => runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'LeavesScreen', refreshProfile: () => ref .read(authProvider.notifier) @@ -207,8 +334,11 @@ class LeavesScreen extends ConsumerWidget { } final approvedCount = data.leaves - .where((l) => _getLeaveStatus(l.approvers).label == 'Approved') + .where((l) => _getLeaveStatus(context, l.approvers).label == 'Approved') .length; + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); return SliverPadding( padding: const EdgeInsets.all(24), @@ -229,7 +359,7 @@ class LeavesScreen extends ConsumerWidget { 'Approved', approvedCount.toString(), LucideIcons.checkCircle2, - const Color(0xFF10B981), + successGreen, ), ], ), @@ -329,7 +459,7 @@ class LeavesScreen extends ConsumerWidget { Leave leave, List sessions, ) { - final status = _getLeaveStatus(leave.approvers); + final status = _getLeaveStatus(context, leave.approvers); // Unique dates final uniqueDates = sessions.map((s) => s.date).toSet().toList()..sort(); @@ -366,6 +496,7 @@ class LeavesScreen extends ConsumerWidget { ), clipBehavior: Clip.antiAlias, child: Stack( + clipBehavior: Clip.antiAlias, children: [ // Content Padding( @@ -617,113 +748,7 @@ class LeavesScreen extends ConsumerWidget { ), ), const SizedBox(height: 12), - ...() { - final filteredApprovers = []; - for (final a in leave.approvers) { - if (a.actionByUser == null) continue; - final isDuplicate = filteredApprovers.any( - (item) => - item.actionByUser?.firstName == - a.actionByUser?.firstName && - item.actionByUser?.lastName == - a.actionByUser?.lastName && - item.actionType == a.actionType && - item.actionAt == a.actionAt, - ); - if (!isDuplicate) filteredApprovers.add(a); - } - filteredApprovers.sort( - (a, b) => b.updatedAt.compareTo(a.updatedAt), - ); - - return filteredApprovers.map((approver) { - final isApproved = approver.actionType == 'approve'; - final isRejected = approver.actionType == 'reject'; - final isForwarded = approver.actionType == 'forward'; - final isRecommended = approver.actionType == 'recommend'; - final color = isApproved - ? const Color(0xFF10B981) - : isRejected - ? Colors.red - : isForwarded - ? Colors.indigo - : isRecommended - ? Colors.blue - : Colors.grey; - final label = isApproved - ? 'Approved' - : isForwarded - ? 'Forwarded' - : isRecommended - ? 'Recommended' - : isRejected - ? 'Rejected' - : (approver.actionType ?? 'Action'); - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - LucideIcons.user, - size: 12, - color: color, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '${approver.actionByUser!.firstName} ${approver.actionByUser!.lastName}', - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - label.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 9, - fontWeight: FontWeight.w900, - color: color, - ), - ), - ), - const SizedBox(width: 8), - Text( - _formatDate( - approver.actionAt ?? approver.updatedAt, - ), - style: GoogleFonts.manrope( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ), - ); - }).toList(); - }(), + ..._buildWorkflowHistory(context, leave), ], ], ), @@ -794,20 +819,34 @@ class LeavesScreen extends ConsumerWidget { ); } - _LeaveStatus _getLeaveStatus(List approvers) { + bool _isActed(LeaveApprover a) { + return (a.actionAt != null && a.actionAt!.isNotEmpty) || + a.actionByUser != null; + } + + _LeaveStatus _getLeaveStatus( + BuildContext context, + List approvers, + ) { + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); + if (approvers.isEmpty) { return _LeaveStatus('Pending', Colors.amber, LucideIcons.clock); } // If any level has rejected, the entire leave is immediately Rejected - final hasRejected = approvers.any((a) => a.actionType == 'reject'); + final hasRejected = approvers.any( + (a) => a.actionType == 'reject' && _isActed(a), + ); if (hasRejected) { return _LeaveStatus('Rejected', Colors.red, LucideIcons.xCircle); } final actedApprovers = approvers - .where((a) => a.actionType != null || a.actionAt != null) + .where((a) => _isActed(a) && a.actionType != 'pending') .toList() ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); @@ -816,7 +855,9 @@ class LeavesScreen extends ConsumerWidget { } final hasPending = approvers.any( - (a) => a.actionType == null && a.actionAt == null, + (a) => + a.actionType == 'pending' || + (!_isActed(a) && a.actionType != 'reject'), ); final lastAction = actedApprovers.first.actionType; @@ -839,7 +880,7 @@ class LeavesScreen extends ConsumerWidget { if (lastAction == 'approve') { return _LeaveStatus( 'Approved', - const Color(0xFF10B981), + successGreen, LucideIcons.checkCircle2, ); } diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index ee16399f..3cc7c55c 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -36,8 +36,33 @@ class _LoginScreenState extends ConsumerState bool _isLoading = false; bool _obscurePassword = true; + int _consecutiveFailures = 0; + Timer? _cooldownTimer; + int _cooldownSecondsRemaining = 0; + late final SecurityGuard _securityGuard; + void _startCooldown() { + _cooldownSecondsRemaining = 30; + _cooldownTimer?.cancel(); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + if (_cooldownSecondsRemaining > 1) { + _cooldownSecondsRemaining--; + } else { + _cooldownSecondsRemaining = 0; + _consecutiveFailures = 0; + _cooldownTimer?.cancel(); + _cooldownTimer = null; + } + }); + }); + } + void _checkAndShowUpdateDialog() { final updateState = ref.read(appUpdateProvider); if (updateState.checkResult != null && @@ -72,6 +97,7 @@ class _LoginScreenState extends ConsumerState @override void dispose() { + _cooldownTimer?.cancel(); // Disable screen protection when leaving the Login screen final _ = _securityGuard.setSecureScreen(enabled: false); _usernameController.dispose(); @@ -82,6 +108,8 @@ class _LoginScreenState extends ConsumerState Future _handleLogin() async { FocusScope.of(context).unfocus(); + if (_cooldownSecondsRemaining > 0) return; + if (!kDebugMode && !kIsWeb && Platform.isAndroid) { const channel = MethodChannel('com.devakesu.apps.ghostclass/security'); try { @@ -108,15 +136,7 @@ class _LoginScreenState extends ConsumerState } } - final errors = []; - if (_usernameController.text.trim().isEmpty) { - errors.add('Username, email, or phone number is required.'); - } - if (_passwordController.text.isEmpty) { - errors.add('Password is required.'); - } - if (errors.isNotEmpty) { - await handleError(errors.join('\n'), title: 'Missing Fields'); + if (!(_formKey.currentState?.validate() ?? false)) { return; } @@ -125,27 +145,27 @@ class _LoginScreenState extends ConsumerState LoadingOverlay.show(context, message: 'Waking up EzyGo...'); try { - try { - AppLogger.safeUnawait( - AnalyticsService.instance - .logCustom('login_attempt', { - 'username_length': _usernameController.text.trim().length, - }) - .catchError( - (Object e, StackTrace st) => AppLogger.e( - 'LoginScreen: Analytics login_attempt failed', - e, - st, - ), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('login_attempt', { + 'username_length': _usernameController.text.trim().length, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics login_attempt failed', + e, + st, ), - 'LoginScreen: analytics login_attempt', - ); - } on Object catch (_) {} + ), + 'LoginScreen: analytics login_attempt', + ); await ref .read(authProvider.notifier) .login(_usernameController.text.trim(), _passwordController.text); + _consecutiveFailures = 0; + if (mounted) { LoadingOverlay.hide(context); context.go('/'); @@ -153,40 +173,44 @@ class _LoginScreenState extends ConsumerState } on LoginException catch (e) { if (!mounted) return; LoadingOverlay.hide(context); - try { - AppLogger.safeUnawait( - AnalyticsService.instance - .logCustom('login_failed', { - 'reason': e.message, - }) - .catchError( - (Object e, StackTrace st) => AppLogger.e( - 'LoginScreen: Analytics login_failed failed', - e, - st, - ), + _consecutiveFailures++; + if (_consecutiveFailures >= 3) { + _startCooldown(); + } + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('login_failed', { + 'reason': e.message, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics login_failed failed', + e, + st, ), - 'LoginScreen: analytics login_failed', - ); - } on Object catch (_) {} + ), + 'LoginScreen: analytics login_failed', + ); await handleError(e.message, title: 'Login Failed'); } on Object catch (e) { if (!mounted) return; LoadingOverlay.hide(context); - try { - AppLogger.safeUnawait( - AnalyticsService.instance - .logError(e.toString()) - .catchError( - (Object e, StackTrace st) => AppLogger.e( - 'LoginScreen: Analytics logError failed', - e, - st, - ), + _consecutiveFailures++; + if (_consecutiveFailures >= 3) { + _startCooldown(); + } + AppLogger.safeUnawait( + AnalyticsService.instance + .logError(e.toString()) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics logError failed', + e, + st, ), - 'LoginScreen: analytics logError', - ); - } on Object catch (_) {} + ), + 'LoginScreen: analytics logError', + ); await handleError(e); } finally { if (mounted) setState(() => _isLoading = false); @@ -329,6 +353,12 @@ class _LoginScreenState extends ConsumerState AutofillHints.username, AutofillHints.email, ], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Username, email, or phone number is required.'; + } + return null; + }, ), const SizedBox(height: 16), @@ -405,6 +435,12 @@ class _LoginScreenState extends ConsumerState textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.password], onFieldSubmitted: (_) => _handleLogin(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required.'; + } + return null; + }, ), const SizedBox(height: 24), @@ -439,7 +475,11 @@ class _LoginScreenState extends ConsumerState borderRadius: BorderRadius.circular(16), ), ), - onPressed: _isLoading ? null : _handleLogin, + onPressed: + (_isLoading || + _cooldownSecondsRemaining > 0) + ? null + : _handleLogin, child: _isLoading ? const SizedBox( height: 22, @@ -449,9 +489,11 @@ class _LoginScreenState extends ConsumerState color: Colors.white, ), ) - : const Text( - 'Login', - style: TextStyle( + : Text( + _cooldownSecondsRemaining > 0 + ? 'Retry in ${_cooldownSecondsRemaining}s' + : 'Login', + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, letterSpacing: 0.5, diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index ccf1ca61..995f1dbb 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -48,6 +48,10 @@ class _NavigationShellState extends ConsumerState { bool _criticalSecurityLogoutStarted = false; final List> _subscriptions = []; + // Tracks the timestamp of the first back-press on the dashboard tab, + // used to implement double-back-to-exit behaviour. + DateTime? _lastBackPressOnDashboard; + AcademicState? _asyncValueOrNull(AsyncValue value) { return value.hasValue ? value.value : null; } @@ -186,6 +190,128 @@ class _NavigationShellState extends ConsumerState { super.dispose(); } + /// Handles back-button presses intercepted by [BackButtonListener]. + /// + /// [BackButtonListener] registers a [ChildBackButtonDispatcher] that takes + /// PRIORITY over go_router's [RootBackButtonDispatcher], so this callback + /// fires before go_router attempts to pop any navigator — reliably on all + /// Android versions including predictive back (Android 13+). + /// + /// Returns `true` → back consumed (app stays open, we navigate ourselves). + /// Returns `false` → delegate to go_router's default back handling. + Future _handleBackPress(int selectedIndex) async { + AppLogger.i( + 'NavigationShell: _handleBackPress called with selectedIndex=$selectedIndex', + ); + if (selectedIndex != 0) { + AppLogger.i( + 'NavigationShell: selectedIndex != 0, navigating to /dashboard', + ); + context.go('/dashboard'); + return true; + } + + // Dashboard → double-back-to-exit. + final now = DateTime.now(); + final last = _lastBackPressOnDashboard; + if (last != null && now.difference(last) < const Duration(seconds: 2)) { + // Second back within 2 s → exit. + await SystemNavigator.pop(); + return true; + } + _lastBackPressOnDashboard = now; + + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text( + 'Press back again to exit', + style: GoogleFonts.manrope(fontWeight: FontWeight.w600), + ), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.fromLTRB(16, 0, 16, 90), + ), + ); + return true; + } + + int _calculateSelectedIndex(String location) { + if (location.startsWith('/dashboard')) return 0; + if (location.startsWith('/calendar')) return 1; + if (location.startsWith('/scores')) return 2; + if (location.startsWith('/leaves')) return 3; + if (location.startsWith('/ghostclass') || + location.startsWith('/profile-dump')) { + return 4; + } + return 0; + } + + void _onTabTapped(int index) { + switch (index) { + case 0: + context.go('/dashboard'); + case 1: + context.go('/calendar'); + case 2: + context.go('/scores'); + case 3: + context.go('/leaves'); + case 4: + context.go('/ghostclass'); + } + } + + Future _showTrackingOverlay() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + builder: (context) => const TrackingScreen(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + + Future _showNotificationsOverlay() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + builder: (context) => const NotificationsScreen(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + + Future _showAddAttendanceDialog() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showDialog( + context: context, + builder: (context) => const AddAttendanceDialog(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + @override Widget build(BuildContext context) { final ref = this.ref; @@ -203,74 +329,17 @@ class _NavigationShellState extends ConsumerState { final location = GoRouterState.of(context).uri.path; - int calculateSelectedIndex(String location) { - if (location.startsWith('/dashboard')) return 0; - if (location.startsWith('/calendar')) return 1; - if (location.startsWith('/scores')) return 2; - if (location.startsWith('/leaves')) return 3; - if (location.startsWith('/ghostclass') || - location.startsWith('/profile-dump')) { - return 4; - } - return 0; - } - - final selectedIndex = calculateSelectedIndex(location); - - void onTabTapped(int index) { - switch (index) { - case 0: - context.go('/dashboard'); - case 1: - context.go('/calendar'); - case 2: - context.go('/scores'); - case 3: - context.go('/leaves'); - case 4: - context.go('/ghostclass'); - } - } - - Future showTrackingOverlay() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - builder: (context) => const TrackingScreen(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } - - Future showNotificationsOverlay() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - builder: (context) => const NotificationsScreen(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } + final selectedIndex = _calculateSelectedIndex(location); final bottomPadding = MediaQuery.of(context).padding.bottom; // --- OUTAGE BARRIER --- - final dashboardAsync = ref.watch(dashboardProvider); - final trackingAsync = ref.watch(trackingProvider); + final dashboardError = ref.watch( + dashboardProvider.select((state) => state.error), + ); + final trackingError = ref.watch( + trackingProvider.select((state) => state.error), + ); // Reactive provider confirms an outage (Global Barrier) final showOutageBarrier = ref.watch(outageProvider); @@ -281,17 +350,6 @@ class _NavigationShellState extends ConsumerState { final securityMessage = securityFailure?.message ?? ''; final isCriticalSecurityFailure = securityFailure?.criticalRisk ?? false; - Future showAddAttendanceDialog() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showDialog( - context: context, - builder: (context) => const AddAttendanceDialog(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } - final mainScaffold = Scaffold( backgroundColor: bg, appBar: PreferredSize( @@ -323,7 +381,7 @@ class _NavigationShellState extends ConsumerState { button: true, label: 'Notifications', child: InkWell( - onTap: showNotificationsOverlay, + onTap: _showNotificationsOverlay, splashColor: Colors.transparent, highlightColor: Colors.transparent, child: Stack( @@ -395,7 +453,7 @@ class _NavigationShellState extends ConsumerState { button: true, label: 'Tracking', child: InkWell( - onTap: showTrackingOverlay, + onTap: _showTrackingOverlay, splashColor: Colors.transparent, highlightColor: Colors.transparent, child: Container( @@ -538,7 +596,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.layoutDashboard, label: 'Dashboard', isSelected: selectedIndex == 0, - onTap: () => onTabTapped(0), + onTap: () => _onTabTapped(0), ), ), Expanded( @@ -546,7 +604,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.calendar, label: 'Calendar', isSelected: selectedIndex == 1, - onTap: () => onTabTapped(1), + onTap: () => _onTabTapped(1), ), ), Expanded( @@ -554,7 +612,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.graduationCap, label: 'Scores', isSelected: selectedIndex == 2, - onTap: () => onTabTapped(2), + onTap: () => _onTabTapped(2), ), ), Expanded( @@ -562,7 +620,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.clipboardList, label: 'Leaves', isSelected: selectedIndex == 3, - onTap: () => onTabTapped(3), + onTap: () => _onTabTapped(3), ), ), Expanded( @@ -570,7 +628,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.ghost, label: 'GhostClass', isSelected: selectedIndex == 4, - onTap: () => onTabTapped(4), + onTap: () => _onTabTapped(4), ), ), ], @@ -579,182 +637,252 @@ class _NavigationShellState extends ConsumerState { ), ); - return Stack( - alignment: Alignment.bottomCenter, - children: [ - ExcludeSemantics( - excluding: showSecurityBarrier, - child: mainScaffold, - ), - // The Semicircle Button (Positioned at bottom center, above everything) - Positioned( - bottom: 75 + bottomPadding, // Account for bottom safe area/notch - child: ExcludeSemantics( - excluding: - isModalOpen || - (selectedIndex > 1) || - showOutageBarrier || - showSecurityBarrier, - child: IgnorePointer( - ignoring: + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + AppLogger.i( + 'NavigationShell: onPopInvokedWithResult called with didPop=$didPop', + ); + if (didPop) return; + unawaited(_handleBackPress(selectedIndex)); + }, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + ExcludeSemantics( + excluding: showSecurityBarrier, + child: mainScaffold, + ), + // The Semicircle Button (Positioned at bottom center, above everything) + Positioned( + bottom: 75 + bottomPadding, // Account for bottom safe area/notch + child: ExcludeSemantics( + excluding: isModalOpen || (selectedIndex > 1) || showOutageBarrier || showSecurityBarrier, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: - (isModalOpen || - (selectedIndex > 1) || - showOutageBarrier || - showSecurityBarrier) - ? 0 - : 1, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: showAddAttendanceDialog, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(72), - ), - child: Container( - width: 72, - height: 34, - decoration: BoxDecoration( - color: primary, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(72), - ), + child: IgnorePointer( + ignoring: + isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: + (isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier) + ? 0 + : 1, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: _showAddAttendanceDialog, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(72), ), - child: Icon( - LucideIcons.plus, - color: Theme.of(context).colorScheme.onPrimary, - size: 24, + child: Container( + width: 72, + height: 34, + decoration: BoxDecoration( + color: primary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(72), + ), + ), + child: Icon( + LucideIcons.plus, + color: Theme.of(context).colorScheme.onPrimary, + size: 24, + ), ), ), ), ), ), ), - ), - ).animate().slideY(begin: 3.5, end: 0, curve: Curves.easeOutBack), - - // --- GLOBAL OUTAGE OVERLAY --- - if (showOutageBarrier) - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: ColoredBox( - color: bg.withValues(alpha: 0.9), // More opaque background - child: ServiceErrorView( - error: dashboardAsync.error ?? trackingAsync.error, - onRetry: () async { - ref.read(apiServiceProvider).clearCaches(); - ref - ..invalidate(dashboardProvider) - ..invalidate(trackingProvider) - ..invalidate(academicProvider); - - // Wait for critical providers to finish (success or new error) - // We add a 10s timeout so the UI doesn't feel 'stuck' if network hangs - AppLogger.d( - 'NavigationShell: Starting outage recovery retry...', - ); - try { - await Future.wait([ - ref.read(dashboardProvider.future), - ref.read(trackingProvider.future), - ]).timeout(const Duration(seconds: 30)); - AppLogger.i( - 'NavigationShell: Outage recovery wait completed (or partial success).', - ); - } on Object catch (e) { - AppLogger.e( - 'NavigationShell: Outage recovery wait timed out or failed ($e). Re-enabling UI.', + ).animate().slideY(begin: 3.5, end: 0, curve: Curves.easeOutBack), + + // --- GLOBAL OUTAGE OVERLAY --- + if (showOutageBarrier) + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: ColoredBox( + color: bg.withValues(alpha: 0.9), // More opaque background + child: ServiceErrorView( + error: dashboardError ?? trackingError, + onRetry: () async { + ref.read(apiServiceProvider).clearCaches(); + ref + ..invalidate(dashboardProvider) + ..invalidate(trackingProvider) + ..invalidate(academicProvider); + + // Wait for critical providers to finish (success or new error) + // We add a 10s timeout so the UI doesn't feel 'stuck' if network hangs + AppLogger.d( + 'NavigationShell: Starting outage recovery retry...', ); - } - }, + try { + await Future.wait([ + ref.read(dashboardProvider.future), + ref.read(trackingProvider.future), + ]).timeout(const Duration(seconds: 30)); + AppLogger.i( + 'NavigationShell: Outage recovery wait completed (or partial success).', + ); + } on Object catch (e) { + AppLogger.e( + 'NavigationShell: Outage recovery wait timed out or failed ($e). Re-enabling UI.', + ); + } + }, + ), ), ), - ), - ).animate().fadeIn(duration: 300.ms), - - // --- GLOBAL SECURITY BARRIER --- - if (showSecurityBarrier) - Positioned.fill( - child: Semantics( - container: true, - liveRegion: true, - label: securityMessage.isEmpty - ? 'Security verification failed.' - : 'Security verification failed. $securityMessage', - child: Stack( - children: [ - const ModalBarrier( - dismissible: false, - color: Colors.black, - semanticsLabel: 'Security verification failed', - ), - BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - color: Colors.black.withValues(alpha: 0.5), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - shape: BoxShape.circle, - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), + ).animate().fadeIn(duration: 300.ms), + + // --- GLOBAL SECURITY BARRIER --- + if (showSecurityBarrier) + Positioned.fill( + child: Semantics( + container: true, + liveRegion: true, + label: securityMessage.isEmpty + ? 'Security verification failed.' + : 'Security verification failed. $securityMessage', + child: Stack( + children: [ + const ModalBarrier( + dismissible: false, + color: Colors.black, + semanticsLabel: 'Security verification failed', + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + shape: BoxShape.circle, + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), ), + child: const Icon( + LucideIcons.shieldAlert, + size: 48, + color: Colors.red, + ), + ) + .animate( + onPlay: (controller) => + controller.repeat(reverse: true), + ) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.1, 1.1), + duration: 1000.ms, ), - child: const Icon( - LucideIcons.shieldAlert, - size: 48, - color: Colors.red, - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.1, 1.1), - duration: 1000.ms, + const SizedBox(height: 32), + Text( + 'Security Verification Failed', + style: GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, ), - const SizedBox(height: 32), - Text( - 'Security Verification Failed', - style: GoogleFonts.manrope( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - securityMessage, - style: GoogleFonts.manrope( - fontSize: 15, - color: Colors.white.withValues(alpha: 0.7), - height: 1.5, + const SizedBox(height: 16), + Text( + securityMessage, + style: GoogleFonts.manrope( + fontSize: 15, + color: Colors.white.withValues(alpha: 0.7), + height: 1.5, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - if (!Platform.isIOS || !isCriticalSecurityFailure) + const SizedBox(height: 48), + if (!Platform.isIOS || !isCriticalSecurityFailure) + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: () async { + if (isCriticalSecurityFailure) { + if (Platform.isAndroid) { + await SystemNavigator.pop(); + } else { + exit(0); + } + return; + } + + // Clear lock and retry + ref.read(apiServiceProvider).clearCaches(); + ref + .read(securityFailureProvider.notifier) + .clearFailure(); + try { + await ref + .read(authProvider.notifier) + .refreshProfile(force: true); + } on Object { + // The 401 interceptor will catch it again if it still fails + } + }, + child: Text( + isCriticalSecurityFailure + ? 'Close App' + : 'Restart App', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + ) + else + Text( + 'Please close the app manually.', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), SizedBox( width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withValues(alpha: 0.3), + ), padding: const EdgeInsets.symmetric( vertical: 16, ), @@ -762,110 +890,50 @@ class _NavigationShellState extends ConsumerState { borderRadius: BorderRadius.circular(16), ), ), - onPressed: () async { - if (isCriticalSecurityFailure) { - if (Platform.isAndroid) { - await SystemNavigator.pop(); - } else { - exit(0); - } - return; - } - - // Clear lock and retry - ref.read(apiServiceProvider).clearCaches(); - ref - .read(securityFailureProvider.notifier) - .clearFailure(); - try { - await ref - .read(authProvider.notifier) - .refreshProfile(force: true); - } on Object { - // The 401 interceptor will catch it again if it still fails - } - }, - child: Text( - isCriticalSecurityFailure - ? 'Close App' - : 'Restart App', + onPressed: () => SupportHelper.contactViaEmail( + subject: + 'Security Failure Report [v${AppConfig.appVersion}]', + customBody: + 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' + '-- SUMMARY --\n' + 'Message: $securityMessage\n\n' + '-- PERSISTENCE --\n' + '${SupportHelper.persistenceMessage}\n', + ), + icon: const Icon(LucideIcons.mail, size: 18), + label: Text( + 'Contact Support', style: GoogleFonts.manrope( - fontSize: 16, fontWeight: FontWeight.w700, - color: Colors.black, ), ), ), - ) - else - Text( - 'Please close the app manually.', - style: GoogleFonts.manrope( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white.withValues(alpha: 0.6), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide( - color: Colors.white.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.symmetric( - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - onPressed: () => SupportHelper.contactViaEmail( - subject: - 'Security Failure Report [v${AppConfig.appVersion}]', - customBody: - 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' - '-- SUMMARY --\n' - 'Message: $securityMessage\n\n' - '-- PERSISTENCE --\n' - '${SupportHelper.persistenceMessage}\n', - ), - icon: const Icon(LucideIcons.mail, size: 18), - label: Text( - 'Contact Support', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w700, - ), - ), ), - ), - if (!isCriticalSecurityFailure) ...[ - const SizedBox(height: 16), - TextButton( - onPressed: () => - ref.read(authProvider.notifier).logout(), - child: Text( - 'Logout of GhostClass', - style: GoogleFonts.manrope( - color: Colors.red, - fontWeight: FontWeight.w600, + if (!isCriticalSecurityFailure) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => + ref.read(authProvider.notifier).logout(), + child: Text( + 'Logout of GhostClass', + style: GoogleFonts.manrope( + color: Colors.red, + fontWeight: FontWeight.w600, + ), ), ), - ), + ], ], - ], + ), ), ), - ), - ], + ], + ), ), - ), - ).animate().fadeIn(duration: 400.ms), - ], - ); + ).animate().fadeIn(duration: 400.ms), + ], + ), // Stack + ); // PopScope } } diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index b7719b61..f2a1a744 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -7,6 +7,7 @@ import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; +import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -69,6 +70,7 @@ class _NotificationsScreenState extends ConsumerState final state = ref.read(notificationsProvider).value; if (state != null && state.hasNextPage && + !state.isFetchingNextPage && !ref.read(notificationsProvider).isLoading) { AppLogger.safeUnawait( notifier.fetchNextPage().catchError((Object e, StackTrace st) { @@ -84,6 +86,16 @@ class _NotificationsScreenState extends ConsumerState Widget build(BuildContext context) { final notificationsAsync = ref.watch(notificationsProvider); final permissionAsync = ref.watch(notificationPermissionProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; + + if (isSyncing) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: const LoadingOverlay(isFullScreen: false, showLogo: false), + ); + } + final isDenied = permissionAsync.when( data: (v) => v, loading: () => false, @@ -168,27 +180,27 @@ class _NotificationsScreenState extends ConsumerState child: notificationsAsync.when( data: (data) => ServiceRefreshIndicator( onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'NotificationsScreen', - refreshProfile: () => ref - .read(authProvider.notifier) - .refreshProfile(force: true), + refreshProfile: () => + authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync( + supabaseToken, + force: true, + ); }, refreshData: () async { - final _ = await ref.refresh( - notificationsProvider.future, - ); + await ref.read(notificationsProvider.future); }, ); } on Object { @@ -214,32 +226,37 @@ class _NotificationsScreenState extends ConsumerState NotificationsState data, ) { if (data.allNotifications.isEmpty) { - return ListView( + return CustomScrollView( controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox(height: MediaQuery.of(context).size.height * 0.2), - Center( - child: Column( - children: [ - Icon( - LucideIcons.bellOff, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - const SizedBox(height: 16), - const Text('All caught up!'), - Text( - 'You have no new notifications.', - style: TextStyle( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.bellOff, + size: 64, color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + ).colorScheme.onSurface.withValues(alpha: 0.2), ), - ), - ], + const SizedBox(height: 16), + const Text('All caught up!'), + Text( + 'You have no new notifications.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), ), ), ], @@ -265,50 +282,64 @@ class _NotificationsScreenState extends ConsumerState unreadRegular.sort(compareNotifications); readNotifications.sort(compareNotifications); - return ListView( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - children: [ - if (unreadConflicts.isNotEmpty) ...[ - _buildSectionHeader(context, 'ACTION REQUIRED', Colors.amber), - const SizedBox(height: 12), - ...unreadConflicts.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), - ), - ), - const SizedBox(height: 24), - ], - if (unreadRegular.isNotEmpty) ...[ - _buildSectionHeader(context, 'UNREAD', Colors.blue), - const SizedBox(height: 12), - ...unreadRegular.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), + final slivers = []; + + void addSection(String title, Color color, List items) { + if (items.isEmpty) return; + slivers + ..add( + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only( + top: slivers.isEmpty ? 16 : 0, + ), + child: _buildSectionHeader(context, title, color), ), ), - const SizedBox(height: 24), - ], - if (readNotifications.isNotEmpty) ...[ - _buildSectionHeader(context, 'EARLIER', Colors.grey), - const SizedBox(height: 12), - ...readNotifications.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), + ) + ..add(const SliverToBoxAdapter(child: SizedBox(height: 12))) + ..add( + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final notification = items[index]; + return _NotificationCard( + notification: notification, + key: ValueKey( + 'notif_${notification.id}_${notification.isRead}', + ), + ); + }, + childCount: items.length, ), ), - ], - if (data.hasNextPage) - const Padding( + ) + ..add(const SliverToBoxAdapter(child: SizedBox(height: 24))); + } + + addSection('ACTION REQUIRED', Colors.amber, unreadConflicts); + addSection('UNREAD', Colors.blue, unreadRegular); + addSection('EARLIER', Colors.grey, readNotifications); + + if (data.hasNextPage) { + slivers.add( + const SliverToBoxAdapter( + child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: CircularProgressIndicator(strokeWidth: 2), ), ), - ], + ), + ); + } + + return CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: slivers, ); } @@ -446,120 +477,125 @@ class _NotificationCard extends ConsumerWidget { iconColor = Colors.blue; } - return InkWell( - onTap: () { - final _ = ref - .read(notificationsProvider.notifier) - .toggleRead(notification.id, wasRead: isRead); - ServiceToast.show( - context, - isRead ? 'Marked as unread' : 'Marked as read', - duration: const Duration(seconds: 2), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isRead - ? Colors.transparent - : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all( + return Semantics( + button: true, + label: + 'Notification: ${notification.title}, ${isRead ? 'read' : 'unread'}. Tap to toggle.', + child: InkWell( + onTap: () { + final _ = ref + .read(notificationsProvider.notifier) + .toggleRead(notification.id, wasRead: isRead); + ServiceToast.show( + context, + isRead ? 'Marked as unread' : 'Marked as read', + duration: const Duration(seconds: 2), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( color: isRead ? Colors.transparent - : Theme.of( - context, - ).colorScheme.outlineVariant.withValues(alpha: 0.1), + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isRead + ? Colors.transparent + : Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.1), + ), + boxShadow: isRead + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - boxShadow: isRead - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 10, - offset: const Offset(0, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isRead) + Container( + margin: const EdgeInsets.only(right: 12, top: 12), + width: 6, + height: 6, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isRead) + ), Container( - margin: const EdgeInsets.only(right: 12, top: 12), - width: 6, - height: 6, + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), + child: Icon(icon, size: 20, color: iconColor), ), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, size: 20, color: iconColor), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - notification.title, - style: GoogleFonts.manrope( - fontSize: 14, - fontWeight: isRead - ? FontWeight.w600 - : FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: isRead ? 0.6 : 1.0), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + notification.title, + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: isRead + ? FontWeight.w600 + : FontWeight.w800, + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: isRead ? 0.6 : 1.0), + ), ), ), - ), - Text( - _formatRelativeTime(notification.createdAt), - style: GoogleFonts.manrope( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + Text( + _formatRelativeTime(notification.createdAt), + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), ), + ], + ), + const SizedBox(height: 4), + Text( + notification.description, + style: GoogleFonts.manrope( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface + .withValues( + alpha: isRead ? 0.4 : 0.6, + ), + height: 1.4, ), - ], - ), - const SizedBox(height: 4), - Text( - notification.description, - style: GoogleFonts.manrope( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withValues( - alpha: isRead ? 0.4 : 0.6, - ), - height: 1.4, ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.05); } - String _formatRelativeTime(String dateString) { + String _formatRelativeTime(DateTime date) { try { - final date = DateTime.parse(dateString); final now = DateTime.now(); final diff = now.difference(date); diff --git a/mobile/lib/screens/profile_dump_screen.dart b/mobile/lib/screens/profile_dump_screen.dart index 22923587..12040db6 100644 --- a/mobile/lib/screens/profile_dump_screen.dart +++ b/mobile/lib/screens/profile_dump_screen.dart @@ -96,19 +96,16 @@ class _ProfileDumpContent extends ConsumerWidget { if (user.ezygoId == null) { return insts.isNotEmpty ? insts.first.name : '—'; } - try { - final searchId = user.ezygoId!.trim(); - return insts - .firstWhere((i) => i.id.toString().trim() == searchId) - .name; - } on Object catch (e) { - AppLogger.e( - 'ProfileDumpScreen: Failed to resolve institution by id', - e, - ); - // Fallback to first available for UI consistency, similar to GhostClassScreen - return insts.isNotEmpty ? insts.first.name : 'Unknown'; - } + final searchId = user.ezygoId!.trim(); + final matched = insts + .where((i) => i.id.toString().trim() == searchId) + .firstOrNull; + if (matched != null) return matched.name; + // ezygoId present but not in the institutions list — fall back gracefully + AppLogger.w( + 'ProfileDumpScreen: No institution found for ezygoId $searchId, falling back to first', + ); + return insts.isNotEmpty ? insts.first.name : 'Unknown'; }, loading: () => '...', error: (err, stack) => 'Error', diff --git a/mobile/lib/screens/profile_screen.dart b/mobile/lib/screens/profile_screen.dart index e54f9bd8..ce5185ec 100644 --- a/mobile/lib/screens/profile_screen.dart +++ b/mobile/lib/screens/profile_screen.dart @@ -37,6 +37,7 @@ class _ProfileScreenState extends ConsumerState String? _selectedGender; DateTime? _selectedBirthDate; bool _isUploadingAvatar = false; + bool _isPickingImage = false; @override void initState() { @@ -116,13 +117,23 @@ class _ProfileScreenState extends ConsumerState } Future _pickAndUploadAvatar() async { - final picker = ImagePicker(); - final image = await picker.pickImage( - source: ImageSource.gallery, - maxWidth: 512, - maxHeight: 512, - imageQuality: 85, - ); + if (_isPickingImage || _isUploadingAvatar) return; + if (mounted) setState(() => _isPickingImage = true); + final XFile? image; + try { + final picker = ImagePicker(); + image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + } on Object catch (e, st) { + AppLogger.e('ProfileScreen: Image picker failed', e, st); + return; + } finally { + if (mounted) setState(() => _isPickingImage = false); + } if (image == null) return; setState(() => _isUploadingAvatar = true); try { diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 86928b65..4e2454ff 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -3,12 +3,14 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/score.dart'; import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/providers/score_provider.dart'; import 'package:ghostclass/providers/ui_state_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; +import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -30,8 +32,10 @@ class _ScoresScreenState extends ConsumerState { @override Widget build(BuildContext context) { final scoreState = ref.watch(scoreProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; - if (scoreState.isLoading) { + if (scoreState.isLoading || isSyncing) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: const LoadingOverlay(isFullScreen: false, showLogo: false), @@ -75,24 +79,24 @@ class _ScoresScreenState extends ConsumerState { ServiceRefreshIndicator( useOverlay: false, onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); + final scoreNotifier = ref.read(scoreProvider.notifier); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'ScoresScreen', - refreshProfile: () => ref - .read(authProvider.notifier) - .refreshProfile(force: true), + refreshProfile: () => + authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync(supabaseToken, force: true); }, - refreshData: () => ref.read(scoreProvider.notifier).refresh(), + refreshData: scoreNotifier.refresh, ); } on Object { if (!context.mounted) rethrow; @@ -202,13 +206,10 @@ class _ScoresScreenState extends ConsumerState { ), loading: () => const SliverFillRemaining(child: SizedBox.shrink()), - error: (err, _) => const SliverFillRemaining( - child: Center( - child: Text( - 'We encountered an error while loading your scores. Please try again later. If the issue persists, please contact us.', - style: TextStyle(color: Colors.redAccent), - textAlign: TextAlign.center, - ), + error: (err, _) => SliverFillRemaining( + child: ServiceErrorView( + error: err, + onRetry: () => ref.invalidate(scoreProvider), ), ), ), @@ -609,7 +610,7 @@ class _ScoreCard extends StatelessWidget { Text( resolved!.isMaxUnresolvable ? ' (max unknown)' - : ' / ${resolved!.maxMark.toStringAsFixed(0)}', + : ' / ${resolved!.maxMark.toStringAsFixed(resolved!.maxMark % 1 == 0 ? 0 : 1)}', style: GoogleFonts.manrope( fontSize: resolved!.isMaxUnresolvable ? 12 : 16, fontWeight: FontWeight.w700, @@ -968,7 +969,7 @@ class _ExamDetailSheetState extends State<_ExamDetailSheet> { Text( widget.resolved!.isMaxUnresolvable ? ' (max unknown)' - : ' / ${widget.resolved!.maxMark.toStringAsFixed(0)}', + : ' / ${widget.resolved!.maxMark.toStringAsFixed(widget.resolved!.maxMark % 1 == 0 ? 0 : 1)}', style: GoogleFonts.manrope( fontSize: widget.resolved!.isMaxUnresolvable ? 11 @@ -1062,7 +1063,7 @@ class _QuestionRow extends StatelessWidget { ), const SizedBox(width: 4), Text( - '/${question.maximumMark.toStringAsFixed(0)}', + '/${question.maximumMark.toStringAsFixed(question.maximumMark % 1 == 0 ? 0 : 1)}', style: GoogleFonts.manrope( fontSize: 11, fontWeight: FontWeight.bold, diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index e8a1dfe3..10e0fb98 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -25,6 +25,7 @@ import 'package:ghostclass/services/jwe_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/push_notification_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; +import 'package:ghostclass/services/security_guard.dart'; import 'package:ghostclass/services/security_service.dart'; import 'package:ghostclass/services/startup_flow_service.dart'; import 'package:ghostclass/widgets/app_update_dialog.dart'; @@ -49,10 +50,6 @@ class SplashScreen extends ConsumerStatefulWidget { } class _SplashScreenState extends ConsumerState { - static Future<_StartupSnapshot>? _startupInFlight; - static _StartupSnapshot? _startupCache; - static DateTime? _startupCacheAt; - static String? _startupCacheSessionKey; static const Duration _startupCacheTtl = Duration(seconds: 20); bool _pushInitTriggered = false; @@ -63,22 +60,34 @@ class _SplashScreenState extends ConsumerState { return session?.user.id ?? 'anon'; } - bool _canUseStartupCache(String sessionKey) { - final cachedAt = _startupCacheAt; - if (_startupCache == null || cachedAt == null) return false; - if (_startupCacheSessionKey != sessionKey) return false; + bool _canUseStartupCache(String sessionKey, StartupFlowService startupFlow) { + final cachedAt = startupFlow.startupCacheAt; + final cache = startupFlow.startupCache; + if (cache == null || cache is! _StartupSnapshot || cachedAt == null) { + return false; + } + if (startupFlow.startupCacheSessionKey != sessionKey) return false; return DateTime.now().difference(cachedAt) <= _startupCacheTtl; } Future<_StartupSnapshot> _runStartupChecksSingleFlight() { final sessionKey = _currentSessionKey(); - if (_canUseStartupCache(sessionKey)) { - return Future<_StartupSnapshot>.value(_startupCache); + final startupFlow = ref.read(startupFlowServiceProvider); + + final cache = startupFlow.startupCache; + if (cache is _StartupSnapshot && + _canUseStartupCache(sessionKey, startupFlow)) { + return Future<_StartupSnapshot>.value(cache); } - final inFlight = _startupInFlight; - if (inFlight != null && _startupCacheSessionKey == sessionKey) { - return inFlight; + final inFlight = startupFlow.startupInFlight; + if (inFlight != null && startupFlow.startupCacheSessionKey == sessionKey) { + return inFlight.then((val) { + if (val is _StartupSnapshot) return val; + throw StateError( + 'Invalid in-flight startup snapshot type: ${val.runtimeType}', + ); + }); } final api = ref.read(apiServiceProvider); @@ -130,11 +139,26 @@ class _SplashScreenState extends ConsumerState { authStack = st; }); + Object? jweError; + StackTrace? jweStack; + + final jweTask = JweService.instance + .preWarm() + .then((_) { + AppLogger.i('SplashScreen: jweTask completed'); + }) + .catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: jweTask failed', e, st); + jweError = e; + jweStack = st; + }); + api.clearCaches(); AppLogger.i('SplashScreen: Awaiting Future.wait...'); await Future.wait([ integrityTask, authTask, + jweTask, ]); AppLogger.i('SplashScreen: Future.wait completed'); @@ -147,22 +171,27 @@ class _SplashScreenState extends ConsumerState { if (authError != null) { Error.throwWithStackTrace(authError!, authStack ?? StackTrace.current); } + if (jweError != null) { + Error.throwWithStackTrace(jweError!, jweStack ?? StackTrace.current); + } return _StartupSnapshot(user: user, versionResult: versionResult); }(); - _startupCacheSessionKey = sessionKey; - _startupInFlight = future; + startupFlow + ..startupCacheSessionKey = sessionKey + ..startupInFlight = future; return future .then((snapshot) { - _startupCache = snapshot; - _startupCacheAt = DateTime.now(); + startupFlow + ..startupCache = snapshot + ..startupCacheAt = DateTime.now(); return snapshot; }) .whenComplete(() { - if (identical(_startupInFlight, future)) { - _startupInFlight = null; + if (identical(startupFlow.startupInFlight, future)) { + startupFlow.startupInFlight = null; } }); } @@ -225,13 +254,7 @@ class _SplashScreenState extends ConsumerState { } void _startPostNavigationPreloads({ - required bool isDashboard, required ApiService apiService, - required Future dashboardFuture, - required Future trackingFuture, - required Future leaveFuture, - required Future scoreFuture, - required Future notificationsFuture, }) { AppLogger.safeUnawait( JweService.instance.preWarm().catchError( @@ -247,28 +270,9 @@ class _SplashScreenState extends ConsumerState { }), 'SplashScreen: post-nav API pre-warm', ); - - if (isDashboard) { - _prewarmAppData( - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, - ); - } } Future _initializeApp() async { - // Start JWE key warm-up early so attestation requests can reuse prepared - // key material, but do not block startup on this. - AppLogger.safeUnawait( - JweService.instance.preWarm().catchError((Object e, StackTrace st) { - AppLogger.e('SplashScreen: early JWE pre-warm failed', e, st); - }), - 'SplashScreen: early JWE pre-warm', - ); - // Keep the splash visible for 1.5s to improve perceived startup time final splashHold = Future.delayed( const Duration(milliseconds: 1500), @@ -371,27 +375,8 @@ class _SplashScreenState extends ConsumerState { ); final isConnectionOrQuota = - (appCheckError != null && - (appCheckError.toLowerCase().contains('quota') || - appCheckError.toLowerCase().contains('connection') || - appCheckError.toLowerCase().contains('timeout') || - appCheckError.toLowerCase().contains('too_many_attempts') || - appCheckError.toLowerCase().contains('network') || - appCheckError.toLowerCase().contains('rate limit') || - appCheckError.toLowerCase().contains('server') || - appCheckError.toLowerCase().contains('internal error') || - appCheckError.toLowerCase().contains('-12') || - appCheckError.toLowerCase().contains('unavailable'))) || - (reason.toLowerCase().contains('quota') || - reason.toLowerCase().contains('connection') || - reason.toLowerCase().contains('timeout') || - reason.toLowerCase().contains('too_many_attempts') || - reason.toLowerCase().contains('network') || - reason.toLowerCase().contains('rate limit') || - reason.toLowerCase().contains('server') || - reason.toLowerCase().contains('internal error') || - reason.toLowerCase().contains('-12') || - reason.toLowerCase().contains('unavailable')); + SecurityService.isTransientAppCheckFailureText(appCheckError) || + SecurityService.isTransientAppCheckFailureText(reason); final isGenuineSecurityFailure = !isConnectionOrQuota; @@ -422,12 +407,14 @@ class _SplashScreenState extends ConsumerState { '$e\n\n' '${appCheckError != null ? "Local Error: $appCheckError" : ""}', ), - retryLabel: Platform.isAndroid - ? (isCritical ? 'Close App' : 'Restart App') - : (isCritical ? null : 'Retry'), - onRetry: Platform.isAndroid - ? SystemNavigator.pop - : (isCritical ? null : _beginInitializeIfIdle), + retryLabel: isCritical + ? 'Close App' + : (Platform.isAndroid ? 'Restart App' : 'Retry'), + onRetry: isCritical + ? () => ref.read(securityGuardProvider).wipeAndExit() + : (Platform.isAndroid + ? SystemNavigator.pop + : _beginInitializeIfIdle), ); return; } @@ -477,14 +464,14 @@ class _SplashScreenState extends ConsumerState { final supabaseClient = ref.read(supabaseClientProvider); final token = supabaseClient.auth.currentSession?.accessToken; - final dashboardFuture = ref.read(dashboardProvider.future); - final trackingFuture = ref.read(trackingProvider.future); - final leaveFuture = ref.read(leaveProvider.future); - final scoreFuture = ref.read(scoreProvider.future); - final notificationsFuture = ref.read(notificationsProvider.future); - if (finalUser != null) { if (finalUser.termsAccepted) { + final dashboardFuture = ref.read(dashboardProvider.future); + final trackingFuture = ref.read(trackingProvider.future); + final leaveFuture = ref.read(leaveProvider.future); + final scoreFuture = ref.read(scoreProvider.future); + final notificationsFuture = ref.read(notificationsProvider.future); + _startPushInitInBackgroundAfterSplash(pushService); context.go('/dashboard'); AppLogger.safeUnawait( @@ -500,13 +487,7 @@ class _SplashScreenState extends ConsumerState { notificationsFuture: notificationsFuture, ); _startPostNavigationPreloads( - isDashboard: true, apiService: apiService, - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, ); }).catchError((Object e, StackTrace st) { AppLogger.e('SplashScreen: post-dashboard preloads failed', e, st); @@ -519,13 +500,7 @@ class _SplashScreenState extends ConsumerState { AppLogger.safeUnawait( Future.microtask(() async { _startPostNavigationPreloads( - isDashboard: false, apiService: apiService, - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, ); }).catchError((Object e, StackTrace st) { AppLogger.e( @@ -543,13 +518,7 @@ class _SplashScreenState extends ConsumerState { AppLogger.safeUnawait( Future.microtask(() async { _startPostNavigationPreloads( - isDashboard: false, apiService: apiService, - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, ); }).catchError((Object e, StackTrace st) { AppLogger.e('SplashScreen: post-login preloads failed', e, st); @@ -604,16 +573,20 @@ class _SplashScreenState extends ConsumerState { curve: Curves.easeOutCubic, ) .then() // Chain effects after the entrance - .animate(onPlay: (controller) => controller.repeat(reverse: true)) + .animate( + onPlay: (controller) => controller.repeat( + reverse: true, + period: 1200.ms, + ), + ) .scale( begin: const Offset(1, 1), end: const Offset(1.05, 1.05), - duration: 500.ms, + duration: 1200.ms, curve: Curves.easeInOut, ) - .animate(onPlay: (controller) => controller.repeat()) .shimmer( - duration: 600.ms, + duration: 1200.ms, color: Colors.white.withValues(alpha: 0.3), ), ), diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index 37459ebe..ceb636b7 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -8,6 +8,7 @@ import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/providers/tracking_ui_provider.dart'; import 'package:ghostclass/services/api_service.dart'; @@ -43,8 +44,10 @@ class _TrackingScreenState extends ConsumerState final dashboardAsync = ref.watch(dashboardProvider); final data = trackingState.value; final dashboard = dashboardAsync.value; + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; - if (trackingState.isLoading) { + if (trackingState.isLoading || isSyncing) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: const LoadingOverlay(isFullScreen: false, showLogo: false), @@ -115,23 +118,23 @@ class _TrackingScreenState extends ConsumerState return ServiceRefreshIndicator( onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); + final trackingNotifier = ref.read(trackingProvider.notifier); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'TrackingScreen', - refreshProfile: () => - ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync(supabaseToken, force: true); }, - refreshData: () => ref.read(trackingProvider.notifier).refresh(), + refreshData: trackingNotifier.refresh, ); } on Object catch (e, st) { AppLogger.e('TrackingScreen: Pull-to-refresh failed', e, st); @@ -198,6 +201,17 @@ class _TrackingScreenState extends ConsumerState ], ), const SizedBox(height: 16), + Text( + 'These are custom-marked attendance records or the absences you have marked for re-checking or duty leave.', + style: GoogleFonts.manrope( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -218,17 +232,6 @@ class _TrackingScreenState extends ConsumerState ), ], ), - const SizedBox(height: 12), - Text( - 'These are custom-marked attendance records or the absences you have marked for re-checking or duty leave.', - style: GoogleFonts.manrope( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - fontWeight: FontWeight.w500, - ), - ), ], ), ), diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 7ab3d099..52ce3ea5 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/logic/app_exception.dart'; import 'package:ghostclass/logic/error_utils.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/outage_provider.dart'; import 'package:ghostclass/services/auth_service.dart'; import 'package:ghostclass/services/dio_service.dart'; @@ -12,6 +13,7 @@ import 'package:ghostclass/services/ezygo_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/security_service.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; /// ApiService /// ---------- @@ -36,8 +38,9 @@ class ApiService { // --- GhostClass Sync State --- Future>? _syncInFlight; - DateTime? _lastSyncTime; + DateTime? _lastSyncAt; static const _syncCooldown = Duration(minutes: 5); + static const _forcedSyncGracePeriod = Duration(seconds: 15); void clearCaches() => _ezygo.clearCaches(); @@ -159,9 +162,22 @@ class ApiService { // 3. Throttling final now = DateTime.now(); + if (force && + _lastSyncAt != null && + now.difference(_lastSyncAt!) < _forcedSyncGracePeriod) { + AppLogger.d( + 'ApiService: Skipping forced sync due to recent startup sync.', + ); + return Response( + requestOptions: RequestOptions(path: 'sync'), + statusCode: 304, + data: {'message': 'Recently synced'}, + ); + } + if (!force && - _lastSyncTime != null && - now.difference(_lastSyncTime!) < _syncCooldown) { + _lastSyncAt != null && + now.difference(_lastSyncAt!) < _syncCooldown) { AppLogger.d('ApiService: Sync throttled.'); return Response( requestOptions: RequestOptions(path: 'sync'), @@ -180,7 +196,7 @@ class ApiService { receiveTimeout: const Duration(seconds: 30), ), ); - _lastSyncTime = DateTime.now(); + _lastSyncAt = DateTime.now(); return response; } on Object catch (e) { AppLogger.e('ApiService: Background sync failed', e); @@ -237,6 +253,16 @@ class ApiService { ); } + Future> fetchClassCourses(String classId) async { + final supabase = _ref.read(supabaseClientProvider); + return supabase.from('class_courses').select().eq('class_id', classId); + } + + Future> fetchCourseInstructors(String classId) async { + final supabase = _ref.read(supabaseClientProvider); + return supabase.from('course_instructors').select().eq('class_id', classId); + } + // --- Error Handling --- bool _isTransientAppCheckFailure(String? text) { final msg = (text ?? '').toLowerCase(); diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 7d0642e9..95ce4429 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -25,15 +25,25 @@ class AuthService { if (ezygoResponse.statusCode != 200) return ezygoResponse; final data = ezygoResponse.data as Map?; - final ezygoToken = data?['token'] ?? data?['access_token']; - if (ezygoToken?.toString().isEmpty ?? true) { + final rawToken = data?['token'] ?? data?['access_token']; + final ezygoToken = rawToken is String ? rawToken : rawToken?.toString(); + if (ezygoToken == null || ezygoToken.trim().isEmpty) { throw const AppException( message: 'Portal returned no token.', type: AppExceptionType.unauthorized, ); } - return provisionGhostClassSession(ezygoToken.toString()); + final bridgeResponse = await provisionGhostClassSession(ezygoToken); + final bridgeData = bridgeResponse.data; + if (bridgeData is Map) { + bridgeResponse.data = { + ...bridgeData, + 'ezygo_token': ezygoToken.trim(), + }; + } + + return bridgeResponse; } Future> loginEzygo(String username, String password) async { diff --git a/mobile/lib/services/ezygo_service.dart b/mobile/lib/services/ezygo_service.dart index 451b6310..e51bab1a 100644 --- a/mobile/lib/services/ezygo_service.dart +++ b/mobile/lib/services/ezygo_service.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/logic/app_exception.dart'; import 'package:ghostclass/logic/ezygo_batch_fetcher.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/outage_provider.dart'; import 'package:ghostclass/services/dio_service.dart'; import 'package:ghostclass/services/logger.dart'; @@ -14,24 +15,28 @@ import 'package:ghostclass/services/secure_storage.dart'; /// and deduplication to optimize performance and reduce backend load. class EzygoService { EzygoService(this._ref) { - _fetcher = EzygoBatchFetcher( - _ref.read(dioServiceProvider).dio, - getOutage: () => _ref.read(outageProvider), - setOutage: (v) => _ref.read(outageProvider.notifier).update(v), - isBackendUnauthorized: () => false, - ); + _fetcher = _ref.read(ezygoBatchFetcherProvider); } final Ref _ref; late final EzygoBatchFetcher _fetcher; static final String _ezygoApiRoot = AppConfig.ezygoApiRoot; + String _requireEzygoToken(String? token) { + if (token == null) { + throw const AppException( + message: 'No EzyGo credentials found. Please log in.', + type: AppExceptionType.unauthorized, + ); + } + return token; + } + void clearCaches() => _fetcher.clearAll(); Future> fetchCourses(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionuser/courses/withusers'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchAttendanceReportDetailed( @@ -39,18 +44,10 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/attendancereports/student/detailed'; - if (token == null) { - return _ref - .read(dioServiceProvider) - .dio - .post( - path, - data: {}, - ); - } + _requireEzygoToken(token); return _fetcher.fetch( path: path, - token: token, + token: token!, method: 'POST', data: {}, ); @@ -61,8 +58,7 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionusers/myinstitutions'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> updateDefaultInstitution( @@ -122,8 +118,7 @@ class EzygoService { Future> fetchSemester(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_semester'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchAcademicYear( @@ -131,8 +126,7 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_academic_year'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future _fetchWithCache( @@ -141,7 +135,7 @@ class EzygoService { SecureStorageService storage, { Duration ttl = const Duration(days: 7), }) async { - final cacheKey = 'ezygo_static_${path.hashCode}'; + final cacheKey = 'ezygo_static_${Uri.encodeFull(path)}'; try { final cached = await storage.getCachedData(cacheKey); if (cached != null) { @@ -225,8 +219,7 @@ class EzygoService { Future> fetchExams(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchExamQuestions( @@ -236,8 +229,7 @@ class EzygoService { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/examquestions?from_view_score=true'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchExamAnswers( @@ -246,9 +238,31 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/institutionuser/examanswers'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } } -final ezygoServiceProvider = Provider(EzygoService.new); +final ezygoBatchFetcherProvider = Provider((ref) { + // Watch supabaseUserId to automatically invalidate and recreate the fetcher on session login/logout boundaries + ref.watch( + authProvider.select((asyncUser) => asyncUser.value?.supabaseUserId), + ); + + final fetcher = EzygoBatchFetcher( + ref.read(dioServiceProvider).dio, + getOutage: () => ref.read(outageProvider), + setOutage: (v) => ref.read(outageProvider.notifier).update(v), + isBackendUnauthorized: () => false, + ); + + ref.onDispose(() { + fetcher.clearAll(setOutageState: false); + }); + + return fetcher; +}); + +final ezygoServiceProvider = Provider((ref) { + ref.watch(ezygoBatchFetcherProvider); + return EzygoService(ref); +}); diff --git a/mobile/lib/services/jwe_interceptor.dart b/mobile/lib/services/jwe_interceptor.dart index 6666e2a2..a9cda053 100644 --- a/mobile/lib/services/jwe_interceptor.dart +++ b/mobile/lib/services/jwe_interceptor.dart @@ -16,9 +16,9 @@ class JweInterceptor extends Interceptor { JweService get _jweService => _serviceOverride ?? JweService.instance; // Use a map to store RCEKs for concurrent requests with self-pruning timestamps - static final Map _rcekMap = {}; + final Map _rcekMap = {}; - static void _pruneExpiredRceks() { + void _pruneExpiredRceks() { final now = DateTime.now(); // Prune entries older than 2 minutes (120 seconds) to prevent any unbounded leak _rcekMap.removeWhere( diff --git a/mobile/lib/services/logger.dart b/mobile/lib/services/logger.dart index d4afcffd..42321e43 100644 --- a/mobile/lib/services/logger.dart +++ b/mobile/lib/services/logger.dart @@ -100,11 +100,14 @@ class AppLogger { static void safeUnawait(Future future, [String? context]) { unawaited( future.catchError( - (Object e, StackTrace st) => AppLogger.e( - 'SafeUnawait${context != null ? ': $context' : ''}', - e, - st, - ), + (Object e, StackTrace st) { + AppLogger.e( + 'SafeUnawait${context != null ? ': $context' : ''}', + e, + st, + ); + return null; + }, ), ); } diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart index 7e9eb5d8..e9a7bdf9 100644 --- a/mobile/lib/services/push_notification_service.dart +++ b/mobile/lib/services/push_notification_service.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/providers/auth_provider.dart'; @@ -59,6 +60,10 @@ class PushNotificationService { await _tokenSub?.cancel(); await _messageSub?.cancel(); await _messageOpenedSub?.cancel(); + await _deferredAuthSub?.cancel(); + _deferredAuthTimer?.cancel(); + _deferredAuthSub = null; + _deferredAuthTimer = null; // Request permissions natively on iOS and Android targets final settings = await _messaging.requestPermission(); @@ -133,14 +138,18 @@ class PushNotificationService { ?.overlay ?.context; if (context != null && context.mounted) { - ServiceToast.showNotification( - context, - title: notification.title!, - body: notification.body ?? '', - onTap: () { - router.go('/notifications'); - }, - ); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + ServiceToast.showNotification( + context, + title: notification.title!, + body: notification.body ?? '', + onTap: () { + router.go('/notifications'); + }, + ); + } + }); } } on Object catch (e) { AppLogger.e( @@ -264,15 +273,7 @@ class PushNotificationService { if (session != null) { try { final accessToken = session.accessToken; - final baseUrl = AppConfig.ghostclassApiUrl; - final response = await _dio.post( - '$baseUrl/auth/register-fcm', - data: {'fcm_token': token.trim()}, - options: Options( - headers: {'Authorization': 'Bearer $accessToken'}, - validateStatus: (s) => s != null && s < 600, - ), - ); + final response = await _registerTokenHttp(token, accessToken); if (response.statusCode == 200) { AppLogger.i('FCM push token registered on auth event'); try { @@ -328,16 +329,7 @@ class PushNotificationService { } final accessToken = currentSession.accessToken; - final baseUrl = AppConfig.ghostclassApiUrl; - - final response = await _dio.post( - '$baseUrl/auth/register-fcm', - data: {'fcm_token': token.trim()}, - options: Options( - headers: {'Authorization': 'Bearer $accessToken'}, - validateStatus: (s) => s != null && s < 600, - ), - ); + final response = await _registerTokenHttp(token, accessToken); if (response.statusCode == 200) { AppLogger.i('FCM push token securely registered with backend services'); @@ -406,6 +398,21 @@ class PushNotificationService { } } + Future> _registerTokenHttp( + String token, + String accessToken, + ) { + final baseUrl = AppConfig.ghostclassApiUrl; + return _dio.post( + '$baseUrl/auth/register-fcm', + data: {'fcm_token': token.trim()}, + options: Options( + headers: {'Authorization': 'Bearer $accessToken'}, + validateStatus: (s) => s != null && s < 600, + ), + ); + } + void dispose() { unawaited(_tokenSub?.cancel()); unawaited(_messageSub?.cancel()); diff --git a/mobile/lib/services/refresh_coordinator.dart b/mobile/lib/services/refresh_coordinator.dart index 601d6f51..3e649750 100644 --- a/mobile/lib/services/refresh_coordinator.dart +++ b/mobile/lib/services/refresh_coordinator.dart @@ -1,11 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:ghostclass/services/logger.dart'; Future runUnifiedPullToRefresh({ + required VoidCallback invalidateNotifications, required Future Function() refreshProfile, required Future Function() refreshData, Future Function()? syncCron, String logLabel = 'PullToRefresh', }) async { + invalidateNotifications(); await refreshProfile(); final syncFuture = syncCron == null @@ -15,7 +18,6 @@ Future runUnifiedPullToRefresh({ await syncCron(); } on Object catch (e, st) { AppLogger.e('$logLabel: cron sync failed', e, st); - rethrow; } }(); diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index 171e61d2..5472f20d 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -59,7 +59,10 @@ class SecureStorageService { } on Object catch (e, st) { AppLogger.e('SecureStorage: Error during read of key: $key', e, st); await _selfHeal(e); - return null; + if (_isUnrecoverableError(e)) { + return null; + } + rethrow; } } @@ -82,9 +85,22 @@ class SecureStorageService { } } + bool _isUnrecoverableError(Object error) { + final errStr = error.toString(); + return errStr.contains('storage_key_error') || + errStr.contains('KeyStoreException') || + errStr.contains('javax.crypto.AEADBadTagException'); + } + Future _selfHeal(Object error) async { + if (!_isUnrecoverableError(error)) { + AppLogger.e( + 'SecureStorage: Transient error detected ($error), skipping self-heal.', + ); + return; + } AppLogger.e( - 'SecureStorage: Executing self-healing routine due to exception: $error', + 'SecureStorage: Executing self-healing routine due to unrecoverable exception: $error', ); try { await _storage.deleteAll(); @@ -288,6 +304,20 @@ class SecureStorageService { /// Deletes every key managed by this service. Should be called on logout. Future clearAll() => _safeDeleteAll(); + + /// Deletes all cached keys starting with "cache_" from secure storage. + Future clearAllCachedData() async { + try { + final all = await _storage.readAll(); + await Future.wait( + all.keys + .where((key) => key.startsWith('cache_')) + .map((key) => _safeDelete(key: key)), + ); + } on Object catch (e) { + AppLogger.e('SecureStorage: Error clearing cached data', e); + } + } } final secureStorageProvider = Provider( diff --git a/mobile/lib/services/security_service.dart b/mobile/lib/services/security_service.dart index 7fd63c84..d21fdfbd 100644 --- a/mobile/lib/services/security_service.dart +++ b/mobile/lib/services/security_service.dart @@ -41,13 +41,15 @@ class SecurityService { static const Duration _cachedAttestationMaxAge = Duration(hours: 6); static const Duration _blockingAttestationMaxAge = Duration(days: 7); + late final Dio _cachedDio = _ref.read(dioServiceProvider).dio; + Dio get _dio { if (_disposed) { throw StateError( 'Cannot use SecurityService after it has been disposed.', ); } - return _ref.read(dioServiceProvider).dio; + return _cachedDio; } bool _isVersionOlder(String current, String target) { @@ -70,7 +72,8 @@ class SecurityService { return false; } - bool _isTransientAppCheckFailureText(String text) { + static bool isTransientAppCheckFailureText(String? text) { + if (text == null) return false; final msg = text.toLowerCase(); return msg.contains('quota') || msg.contains('connection') || @@ -89,8 +92,7 @@ class SecurityService { if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.sendTimeout || e.type == DioExceptionType.receiveTimeout || - e.type == DioExceptionType.connectionError || - e.type == DioExceptionType.badCertificate) { + e.type == DioExceptionType.connectionError) { return true; } final statusCode = e.response?.statusCode; @@ -106,17 +108,24 @@ class SecurityService { final reason = (e.details?['reason'] as String?) ?? e.message; final appCheckError = e.details?['appCheckError'] as String?; final isConnectionOrQuota = - (appCheckError != null && - _isTransientAppCheckFailureText(appCheckError)) || - _isTransientAppCheckFailureText(reason); + isTransientAppCheckFailureText(appCheckError) || + isTransientAppCheckFailureText(reason); if (isConnectionOrQuota) { return true; } } final msg = e.toString().toLowerCase(); - if (msg.contains('socketexception') || + + // Security/certificate/handshake validation failures are not transient. + if (msg.contains('badcertificate') || msg.contains('handshakeexception') || + msg.contains('certverify') || + msg.contains('certificate')) { + return false; + } + + if (msg.contains('socketexception') || msg.contains('network') || msg.contains('connection') || msg.contains('timeout')) { @@ -272,27 +281,8 @@ class SecurityService { final appCheckError = e.details?['appCheckError'] as String?; final isConnectionOrQuota = - (appCheckError != null && - (appCheckError.toLowerCase().contains('quota') || - appCheckError.toLowerCase().contains('connection') || - appCheckError.toLowerCase().contains('timeout') || - appCheckError.toLowerCase().contains('too_many_attempts') || - appCheckError.toLowerCase().contains('network') || - appCheckError.toLowerCase().contains('rate limit') || - appCheckError.toLowerCase().contains('server') || - appCheckError.toLowerCase().contains('internal error') || - appCheckError.toLowerCase().contains('-12') || - appCheckError.toLowerCase().contains('unavailable'))) || - (reason.toLowerCase().contains('quota') || - reason.toLowerCase().contains('connection') || - reason.toLowerCase().contains('timeout') || - reason.toLowerCase().contains('too_many_attempts') || - reason.toLowerCase().contains('network') || - reason.toLowerCase().contains('rate limit') || - reason.toLowerCase().contains('server') || - reason.toLowerCase().contains('internal error') || - reason.toLowerCase().contains('-12') || - reason.toLowerCase().contains('unavailable')); + isTransientAppCheckFailureText(appCheckError) || + isTransientAppCheckFailureText(reason); final isGenuineSecurityFailure = !isConnectionOrQuota; diff --git a/mobile/lib/services/startup_flow_service.dart b/mobile/lib/services/startup_flow_service.dart index 2c0e6414..7bd28345 100644 --- a/mobile/lib/services/startup_flow_service.dart +++ b/mobile/lib/services/startup_flow_service.dart @@ -7,6 +7,12 @@ class StartupFlowService { DateTime? _postLoginMarkedAt; static const Duration _postLoginFastPathTtl = Duration(seconds: 45); + // SplashScreen startup checks cache + Object? startupCache; + DateTime? startupCacheAt; + String? startupCacheSessionKey; + Future? startupInFlight; + void markPostLoginFastPath(String sessionKey) { _postLoginSessionKey = sessionKey; _postLoginMarkedAt = DateTime.now(); diff --git a/mobile/lib/widgets/about/attestation_section.dart b/mobile/lib/widgets/about/attestation_section.dart index 1f9621ea..815f60c7 100644 --- a/mobile/lib/widgets/about/attestation_section.dart +++ b/mobile/lib/widgets/about/attestation_section.dart @@ -20,16 +20,47 @@ class AttestationSection extends ConsumerStatefulWidget { ConsumerState createState() => _AttestationSectionState(); } +class AboutAttestationState { + const AboutAttestationState({this.data, this.lastVerifiedAt}); + final Map? data; + final DateTime? lastVerifiedAt; +} + +class AboutAttestationNotifier extends Notifier { + @override + AboutAttestationState? build() => null; + + void update(AboutAttestationState newState) { + if (state != newState) { + state = newState; + } + } +} + +final aboutAttestationProvider = + NotifierProvider( + AboutAttestationNotifier.new, + ); + class _AttestationSectionState extends ConsumerState { bool _isLoading = false; Map? _data; String? _error; + DateTime? _lastVerifiedAt; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - final _ = _verify(); + final cached = ref.read(aboutAttestationProvider); + if (cached != null) { + setState(() { + _data = cached.data; + _lastVerifiedAt = cached.lastVerifiedAt; + }); + } else { + final _ = _verify(); + } }); } @@ -49,7 +80,20 @@ class _AttestationSectionState extends ConsumerState { if (response.statusCode == 200) { if (mounted) { - setState(() => _data = response.data as Map?); + final now = DateTime.now(); + final data = response.data as Map?; + setState(() { + _data = data; + _lastVerifiedAt = now; + }); + ref + .read(aboutAttestationProvider.notifier) + .update( + AboutAttestationState( + data: data, + lastVerifiedAt: now, + ), + ); } } else { if (mounted) { @@ -173,19 +217,40 @@ class _AttestationSectionState extends ConsumerState { ), const SizedBox(height: 8), Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon( - LucideIcons.shieldCheck, - size: 10, - color: theme.colorScheme.onSecondary, + Row( + children: [ + Icon( + LucideIcons.shieldCheck, + size: 10, + color: theme.colorScheme.onSecondary, + ), + const SizedBox(width: 4), + Text( + 'Last verified: ${_lastVerifiedAt != null ? "${_lastVerifiedAt!.hour}:${_lastVerifiedAt!.minute.toString().padLeft(2, '0')}" : "Never"}', + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSecondary, + ), + ), + ], ), - const SizedBox(width: 4), - Text( - 'Last verified: ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}', - style: GoogleFonts.manrope( - fontSize: 10, - fontWeight: FontWeight.w700, - color: theme.colorScheme.onSecondary, + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _isLoading ? null : _verify, + icon: const Icon(LucideIcons.refreshCw, size: 10), + label: Text( + 'Verify Again', + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w700, + ), ), ), ], diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 6a72670a..95f666cc 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -284,12 +284,12 @@ class _AddAttendanceDialogState extends ConsumerState { const AttendanceDialogLabel(text: 'Session'), _buildSessionSelector(primary), if (_isBlocked) - const Padding( - padding: EdgeInsets.only(top: 6, left: 4), + Padding( + padding: const EdgeInsets.only(top: 6, left: 4), child: Text( 'Session occupied', style: TextStyle( - color: Colors.redAccent, + color: Theme.of(context).colorScheme.error, fontSize: 11, fontWeight: FontWeight.w600, ), @@ -722,12 +722,14 @@ class _AddAttendanceDialogState extends ConsumerState { ], ), ), - if (isSelected) + if (isSelected) ...[ + const SizedBox(width: 12), Icon( LucideIcons.checkCircle2, color: primary, size: 18, ), + ], ], ), ), diff --git a/mobile/lib/widgets/aesthetic_refresh_indicator.dart b/mobile/lib/widgets/aesthetic_refresh_indicator.dart index 69793826..80128ef9 100644 --- a/mobile/lib/widgets/aesthetic_refresh_indicator.dart +++ b/mobile/lib/widgets/aesthetic_refresh_indicator.dart @@ -32,6 +32,12 @@ class _AestheticRefreshIndicatorState extends State { double _pullDistance = 0; bool _isRefreshing = false; + /// True only when the current drag gesture started while the list was + /// already at (or above) the very top. This prevents fast upward flings + /// from the middle/bottom of the list accidentally triggering a refresh + /// when they overshoot past pixels = 0. + bool _dragStartedAtTop = false; + void _safeSetState(VoidCallback fn) { if (mounted) { setState(fn); @@ -101,14 +107,25 @@ class _AestheticRefreshIndicatorState extends State { final metrics = notification.metrics; + // Track whether each new drag gesture started from the very top of the + // scroll view. Only gestures that begin at the top are eligible to + // trigger pull-to-refresh. A fast upward fling from the middle/bottom + // may overshoot past pixels=0, but _dragStartedAtTop will be false, so + // the overscroll is ignored. + if (notification is ScrollStartNotification) { + _dragStartedAtTop = metrics.pixels <= 0; + } + if (notification is ScrollUpdateNotification && !_isRefreshing) { - if (metrics.pixels < 0) { - final distance = (metrics.pixels.abs() / 100).clamp(0.0, 1.0); + if (_dragStartedAtTop && metrics.pixels < 0) { + // Divisor 160 → full progress at 160 px; threshold 0.85 → triggers at ~136 px. + final distance = (metrics.pixels.abs() / 160).clamp(0.0, 1.0); _safeSetState(() => _pullDistance = distance); // Trigger refresh exactly when user releases (dragDetails becomes null) // and we are past the threshold. - if (notification.dragDetails == null && distance >= 0.8) { + if (notification.dragDetails == null && distance >= 0.85) { + _dragStartedAtTop = false; final _ = _handleRefresh(); } } else if (_pullDistance > 0) { @@ -117,11 +134,12 @@ class _AestheticRefreshIndicatorState extends State { } if (notification is OverscrollNotification && !_isRefreshing) { - if (metrics.pixels < 0) { - final distance = (metrics.pixels.abs() / 100).clamp(0.0, 1.0); + if (_dragStartedAtTop && metrics.pixels < 0) { + final distance = (metrics.pixels.abs() / 160).clamp(0.0, 1.0); _safeSetState(() => _pullDistance = distance); - if (notification.dragDetails == null && distance >= 0.8) { + if (notification.dragDetails == null && distance >= 0.85) { + _dragStartedAtTop = false; final _ = _handleRefresh(); } } @@ -130,12 +148,14 @@ class _AestheticRefreshIndicatorState extends State { if (notification is UserScrollNotification && notification.direction == ScrollDirection.idle) { if (!_isRefreshing) { + _dragStartedAtTop = false; _safeSetState(() => _pullDistance = 0.0); } } if (notification is ScrollEndNotification) { if (!_isRefreshing) { + _dragStartedAtTop = false; _safeSetState(() => _pullDistance = 0.0); } } diff --git a/mobile/lib/widgets/calendar/calendar_session_card.dart b/mobile/lib/widgets/calendar/calendar_session_card.dart index 1f9e5213..bdf6e870 100644 --- a/mobile/lib/widgets/calendar/calendar_session_card.dart +++ b/mobile/lib/widgets/calendar/calendar_session_card.dart @@ -170,14 +170,6 @@ class CalendarSessionCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (event.isDisabled) ...[ - Icon( - LucideIcons.eyeOff, - size: 10, - color: disabledTextColor, - ), - const SizedBox(width: 6), - ], Text( event.status.toUpperCase(), style: GoogleFonts.manrope( @@ -190,6 +182,10 @@ class CalendarSessionCard extends StatelessWidget { ], ), ), + if (event.isDisabled) ...[ + const SizedBox(height: 6), + const _DisabledTag(), + ], if (event.isCorrection) ...[ const SizedBox(height: 6), const _CorrectionTag(), @@ -355,6 +351,45 @@ class _ActionButton extends StatelessWidget { } } +class _DisabledTag extends StatelessWidget { + const _DisabledTag(); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final displayColor = isDark + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6) + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65); + final color = Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.1 : 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.eyeOff, size: 10, color: displayColor), + const SizedBox(width: 6), + Text( + 'DISABLED', + style: GoogleFonts.manrope( + fontSize: 9, + fontWeight: FontWeight.w900, + color: displayColor, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } +} + class _CorrectionTag extends StatelessWidget { const _CorrectionTag(); diff --git a/mobile/lib/widgets/calendar/calendar_widgets.dart b/mobile/lib/widgets/calendar/calendar_widgets.dart index 09458b32..d94376ed 100644 --- a/mobile/lib/widgets/calendar/calendar_widgets.dart +++ b/mobile/lib/widgets/calendar/calendar_widgets.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/models/attendance.dart'; +import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/theme/app_theme.dart'; @@ -112,11 +114,9 @@ class AttendanceCalendarWidget extends StatelessWidget { Theme.of(context).colorScheme.primary) : isToday ? (Theme.of( - context, - ) - .extension() - ?.brandPrimary ?? - Theme.of(context).colorScheme.primary) + context, + ).extension()?.brandPurple ?? + const Color(0xFF7C3AED)) .withValues(alpha: 0.2) : _getStatusBg(status, context), shape: BoxShape.circle, @@ -127,10 +127,8 @@ class AttendanceCalendarWidget extends StatelessWidget { context, ) .extension() - ?.brandPrimary ?? - Theme.of( - context, - ).colorScheme.primary) + ?.brandPurple ?? + const Color(0xFF7C3AED)) .withValues(alpha: 0.4), width: 1.5, ) @@ -153,11 +151,9 @@ class AttendanceCalendarWidget extends StatelessWidget { ? Colors.white : isToday ? (Theme.of( - context, - ) - .extension() - ?.brandPrimary ?? - Theme.of(context).colorScheme.primary) + context, + ).extension()?.brandPurple ?? + const Color(0xFF7C3AED)) : status != null ? _getStatusColor(status, context) : Theme.of(context).colorScheme.onSurface @@ -179,36 +175,158 @@ class AttendanceCalendarWidget extends StatelessWidget { String? _getDayStatus(DateTime date, BuildContext context) { final dateStr = DateFormat('yyyyMMdd').format(date); final dbDate = DateFormat('yyyy-MM-dd').format(date); - final sessions = dashboard.attendance.studentAttendanceData[dateStr]; - final extraTracking = tracking.groupedByCourse.values - .expand((e) => e) - .where((t) => t.date == dbDate && t.status == 'extra') - .toList(); - if (sessions == null && extraTracking.isEmpty) return null; + String resolveSafeId(String rawId) { + final normRaw = rawId.trim().toUpperCase(); + for (final c in dashboard.courses) { + if (c.safeId.trim().toUpperCase() == normRaw || + (c.code ?? '').trim().toUpperCase() == normRaw) { + return c.safeId; + } + } + final numericId = int.tryParse(rawId); + if (numericId != null) { + for (final c in dashboard.courses) { + if (c.id == numericId) return c.safeId; + } + } + return rawId; + } var hasAbsent = false; var hasDutyLeave = false; var hasOtherLeave = false; var hasPresent = false; + final sessions = dashboard.attendance.studentAttendanceData[dateStr]; if (sessions != null) { - sessions.forEach((_, s) { - final status = AttendanceStatus.fromCode(s.attendance); - if (status == AttendanceStatus.absent) hasAbsent = true; - if (status == AttendanceStatus.dutyLeave) hasDutyLeave = true; - if (status == AttendanceStatus.otherLeave) hasOtherLeave = true; - if (status == AttendanceStatus.present) hasPresent = true; + var idx = 0; + sessions.forEach((key, sData) { + final rawId = sData.course.toString(); + final safeId = resolveSafeId(rawId); + final normSafeId = safeId.trim().toUpperCase(); + + final courseDetails = dashboard.courses.firstWhere( + (c) => + c.safeId.trim().toUpperCase() == normSafeId || + (c.code ?? '').trim().toUpperCase() == normSafeId, + orElse: () => CourseDetails(id: 0, name: safeId), + ); + + final officialCourse = dashboard.attendance.courses[rawId]; + if (officialCourse == null && + courseDetails.id == 0 && + courseDetails.name == safeId) { + idx++; + return; + } + + var displaySessionName = sData.session?.toString() ?? key; + final sNumKey = int.tryParse(key); + if ((sData.session == null || sData.session.toString() == 'null') && + sNumKey != null && + sNumKey > 20) { + displaySessionName = (idx + 1).toString(); + } + + final resolvedCode = utils.resolveCourseDisplayCode( + courseKey: rawId, + mergedCourse: courseDetails, + officialReport: dashboard.attendance, + ); + + final isDisabled = disabledCodes.contains( + (resolvedCode ?? '').toUpperCase(), + ); + final status = AttendanceStatus.fromCode(sData.attendance); + + final trackerCourseCode = + (resolvedCode != null && resolvedCode.trim().isNotEmpty) + ? resolvedCode.replaceAll(RegExp(r'\s+'), '').toUpperCase() + : safeId.replaceAll(RegExp(r'\s+'), '').toUpperCase(); + + final trackingKeys = { + rawId.trim().toUpperCase(), + safeId.trim().toUpperCase(), + trackerCourseCode.trim().toUpperCase(), + }; + final trackingRecords = tracking.groupedByCourse.entries + .where( + (entry) => trackingKeys.contains(entry.key.trim().toUpperCase()), + ) + .expand((entry) => entry.value) + .toList(); + + TrackingRecord? override; + final normDisplaySession = utils.normalizeSession(displaySessionName); + final normRawSession = utils.normalizeSession(key); + for (final t in trackingRecords) { + if (t.date != dbDate) continue; + final tNorm = utils.normalizeSession(t.session); + if (tNorm == normDisplaySession || tNorm == normRawSession) { + override = t; + break; + } + } + + final isCorrection = + override != null && override.status == 'correction'; + + final currentStatus = isCorrection + ? AttendanceStatus.fromCode(override.attendance) + : status; + + if (isDisabled) { + if (currentStatus == AttendanceStatus.present) { + hasPresent = true; + } + idx++; + return; + } + + if (currentStatus == AttendanceStatus.absent) hasAbsent = true; + if (currentStatus == AttendanceStatus.dutyLeave) hasDutyLeave = true; + if (currentStatus == AttendanceStatus.otherLeave) hasOtherLeave = true; + if (currentStatus == AttendanceStatus.present) hasPresent = true; + + idx++; }); } - for (final t in extraTracking) { - final status = AttendanceStatus.fromCode(t.attendance); - if (status == AttendanceStatus.absent) hasAbsent = true; - if (status == AttendanceStatus.dutyLeave) hasDutyLeave = true; - if (status == AttendanceStatus.otherLeave) hasOtherLeave = true; - if (status == AttendanceStatus.present) hasPresent = true; - } + tracking.groupedByCourse.forEach((courseKey, list) { + final normKey = courseKey.trim().toUpperCase(); + final courseDetails = dashboard.courses.firstWhere( + (c) => + c.safeId.trim().toUpperCase() == normKey || + (c.code ?? '').trim().toUpperCase() == normKey, + orElse: () => CourseDetails(id: 0, name: courseKey), + ); + + final displayCode = utils.resolveCourseDisplayCode( + courseKey: courseKey, + mergedCourse: courseDetails, + officialReport: dashboard.attendance, + ); + final isDisabled = disabledCodes.contains( + (displayCode ?? '').toUpperCase(), + ); + + for (final tr in list) { + if (tr.date == dbDate && tr.status == 'extra') { + final trStatus = AttendanceStatus.fromCode(tr.attendance); + if (isDisabled) { + if (trStatus == AttendanceStatus.present) { + hasPresent = true; + } + continue; + } + if (trStatus == AttendanceStatus.absent) hasAbsent = true; + if (trStatus == AttendanceStatus.dutyLeave) hasDutyLeave = true; + if (trStatus == AttendanceStatus.otherLeave) hasOtherLeave = true; + if (trStatus == AttendanceStatus.present) hasPresent = true; + } + } + }); if (hasAbsent) return 'absent'; if (hasDutyLeave) return 'dutyLeave'; @@ -298,6 +416,10 @@ class CalendarLegend extends StatelessWidget { runSpacing: 12, alignment: WrapAlignment.center, children: [ + _LegendItem( + label: 'Today', + color: ghostColors?.brandPurple ?? const Color(0xFF7C3AED), + ), _LegendItem( label: 'Present', color: ghostColors?.successGreen ?? const Color(0xFF10B981), diff --git a/mobile/lib/widgets/dashboard/header_section.dart b/mobile/lib/widgets/dashboard/header_section.dart index 5d913fee..526a7448 100644 --- a/mobile/lib/widgets/dashboard/header_section.dart +++ b/mobile/lib/widgets/dashboard/header_section.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/attendance_utils.dart' + show calculateCurrentAcademicInfo; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -37,6 +40,19 @@ class _HeaderSectionState extends ConsumerState { _PeriodDirection.next, ); + final currentAcademic = calculateCurrentAcademicInfo(); + final curSem = currentAcademic['current_semester']!; + final curYear = currentAcademic['current_year']!; + final currentIndex = _getPeriodIndex(curSem, curYear); + + var isNextAllowed = true; + if (nextPeriod != null && currentIndex != null) { + final nextIndex = _getPeriodIndex(nextPeriod.semester, nextPeriod.year); + if (nextIndex != null && nextIndex > currentIndex + 1) { + isNextAllowed = false; + } + } + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), @@ -121,6 +137,7 @@ class _HeaderSectionState extends ConsumerState { context, currentPeriod: currentPeriod, targetPeriod: nextPeriod, + isAllowed: isNextAllowed, ), ), ), @@ -134,8 +151,19 @@ class _HeaderSectionState extends ConsumerState { BuildContext context, { required _AcademicPeriod currentPeriod, required _AcademicPeriod? targetPeriod, + bool isAllowed = true, }) async { - if (_academicPeriodChangeLocked || targetPeriod == null) return; + if (_academicPeriodChangeLocked) return; + + if (!isAllowed) { + ServiceToast.show( + context, + 'You cannot view past the maximum allowed academic period', + ); + return; + } + + if (targetPeriod == null) return; setState(() { _academicPeriodChangeLocked = true; @@ -376,3 +404,10 @@ String _formatAcademicYear(int startYear) { String _formatAcademicPeriod(_AcademicPeriod period) { return '${period.semester.toUpperCase()} ${period.year}'; } + +int? _getPeriodIndex(String semester, String year) { + final startYear = _parseAcademicYearStart(year); + if (startYear == null) return null; + final sem = semester.toLowerCase(); + return startYear * 2 + (sem.contains('even') ? 1 : 0); +} diff --git a/mobile/lib/widgets/security_lockdown_listener.dart b/mobile/lib/widgets/security_lockdown_listener.dart index a87b0d5b..5203c5a2 100644 --- a/mobile/lib/widgets/security_lockdown_listener.dart +++ b/mobile/lib/widgets/security_lockdown_listener.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/security_utils.dart'; @@ -34,23 +32,20 @@ class SecurityLockdownListener extends ConsumerWidget { WidgetRef ref, SecurityFailureState state, ) { - if (Platform.isIOS) { - // Wipe storage immediately on iOS since we won't show the exit button - AppLogger.safeUnawait( - ref.read(securityGuardProvider).wipeAndExit(), - 'SecurityLockdownListener: wipeAndExit', - ); - } + // Wipe storage immediately for security + AppLogger.safeUnawait( + ref.read(securityGuardProvider).storage.clearAll(), + 'SecurityLockdownListener: clearAll', + ); + final navContext = rootNavigatorKey.currentContext ?? context; final _ = SecurityUtils.showSecurityFailureDialog( navContext, title: state.message, message: state.reason ?? 'Your device failed the security verification.', technicalDetails: state.source ?? 'Unknown security context.', - retryLabel: Platform.isAndroid ? 'Close App' : null, - onRetry: Platform.isAndroid - ? () => ref.read(securityGuardProvider).wipeAndExit() - : null, + retryLabel: 'Close App', + onRetry: () => ref.read(securityGuardProvider).wipeAndExit(), ); } } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5ef7a080..3cb31268 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.4.5+1 +version: 4.4.6+1 environment: sdk: ^3.11.4 diff --git a/mobile/test/logic/ezygo_batch_fetcher_test.dart b/mobile/test/logic/ezygo_batch_fetcher_test.dart index 7fa210cf..88682fa5 100644 --- a/mobile/test/logic/ezygo_batch_fetcher_test.dart +++ b/mobile/test/logic/ezygo_batch_fetcher_test.dart @@ -279,5 +279,62 @@ void main() { final results = await futures; expect(results.length, 4); }); + + test( + 'isolates state (caches/in-flight) between separate fetcher instances', + () async { + final fetcher1 = EzygoBatchFetcher( + mockDio, + getOutage: () => outageState, + setOutage: (val) => outageState = val, + isBackendUnauthorized: () => backendUnauthorized, + ); + final fetcher2 = EzygoBatchFetcher( + mockDio, + getOutage: () => outageState, + setOutage: (val) => outageState = val, + isBackendUnauthorized: () => backendUnauthorized, + ); + + final res1 = Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 200, + data: {'instance': 1}, + ); + final res2 = Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 200, + data: {'instance': 2}, + ); + + var callCount = 0; + when( + () => mockDio.request( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async { + callCount++; + return callCount == 1 ? res1 : res2; + }); + + final r1 = await fetcher1.fetch(path: '/test', token: 'token'); + expect(r1.data, {'instance': 1}); + + // If they shared static state, fetcher2 would hit fetcher1's cache. + // With isolated instance fields, fetcher2 will make its own request. + final r2 = await fetcher2.fetch(path: '/test', token: 'token'); + expect(r2.data, {'instance': 2}); + + verify( + () => mockDio.request( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(2); + }, + ); }); } diff --git a/mobile/test/models/score_test.dart b/mobile/test/models/score_test.dart index 68dd6683..2d6fb529 100644 --- a/mobile/test/models/score_test.dart +++ b/mobile/test/models/score_test.dart @@ -58,6 +58,38 @@ void main() { expect(exam.date, isNull); }); + test( + 'fromJson prioritizes settings questionPaperMaximumMark over maximum_mark', + () { + final exam = Exam.fromJson({ + 'id': 1, + 'maximum_mark': 100, + 'settings': {'questionPaperMaximumMark': 80}, + }); + + expect(exam.maximumMark, 80); + }, + ); + + test( + 'fromJson falls back to maximum_mark if settings questionPaperMaximumMark is 0 or null', + () { + final exam1 = Exam.fromJson({ + 'id': 1, + 'maximum_mark': 100, + 'settings': {'questionPaperMaximumMark': 0}, + }); + expect(exam1.maximumMark, 100); + + final exam2 = Exam.fromJson({ + 'id': 2, + 'maximum_mark': 100, + 'settings': {'questionPaperMaximumMark': null}, + }); + expect(exam2.maximumMark, 100); + }, + ); + test('courseName falls back when course list is empty', () { final exam = Exam( id: 1, diff --git a/mobile/test/navigation_shell_interaction_test.dart b/mobile/test/navigation_shell_interaction_test.dart index 15015659..a5371cd9 100644 --- a/mobile/test/navigation_shell_interaction_test.dart +++ b/mobile/test/navigation_shell_interaction_test.dart @@ -113,4 +113,71 @@ void main() { await tester.pumpAndSettle(); expect(find.text('dashboard'), findsOneWidget); }); + + testWidgets('NavigationShell back button intercept on calendar tab', ( + tester, + ) async { + final overrides = [ + dashboardProvider.overrideWith( + () => MockDashboardNotifier(mockDashboard), + ), + authProvider.overrideWith(() => MockAuthNotifier(mockUser)), + trackingProvider.overrideWith(() => MockTrackingNotifier(mockTracking)), + academicProvider.overrideWith(() => MockAcademicNotifier(mockAcademic)), + notificationsProvider.overrideWith( + () => MockNotificationNotifier(mockNotifications), + ), + outageProvider.overrideWith(() => MockOutageNotifier(data: false)), + securityFailureProvider.overrideWith( + () => MockSecurityFailureNotifier(null), + ), + ]; + + final router = GoRouter( + initialLocation: '/dashboard', + routes: [ + ShellRoute( + builder: (context, state, child) => NavigationShell(child: child), + routes: [ + GoRoute( + path: '/dashboard', + pageBuilder: (c, s) => + const MaterialPage(child: Center(child: Text('dashboard'))), + ), + GoRoute( + path: '/calendar', + pageBuilder: (c, s) => + const MaterialPage(child: Center(child: Text('calendar'))), + ), + ], + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp.router( + theme: AppTheme.darkTheme, + routerConfig: router, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Go to calendar + await tester.tap(find.byIcon(LucideIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.text('calendar'), findsOneWidget); + + // Simulate system back button + final handled = await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + // We expect the back press to be handled by our back press listener, + // which should navigate to dashboard. + expect(handled, isTrue); + expect(find.text('dashboard'), findsOneWidget); + }); } diff --git a/mobile/test/providers/auth_provider_analytics_test.dart b/mobile/test/providers/auth_provider_analytics_test.dart index 15512267..a24c0b94 100644 --- a/mobile/test/providers/auth_provider_analytics_test.dart +++ b/mobile/test/providers/auth_provider_analytics_test.dart @@ -160,6 +160,7 @@ void main() { when(() => mockStorage.saveEzygoUserId(any())).thenAnswer((_) async {}); when(() => mockStorage.saveTermsVersion(any())).thenAnswer((_) async {}); when(() => mockStorage.clearAll()).thenAnswer((_) async {}); + when(() => mockStorage.clearAllCachedData()).thenAnswer((_) async {}); when( () => mockProfileService.hasRenderableLocalProfile(any()), @@ -533,7 +534,7 @@ void main() { await notifier.updateAcademicContext('Even', '2025-2026'); - verify(() => mockApi.clearCaches()).called(1); + verify(() => mockApi.clearCaches()).called(2); expect(refreshCount, equals(1)); final user = container.read(authProvider).value; diff --git a/mobile/test/providers/notification_provider_test.dart b/mobile/test/providers/notification_provider_test.dart new file mode 100644 index 00000000..ae7d263a --- /dev/null +++ b/mobile/test/providers/notification_provider_test.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/logic/encrypted_value.dart'; +import 'package:ghostclass/models/user.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DummyAuthNotifier extends AuthNotifier { + @override + FutureOr build() { + return AuthenticatedUser( + supabaseUserId: 'test-user-id', + ezygoToken: EncryptedValue.fromPlaintext('token'), + settings: UserSettings.defaults(), + ); + } +} + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + try { + await Supabase.initialize( + url: 'https://placeholder-domain.supabase.co', + anonKey: 'placeholder-anon-key', + ); + } on Object catch (_) { + // already initialized + } + }); + + ProviderContainer buildContainer() { + return ProviderContainer( + overrides: [ + authProvider.overrideWith(DummyAuthNotifier.new), + ], + ); + } + + group('NotificationsNotifier - markAllAsRead TOCTOU Revert', () { + test( + 'reverts directly to snapshotBeforeUpdate if state did not mutate concurrently', + () async { + final container = buildContainer(); + addTearDown(container.dispose); + + final notifier = container.read(notificationsProvider.notifier); + + final initialSnapshot = NotificationsState( + actionNotifications: [ + AppNotification( + id: 1, + title: 'Conflict A', + description: 'Desc A', + createdAt: DateTime.parse('2026-05-31T10:00:00Z'), + topic: 'conflict', + ), + ], + regularNotifications: [ + AppNotification( + id: 2, + title: 'Regular B', + description: 'Desc B', + createdAt: DateTime.parse('2026-05-31T09:00:00Z'), + topic: 'general', + ), + ], + unreadCount: 2, + hasNextPage: true, + ); + + notifier.state = AsyncValue.data(initialSnapshot); + + // markAllAsRead will attempt DB call to placeholder domain, which must throw + await expectLater( + notifier.markAllAsRead(), + throwsA(anything), + ); + + // Verify that it reverted back to initialSnapshot exactly + final finalState = container.read(notificationsProvider).value; + expect(finalState, isNotNull); + expect(finalState!.actionNotifications, hasLength(1)); + expect(finalState.regularNotifications, hasLength(1)); + expect(finalState.unreadCount, 2); + expect(finalState.hasNextPage, true); + }, + ); + + test( + 'reverts by merging if state mutated concurrently during database call', + () async { + final container = buildContainer(); + addTearDown(container.dispose); + + final notifier = container.read(notificationsProvider.notifier); + + final initialSnapshot = NotificationsState( + actionNotifications: [ + AppNotification( + id: 1, + title: 'Conflict A', + description: 'Desc A', + createdAt: DateTime.parse('2026-05-31T10:00:00Z'), + topic: 'conflict', + ), + ], + regularNotifications: [ + AppNotification( + id: 2, + title: 'Regular B', + description: 'Desc B', + createdAt: DateTime.parse('2026-05-31T09:00:00Z'), + topic: 'general', + ), + ], + unreadCount: 2, + hasNextPage: true, + ); + + notifier.state = AsyncValue.data(initialSnapshot); + + // Start markAllAsRead + final markFuture = notifier.markAllAsRead(); + + // Simulate a concurrent mutation (e.g. fetchNextPage finished and appended items C and D) + final mutatedState = NotificationsState( + actionNotifications: const [], + regularNotifications: [ + // A and B got marked as read and sorted optimistically + AppNotification( + id: 1, + title: 'Conflict A', + description: 'Desc A', + createdAt: DateTime.parse('2026-05-31T10:00:00Z'), + topic: 'conflict', + isRead: true, + ), + AppNotification( + id: 2, + title: 'Regular B', + description: 'Desc B', + createdAt: DateTime.parse('2026-05-31T09:00:00Z'), + topic: 'general', + isRead: true, + ), + // Concurrently appended C (unread) and D (read) + AppNotification( + id: 3, + title: 'Regular C', + description: 'Desc C', + createdAt: DateTime.parse('2026-05-31T08:00:00Z'), + topic: 'general', + ), + AppNotification( + id: 4, + title: 'Regular D', + description: 'Desc D', + createdAt: DateTime.parse('2026-05-31T07:00:00Z'), + topic: 'general', + isRead: true, + ), + ], + unreadCount: 0, + ); + + notifier.state = AsyncValue.data(mutatedState); + + // Wait for markAllAsRead to fail and complete + await expectLater(markFuture, throwsA(anything)); + + // Verify the reverted/merged state: + // - A is restored to actionNotifications as unread. + // - B is restored to regularNotifications as unread. + // - C and D remain in regularNotifications with their respective read/unread states. + // - unreadCount is restored to 2 (initialSnapshot unreadCount). + // - hasNextPage remains false (from mutatedState). + final finalState = container.read(notificationsProvider).value; + expect(finalState, isNotNull); + + // Action notifications restored + expect(finalState!.actionNotifications, hasLength(1)); + expect(finalState.actionNotifications.first.id, 1); + expect(finalState.actionNotifications.first.isRead, false); + + // Regular notifications merged + expect(finalState.regularNotifications, hasLength(3)); + // B (id: 2) is restored to unread + final b = finalState.regularNotifications.firstWhere((n) => n.id == 2); + expect(b.isRead, false); + + // C (id: 3) remains in regular + final c = finalState.regularNotifications.firstWhere((n) => n.id == 3); + expect(c.isRead, false); + + // D (id: 4) remains in regular + final d = finalState.regularNotifications.firstWhere((n) => n.id == 4); + expect(d.isRead, true); + + // A (id: 1) should be filtered out of regular because it is back in actionNotifications + expect(finalState.regularNotifications.any((n) => n.id == 1), false); + + expect(finalState.unreadCount, 2); + expect(finalState.hasNextPage, false); + }, + ); + }); +} diff --git a/mobile/test/services/auth_profile_settings_coverage_test.dart b/mobile/test/services/auth_profile_settings_coverage_test.dart index 1b603706..c232adf5 100644 --- a/mobile/test/services/auth_profile_settings_coverage_test.dart +++ b/mobile/test/services/auth_profile_settings_coverage_test.dart @@ -16,7 +16,10 @@ class MockDio extends Mock implements Dio {} class MockDioService extends Mock implements DioService {} -class MockSecureStorageService extends Mock implements SecureStorageService {} +class MockSecureStorageService extends Mock implements SecureStorageService { + @override + Future clearAllCachedData() async {} +} void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/mobile/test/services/secure_storage_service_test.dart b/mobile/test/services/secure_storage_service_test.dart index c308ce43..f3c1e815 100644 --- a/mobile/test/services/secure_storage_service_test.dart +++ b/mobile/test/services/secure_storage_service_test.dart @@ -347,6 +347,104 @@ void main() { verify(() => mockStorage.deleteAll()).called(1); }); + group('Self-Healing & Error Handling', () { + test( + 'Transient error on read (e.g., interaction not allowed) does not purge and rethrows', + () async { + final transientError = Exception( + 'Keychain error: -25308 (interaction not allowed)', + ); + when( + () => mockStorage.read(key: any(named: 'key')), + ).thenThrow(transientError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.getEzygoToken(), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('-25308'), + ), + ), + ); + + verifyNever(() => mockStorage.deleteAll()); + }, + ); + + test( + 'Unrecoverable error on read (e.g., storage_key_error) purges and returns null', + () async { + final unrecoverableError = Exception( + 'PlatformException(storage_key_error, Keystore corrupted, null, null)', + ); + when( + () => mockStorage.read(key: any(named: 'key')), + ).thenThrow(unrecoverableError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + final result = await service.getEzygoToken(); + + expect(result, isNull); + verify(() => mockStorage.deleteAll()).called(1); + }, + ); + + test('Transient error on write does not purge and rethrows', () async { + final transientError = Exception( + 'Keychain error: -25308 (interaction not allowed)', + ); + when( + () => mockStorage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenThrow(transientError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.saveEzygoToken('test'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('-25308'), + ), + ), + ); + + verifyNever(() => mockStorage.deleteAll()); + }); + + test('Unrecoverable error on write purges and rethrows', () async { + final unrecoverableError = Exception( + 'PlatformException(storage_key_error, Keystore corrupted, null, null)', + ); + when( + () => mockStorage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenThrow(unrecoverableError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.saveEzygoToken('test'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('storage_key_error'), + ), + ), + ); + + verify(() => mockStorage.deleteAll()).called(1); + }); + }); + test('Provider returns SecureStorageService', () { final container = ProviderContainer(); final fetchedService = container.read(secureStorageProvider); diff --git a/mobile/test/services/security_service_coverage_test.dart b/mobile/test/services/security_service_coverage_test.dart index 88f0cd34..ebc117a7 100644 --- a/mobile/test/services/security_service_coverage_test.dart +++ b/mobile/test/services/security_service_coverage_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -410,5 +411,103 @@ void main() { expect(result.isForceUpdate, isFalse); // Recomputed dynamically }, ); + + test( + 'verifyIntegrity clears cache and rethrows on non-transient badCertificate exception', + () async { + final cachedJson = jsonEncode({ + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + 'cachedAt': DateTime.now() + .subtract(const Duration(days: 8)) + .toIso8601String(), + }); + + final options = RequestOptions(path: '/api/security/attestation'); + final dioError = DioException( + requestOptions: options, + type: DioExceptionType.badCertificate, + message: 'Certificate verification failed', + ); + + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenThrow(dioError); + + when( + () => mockSecureStorage.getAttestationResult(), + ).thenAnswer((_) async => cachedJson); + + await expectLater( + () => securityService.verifyIntegrity(), + throwsA(isA()), + ); + + verify(() => mockSecureStorage.clearAttestationResult()).called(1); + }, + ); + + test( + 'verifyIntegrity clears cache and rethrows on HandshakeException', + () async { + final cachedJson = jsonEncode({ + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + 'cachedAt': DateTime.now() + .subtract(const Duration(days: 8)) + .toIso8601String(), + }); + + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenThrow( + const HandshakeException('OS Error: CERTIFICATE_VERIFY_FAILED'), + ); + + when( + () => mockSecureStorage.getAttestationResult(), + ).thenAnswer((_) async => cachedJson); + + await expectLater( + () => securityService.verifyIntegrity(), + throwsA(isA()), + ); + + verify(() => mockSecureStorage.clearAttestationResult()).called(1); + }, + ); + + test( + 'verifyIntegrity falls back to stale cache on connectionTimeout transient error', + () async { + final cachedJson = jsonEncode({ + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + 'cachedAt': DateTime.now() + .subtract(const Duration(days: 8)) + .toIso8601String(), + }); + + final options = RequestOptions(path: '/api/security/attestation'); + final dioError = DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + message: 'Connection timed out', + ); + + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenThrow(dioError); + + when( + () => mockSecureStorage.getAttestationResult(), + ).thenAnswer((_) async => cachedJson); + + final result = await securityService.verifyIntegrity(); + expect(result, isNotNull); + expect(result!.latestVersion, AppConfig.appVersion); + verifyNever(() => mockSecureStorage.clearAttestationResult()); + }, + ); }); } diff --git a/mobile/test/services/stealth_headers_service_test.dart b/mobile/test/services/stealth_headers_service_test.dart index 77b387c4..3ce2dc6d 100644 --- a/mobile/test/services/stealth_headers_service_test.dart +++ b/mobile/test/services/stealth_headers_service_test.dart @@ -4,7 +4,10 @@ import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/stealth_headers_service.dart'; import 'package:mocktail/mocktail.dart'; -class MockSecureStorageService extends Mock implements SecureStorageService {} +class MockSecureStorageService extends Mock implements SecureStorageService { + @override + Future clearAllCachedData() async {} +} void main() { late MockSecureStorageService mockStorage; diff --git a/mobile/test/widgets/header_section_test.dart b/mobile/test/widgets/header_section_test.dart new file mode 100644 index 00000000..93f057b7 --- /dev/null +++ b/mobile/test/widgets/header_section_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/models/attendance.dart'; +import 'package:ghostclass/models/dashboard_stats.dart'; +import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/widgets/dashboard/header_section.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../coverage_helper.dart'; + +void main() { + testWidgets('HeaderSection - allow next when under limit', (tester) async { + final mockUser = createMockUser(); + final mockDashboard = DashboardData( + courses: const [], + attendance: const AttendanceReportDetailed( + studentAttendanceData: >{}, + courses: {}, + attendanceDates: {}, + ), + tracking: const [], + stats: DashboardStats.calculate( + attendanceData: const AttendanceReportDetailed( + studentAttendanceData: >{}, + courses: {}, + attendanceDates: {}, + ), + trackingRecords: const [], + selectedSemester: 'odd', + selectedYear: '2025-26', + ), + selectedSemester: 'odd', + selectedYear: '2025-26', + ); + const mockAcademic = AcademicState(semester: 'odd', year: '2025-26'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => MockAuthNotifier(mockUser)), + academicProvider.overrideWith( + () => MockAcademicNotifier(mockAcademic), + ), + ], + child: MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + HeaderSection(data: mockDashboard), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final nextBtn = find.byIcon(LucideIcons.chevronRight); + expect(nextBtn, findsOneWidget); + + await tester.tap(nextBtn); + await tester.pumpAndSettle(); + + expect(find.text('Confirm academic period change'), findsOneWidget); + }); + + testWidgets('HeaderSection - toast when next is over limit', (tester) async { + final mockUser = createMockUser(); + final mockDashboard = DashboardData( + courses: const [], + attendance: const AttendanceReportDetailed( + studentAttendanceData: >{}, + courses: {}, + attendanceDates: {}, + ), + tracking: const [], + stats: DashboardStats.calculate( + attendanceData: const AttendanceReportDetailed( + studentAttendanceData: >{}, + courses: {}, + attendanceDates: {}, + ), + trackingRecords: const [], + selectedSemester: 'odd', + selectedYear: '2026-27', + ), + selectedSemester: 'odd', + selectedYear: '2026-27', + ); + const mockAcademic = AcademicState(semester: 'odd', year: '2026-27'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => MockAuthNotifier(mockUser)), + academicProvider.overrideWith( + () => MockAcademicNotifier(mockAcademic), + ), + ], + child: MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + HeaderSection(data: mockDashboard), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final nextBtn = find.byIcon(LucideIcons.chevronRight); + expect(nextBtn, findsOneWidget); + + await tester.tap(nextBtn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.text('You cannot view past the maximum allowed academic period'), + findsOneWidget, + ); + // Allow the auto-dismiss timer of ServiceToast to complete to avoid pending timer error + await tester.pumpAndSettle(const Duration(seconds: 4)); + }); +} diff --git a/package-lock.json b/package-lock.json index 3c8ff002..f60ac61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -39,7 +39,7 @@ "date-fns": "^4.1.0", "firebase-admin": "^13.8.0", "framer-motion": "^12.34.3", - "googleapis": "^172.0.0", + "googleapis": "^173.0.0", "jose": "^6.2.2", "ldrs": "^1.1.9", "lodash-es": "^4.17.23", @@ -183,12 +183,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -197,29 +197,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -236,13 +236,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -252,13 +252,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -277,36 +277,36 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -316,52 +316,52 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -371,9 +371,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -381,31 +381,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -413,13 +413,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1224,72 +1224,6 @@ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "license": "MIT" }, - "node_modules/@fastify/otel": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.18.0.tgz", - "integrity": "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.212.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.2.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", - "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", - "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.212.0", - "import-in-the-middle": "^2.0.6", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@firebase/app-check-interop-types": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", @@ -2429,9 +2363,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", "funding": [ { "type": "github", @@ -2555,320 +2489,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", - "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", - "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", - "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", - "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", - "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", - "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", - "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", - "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", - "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", - "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", - "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", - "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", - "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", - "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", - "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", - "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", - "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", - "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", @@ -2911,25 +2531,10 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -2959,59 +2564,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@prisma/instrumentation": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", - "integrity": "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4375,9 +3927,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -4392,9 +3944,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -4409,9 +3961,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -4426,9 +3978,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -4443,9 +3995,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -4460,9 +4012,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -4480,9 +4032,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -4500,9 +4052,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -4520,9 +4072,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -4540,9 +4092,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -4560,9 +4112,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -4580,9 +4132,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -4597,9 +4149,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -4616,9 +4168,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -4633,9 +4185,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -4683,9 +4235,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -4705,9 +4257,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", "cpu": [ "arm" ], @@ -4718,9 +4270,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", "cpu": [ "arm64" ], @@ -4731,9 +4283,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", "cpu": [ "arm64" ], @@ -4744,9 +4296,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", "cpu": [ "x64" ], @@ -4757,9 +4309,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", "cpu": [ "arm64" ], @@ -4770,9 +4322,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", "cpu": [ "x64" ], @@ -4783,9 +4335,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", "cpu": [ "arm" ], @@ -4799,9 +4351,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", "cpu": [ "arm" ], @@ -4815,9 +4367,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", "cpu": [ "arm64" ], @@ -4831,9 +4383,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", "cpu": [ "arm64" ], @@ -4847,9 +4399,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", "cpu": [ "loong64" ], @@ -4863,9 +4415,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", "cpu": [ "loong64" ], @@ -4879,9 +4431,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", "cpu": [ "ppc64" ], @@ -4895,9 +4447,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", "cpu": [ "ppc64" ], @@ -4911,9 +4463,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", "cpu": [ "riscv64" ], @@ -4927,9 +4479,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", "cpu": [ "riscv64" ], @@ -4943,9 +4495,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", "cpu": [ "s390x" ], @@ -4959,9 +4511,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], @@ -4975,9 +4527,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", "cpu": [ "x64" ], @@ -4991,9 +4543,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", "cpu": [ "x64" ], @@ -5004,9 +4556,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", "cpu": [ "arm64" ], @@ -5017,9 +4569,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", "cpu": [ "arm64" ], @@ -5030,9 +4582,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", "cpu": [ "ia32" ], @@ -5043,9 +4595,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", "cpu": [ "x64" ], @@ -5056,9 +4608,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", "cpu": [ "x64" ], @@ -5165,50 +4717,50 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz", - "integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.55.0.tgz", + "integrity": "sha512-zUvyBr13EK0evKsSTzwSimRzZ3P9kugS32dLCj3ea5gNN+/DFtU/GsMTdcIQDhusEDraIlH17AGgqJH5gUAv5w==", "license": "MIT", "dependencies": { - "@sentry/core": "10.53.1" + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz", - "integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.55.0.tgz", + "integrity": "sha512-32X9WW1xs5DjCRlp89QJ/PLw4kbTIX6MsBDXN2RBN1nWBjm/2WcwXqO/v/WoIS4W2kTWXcZnQwalLSI22Fp33A==", "license": "MIT", "dependencies": { - "@sentry/core": "10.53.1" + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz", - "integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.55.0.tgz", + "integrity": "sha512-OkQpANGwYU5UKfwLk6Y+NpESRC8nrLBjawRDLwF6cJ8HpNScOuNNJDEJEGwXHVkJPH0pcIixsH8y0Qfcltq6Xw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.53.1", - "@sentry/core": "10.53.1" + "@sentry-internal/browser-utils": "10.55.0", + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz", - "integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.55.0.tgz", + "integrity": "sha512-lu/y7k9cK7FZ/qJpL0fBX4WqK6IFa/+bTPhedEaC5UpzjUNP7BfXt0H+R7q9CHWmp20Ffh/wGfO3j7O+Tv2MAA==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.53.1", - "@sentry/core": "10.53.1" + "@sentry-internal/replay": "10.55.0", + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" @@ -5224,16 +4776,16 @@ } }, "node_modules/@sentry/browser": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz", - "integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.55.0.tgz", + "integrity": "sha512-5n1kxmW1m4j16ZDV9kt+Zo5uafFnKTy7s5YyEcGnC45KnOiO1Gy+QFd3woXns1K5GNxpjF7oOOc6tXgZLuXnQQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.53.1", - "@sentry-internal/feedback": "10.53.1", - "@sentry-internal/replay": "10.53.1", - "@sentry-internal/replay-canvas": "10.53.1", - "@sentry/core": "10.53.1" + "@sentry-internal/browser-utils": "10.55.0", + "@sentry-internal/feedback": "10.55.0", + "@sentry-internal/replay": "10.55.0", + "@sentry-internal/replay-canvas": "10.55.0", + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" @@ -5423,30 +4975,30 @@ } }, "node_modules/@sentry/core": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz", - "integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.55.0.tgz", + "integrity": "sha512-XUyoNtDSYCvgJnoNzlh+YeAXfIPhCRIXbhWqqM3GQ3AFtZICi85lkyfsrwXEl9wzlPGYnU+Eg8F4tOfScx+FcQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/nextjs": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.53.1.tgz", - "integrity": "sha512-pkwqrpAG//LtW5W1Odud0PLLT+rnjDjodUEbScULHVaZE6/Gt+WGBMZmtzpNM+UwhsN19/4PyO7ocLTx/IFrkQ==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.55.0.tgz", + "integrity": "sha512-ODiv6hy7+gmINFvbWAVaCqtxT2ceFW7AW65amy34z3fCgld9+LHTP2++p4g2LmQb/mYQPIbJrVEfJoqGASK4EQ==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.53.1", + "@sentry-internal/browser-utils": "10.55.0", "@sentry/bundler-plugin-core": "^5.3.0", - "@sentry/core": "10.53.1", - "@sentry/node": "10.53.1", - "@sentry/opentelemetry": "10.53.1", - "@sentry/react": "10.53.1", - "@sentry/vercel-edge": "10.53.1", + "@sentry/core": "10.55.0", + "@sentry/node": "10.55.0", + "@sentry/opentelemetry": "10.55.0", + "@sentry/react": "10.55.0", + "@sentry/vercel-edge": "10.55.0", "@sentry/webpack-plugin": "^5.3.0", "rollup": "^4.60.3", "stacktrace-parser": "^0.1.11" @@ -5459,39 +5011,19 @@ } }, "node_modules/@sentry/node": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.53.1.tgz", - "integrity": "sha512-rxHVil0tJAmz+keFcZCj1LaUdgdkK66E/l0gqh2p1209PNCGoD3lnClFr6vusy1aF3zF8O9JPtuMEJzXOKhs+w==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.55.0.tgz", + "integrity": "sha512-+fB/ByoHVWPLGgoafYciiMatTNyX1FHj1bsqZBN+Pw3McbuEU1nwCPLt9zuyZZiWlQtXKsyuACS4ZhXnID5l8A==", "license": "MIT", "dependencies": { - "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/instrumentation-amqplib": "0.61.0", - "@opentelemetry/instrumentation-connect": "0.57.0", - "@opentelemetry/instrumentation-dataloader": "0.31.0", - "@opentelemetry/instrumentation-fs": "0.33.0", - "@opentelemetry/instrumentation-generic-pool": "0.57.0", - "@opentelemetry/instrumentation-graphql": "0.62.0", - "@opentelemetry/instrumentation-hapi": "0.60.0", - "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-kafkajs": "0.23.0", - "@opentelemetry/instrumentation-knex": "0.58.0", - "@opentelemetry/instrumentation-koa": "0.62.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", - "@opentelemetry/instrumentation-mongodb": "0.67.0", - "@opentelemetry/instrumentation-mongoose": "0.60.0", - "@opentelemetry/instrumentation-mysql": "0.60.0", - "@opentelemetry/instrumentation-mysql2": "0.60.0", - "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.6.0", - "@sentry/core": "10.53.1", - "@sentry/node-core": "10.53.1", - "@sentry/opentelemetry": "10.53.1", + "@sentry/core": "10.55.0", + "@sentry/node-core": "10.55.0", + "@sentry/opentelemetry": "10.55.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -5499,13 +5031,13 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.53.1.tgz", - "integrity": "sha512-iH7SMcM/7jPbN+t7+7ussQOiIqI4BMOGt4VYWlV71/z7k0pY+YPaSvlfVkNXfISiDzFAKv0ecCY3BmsLMu1xDQ==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.55.0.tgz", + "integrity": "sha512-M8XMMIk9Y0PGZoEt37Oe5dQCdqDdJlBcwLXidpz/s5k4QtJvCO/BbtcivcuKI2htw5FwxJkSrHUzRvT36tlDpg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.53.1", - "@sentry/opentelemetry": "10.53.1", + "@sentry/core": "10.55.0", + "@sentry/opentelemetry": "10.55.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -5541,12 +5073,12 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.53.1.tgz", - "integrity": "sha512-Zok6UXla0mFOjd1YnVb1TZtQNOry9v93fXUqx8jmDaygwWM2BwvP8rBQabLz0/OZXo8+35oge+Vmw+QY5aesnA==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.55.0.tgz", + "integrity": "sha512-0+YrNmVNrttki4rWP4DW+UTt5MziepwDLNBde39tgc3cGCcy5fLSdDfhb4JfTaE5TXt4kd5XrkgvS/sDgm3RZg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.53.1" + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" @@ -5559,13 +5091,13 @@ } }, "node_modules/@sentry/react": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz", - "integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.55.0.tgz", + "integrity": "sha512-cf6wI0W1FdrL/7d5FTHXUdTN5k5uRZ9AQu5QZxUujnxWN+DBxUWtow1HDD1zTEonQsCFyYuhXVu3w+qmBBW11A==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.53.1", - "@sentry/core": "10.53.1" + "@sentry/browser": "10.55.0", + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" @@ -5575,14 +5107,14 @@ } }, "node_modules/@sentry/vercel-edge": { - "version": "10.53.1", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.53.1.tgz", - "integrity": "sha512-waIOoLfhi1V3xEBJ1s1hpmvvgvcorYfsfm7fQGye0PgVjcBsZUqz32N5iEwkZ2Gz3n4ZOQYibDUqARJi9tOBcw==", + "version": "10.55.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.55.0.tgz", + "integrity": "sha512-IB8MBTdCHnuUtxHWh39Ecr9/TvMqSYW9BOO7ExnByDjHhfXOzQnEs6VJvEEIwSyUEl1yIz1Y3WdOlY9I6lqeMA==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/resources": "^2.6.1", - "@sentry/core": "10.53.1" + "@sentry/core": "10.55.0" }, "engines": { "node": ">=18" @@ -5629,6 +5161,21 @@ } } }, + "node_modules/@serwist/build/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@serwist/build/node_modules/zod": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", @@ -5787,9 +5334,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz", - "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz", + "integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5799,9 +5346,9 @@ } }, "node_modules/@supabase/cli-darwin-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.101.0.tgz", - "integrity": "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.103.0.tgz", + "integrity": "sha512-BoOaHyoLuyOtmhxtRti7za3YYH9T05JenEla/zDXikqTiY5yDd63/1RxSF9mXt+ICFMkqHq3oFkFcDP5snumBQ==", "cpu": [ "arm64" ], @@ -5813,9 +5360,9 @@ ] }, "node_modules/@supabase/cli-darwin-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.101.0.tgz", - "integrity": "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.103.0.tgz", + "integrity": "sha512-SLBb5lfmIF9G+FanWZQvKG6HQeUKFmMv8ls8ZVm6OnO87pLm4aRKLfFYjeXXtiBiRYMZRwxE/RwrRy0uXc0wqw==", "cpu": [ "x64" ], @@ -5827,9 +5374,9 @@ ] }, "node_modules/@supabase/cli-linux-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.101.0.tgz", - "integrity": "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.103.0.tgz", + "integrity": "sha512-nk0fxTbQho+9OoUmIEBlgvEG69GmooMO1D8Ypt7SzgczNcLfYoCoKV9NMkQRUGi5jFXS5xFrRMPeSa/i3tNgwQ==", "cpu": [ "arm64" ], @@ -5844,9 +5391,9 @@ ] }, "node_modules/@supabase/cli-linux-arm64-musl": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.101.0.tgz", - "integrity": "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.103.0.tgz", + "integrity": "sha512-zn2N7t/Mx1LUE/knZvJfJwNHKKvEl5KCqob1fetfPXoPN/GcI0UnfJ+V/bkPjhpPA1b8htVhq4brHMcqpV80mA==", "cpu": [ "arm64" ], @@ -5861,9 +5408,9 @@ ] }, "node_modules/@supabase/cli-linux-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.101.0.tgz", - "integrity": "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.103.0.tgz", + "integrity": "sha512-xk9ADh4D+luctRgscBzNvYLZa1qAw067IwateSE1AxhO3GOhdN6fiLZszl5LodO7CHmp54204dj+UcMct3KWFQ==", "cpu": [ "x64" ], @@ -5878,9 +5425,9 @@ ] }, "node_modules/@supabase/cli-linux-x64-musl": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.101.0.tgz", - "integrity": "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.103.0.tgz", + "integrity": "sha512-/fpuslo5ERFWJxbkRcpUFLV8T7087vOVU+clN2OeYTL8lgxVl04FPL3nW0qDgYeehb9GUeje5adgGvHYS006KA==", "cpu": [ "x64" ], @@ -5895,9 +5442,9 @@ ] }, "node_modules/@supabase/cli-windows-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.101.0.tgz", - "integrity": "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.103.0.tgz", + "integrity": "sha512-oz1xMJ2bu/lfVV1WaNegGmzoxoEEaiBMg6g2Jm4aB6qCUgoIIkiXClghEnPotd0DWklpqFCOIp1lUb15mnjK6Q==", "cpu": [ "arm64" ], @@ -5909,9 +5456,9 @@ ] }, "node_modules/@supabase/cli-windows-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.101.0.tgz", - "integrity": "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.103.0.tgz", + "integrity": "sha512-jueFJW/QmzWuVis2vGzeSDsrZ3gy5KIsq5vlpmhQF0bWpemgqP7buojPUiHCGSW6+9tTh8za8MraEy7HJGX/wQ==", "cpu": [ "x64" ], @@ -5923,9 +5470,9 @@ ] }, "node_modules/@supabase/functions-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", - "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz", + "integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5941,9 +5488,9 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz", - "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz", + "integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5953,9 +5500,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz", - "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz", + "integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==", "license": "MIT", "dependencies": { "@supabase/phoenix": "^0.4.2", @@ -5978,9 +5525,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz", - "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz", + "integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -5991,16 +5538,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.106.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz", - "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", + "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.106.1", - "@supabase/functions-js": "2.106.1", - "@supabase/postgrest-js": "2.106.1", - "@supabase/realtime-js": "2.106.1", - "@supabase/storage-js": "2.106.1" + "@supabase/auth-js": "2.106.2", + "@supabase/functions-js": "2.106.2", + "@supabase/postgrest-js": "2.106.2", + "@supabase/realtime-js": "2.106.2", + "@supabase/storage-js": "2.106.2" }, "engines": { "node": ">=20.0.0" @@ -6299,9 +5846,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.13", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.13.tgz", - "integrity": "sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==", + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", "license": "MIT", "funding": { "type": "github", @@ -6309,12 +5856,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.13", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.13.tgz", - "integrity": "sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==", + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.13" + "@tanstack/query-core": "5.100.14" }, "funding": { "type": "github", @@ -6325,12 +5872,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.25", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz", - "integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==", + "version": "3.13.26", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", + "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.15.0" + "@tanstack/virtual-core": "3.16.0" }, "funding": { "type": "github", @@ -6342,9 +5889,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz", - "integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==", + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", + "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", "license": "MIT", "funding": { "type": "github", @@ -6486,15 +6033,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -6653,15 +6191,6 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -6678,26 +6207,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -6759,15 +6268,6 @@ "htmlparser2": "^10.1" } }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -6810,17 +6310,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6833,7 +6333,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6849,16 +6349,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -6874,14 +6374,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -6896,14 +6396,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6914,9 +6414,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -6931,15 +6431,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6956,9 +6456,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -6970,16 +6470,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -7011,16 +6511,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7035,13 +6535,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7461,14 +6961,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -7482,8 +6982,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -7492,16 +6992,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -7510,13 +7010,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -7547,9 +7047,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -7560,13 +7060,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -7574,14 +7074,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7590,9 +7090,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -7600,13 +7100,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", - "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz", + "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -7618,17 +7118,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.7" + "vitest": "4.1.8" } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8020,9 +7520,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", "dev": true, "license": "MIT", "dependencies": { @@ -8190,9 +7690,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9044,9 +8544,9 @@ } }, "node_modules/date-fns": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", - "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", @@ -9054,9 +8554,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debounce": { @@ -9379,9 +8879,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9462,9 +8962,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", "dev": true, "license": "MIT", "dependencies": { @@ -9693,9 +9193,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "license": "MIT", "workspaces": [ "docs", @@ -9922,9 +9422,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10683,12 +10183,6 @@ "node": ">=12.20.0" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/framer-motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", @@ -11213,9 +10707,9 @@ } }, "node_modules/googleapis": { - "version": "172.0.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-172.0.0.tgz", - "integrity": "sha512-cF1m/nwfu9JP+JtR6j2nkQKnhu0z45eUVsWCVeooU8EIvBiPQqbfgAuJsTggCYEpbWJNwq2WS+aigaBC/sriIw==", + "version": "173.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz", + "integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.2.0", @@ -11426,9 +10920,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12414,10 +11908,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -13005,16 +12509,16 @@ "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, "node_modules/lint-staged": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.5.tgz", - "integrity": "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw==", + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", + "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", "dev": true, "license": "MIT", "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "^1.1.2" + "tinyexec": "^1.2.4" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -13026,7 +12530,7 @@ "url": "https://opencollective.com/lint-staged" }, "optionalDependencies": { - "yaml": "^2.8.4" + "yaml": "^2.9.0" } }, "node_modules/listr2": { @@ -13268,9 +12772,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13305,9 +12809,9 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -14798,37 +14302,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -14950,45 +14423,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15133,9 +14567,9 @@ } }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz", + "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "optional": true, @@ -15257,9 +14691,9 @@ } }, "node_modules/react-email": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-6.3.2.tgz", - "integrity": "sha512-ZzmrwM+QLzfs/EZBnFZRMZwT3Kfvp46zIMCLsGn/rtRBh9ocRJDKHcnV0JWJyc0AVJTdPDHeFNBWap6N/3Dnhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-6.5.0.tgz", + "integrity": "sha512-WrJ+XPW87O1dabF4RJNGnTr7VTGsNa+BlMiinAZdH5fg8Kepwk++ZzX+LEieTlk+a3r13TaTJ4DfI9gv++y02g==", "license": "MIT", "dependencies": { "@babel/parser": "7.27.0", @@ -15387,9 +14821,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.76.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", - "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", + "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -15777,13 +15211,13 @@ "license": "MIT" }, "node_modules/resend": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz", - "integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", "license": "MIT", "dependencies": { "postal-mime": "2.7.4", - "svix": "1.92.2" + "standardwebhooks": "1.0.0" }, "engines": { "node": ">=20" @@ -15922,13 +15356,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -15938,30 +15372,30 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -15971,40 +15405,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -16969,23 +16397,23 @@ } }, "node_modules/supabase": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.101.0.tgz", - "integrity": "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.103.0.tgz", + "integrity": "sha512-LAdBSEzbmJIAf3UojILFL1DF2npwUpqGZ5FNtuqu9pzUdPMHvICyFeQeQCpUpLNrqmee78LqYwiFBpHtBP/TQQ==", "dev": true, "license": "MIT", "bin": { "supabase": "dist/supabase.js" }, "optionalDependencies": { - "@supabase/cli-darwin-arm64": "2.101.0", - "@supabase/cli-darwin-x64": "2.101.0", - "@supabase/cli-linux-arm64": "2.101.0", - "@supabase/cli-linux-arm64-musl": "2.101.0", - "@supabase/cli-linux-x64": "2.101.0", - "@supabase/cli-linux-x64-musl": "2.101.0", - "@supabase/cli-windows-arm64": "2.101.0", - "@supabase/cli-windows-x64": "2.101.0" + "@supabase/cli-darwin-arm64": "2.103.0", + "@supabase/cli-darwin-x64": "2.103.0", + "@supabase/cli-linux-arm64": "2.103.0", + "@supabase/cli-linux-arm64-musl": "2.103.0", + "@supabase/cli-linux-x64": "2.103.0", + "@supabase/cli-linux-x64-musl": "2.103.0", + "@supabase/cli-windows-arm64": "2.103.0", + "@supabase/cli-windows-x64": "2.103.0" } }, "node_modules/supports-color": { @@ -17014,15 +16442,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svix": { - "version": "1.92.2", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz", - "integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==", - "license": "MIT", - "dependencies": { - "standardwebhooks": "1.0.0" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -17103,18 +16522,18 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -17139,22 +16558,22 @@ } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", "dev": true, "license": "MIT" }, @@ -17295,9 +16714,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -17367,18 +16786,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -17402,16 +16821,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17463,9 +16882,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "dev": true, "license": "MIT", "engines": { @@ -17781,17 +17200,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -17874,19 +17293,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -17914,12 +17333,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -18132,14 +17551,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -18273,15 +17692,6 @@ "dev": true, "license": "MIT" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 9e3c7b59..b55cc541 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "private": true, "engines": { "node": ">=22.12.0", @@ -80,7 +80,7 @@ "date-fns": "^4.1.0", "firebase-admin": "^13.8.0", "framer-motion": "^12.34.3", - "googleapis": "^172.0.0", + "googleapis": "^173.0.0", "jose": "^6.2.2", "ldrs": "^1.1.9", "lodash-es": "^4.17.23", diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 2151baf7..245ce8e5 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -2,7 +2,7 @@ "id": "/", "name": "GhostClass", "short_name": "GhostClass", - "description": "GhostClass — Smart Attendance Tracker", + "description": "GhostClass - Smart Attendance Tracker", "start_url": "/dashboard", "scope": "/", "display": "standalone", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index ad364ac0..8303b738 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.4.5 + version: 4.4.6 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 9aeffd1c..3a50536b 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { AnimatePresence, domAnimation, @@ -56,11 +56,12 @@ import { useSyncOnMount } from "@/hooks/use-sync-on-mount"; import { PWAInstallBanner } from "@/components/pwa-install-banner"; import { useDisabledCourses } from "@/hooks/courses/useDisabledCourses"; import { useCourseLookup } from "@/hooks/courses/useCourseLookup"; +import { normalizeCourseCode } from "@/lib/utils"; import { AttendanceReport, Course, TrackAttendance, UserProfile } from "@/types"; import { StatsPanel } from "./components/StatsPanel"; import { DashboardCharts } from "./components/DashboardCharts"; -import { CourseGrid } from "./components/CourseGrid"; +import { CourseGrid, DashboardCourse } from "./components/CourseGrid"; const ChartSkeleton = () => (
@@ -136,13 +137,234 @@ const shiftAcademicPeriod = ( : { semester: "odd", year: formatAcademicYear(startYear + 1) }; }; +const getPeriodIndex = (semester: string, year: string): number | null => { + const startYear = parseAcademicYearStart(year); + if (startYear === null) return null; + const sem = semester.toLowerCase(); + return startYear * 2 + (sem === "even" ? 1 : 0); +}; + const formatAcademicPeriod = (period: AcademicPeriod) => `${period.semester.toUpperCase()} ${period.year}`; +function planAcademicShift( + direction: "previous" | "next", + effectiveSemester: AcademicSemester, + effectiveYear: string, + defaultAcademicInfo: { current_semester: AcademicSemester; current_year: string }, +): { next: AcademicPeriod | null; errorMessage?: string; infoMessage?: string } { + const nextPeriod = shiftAcademicPeriod(effectiveSemester, effectiveYear, direction); + if (!nextPeriod) { + return { next: null, errorMessage: "Could not compute the next academic period." }; + } + + let clampedNext = nextPeriod; + let wasClamped = false; + + if (direction === "next") { + const currentIndex = getPeriodIndex(defaultAcademicInfo.current_semester, defaultAcademicInfo.current_year); + const nextIndex = getPeriodIndex(nextPeriod.semester, nextPeriod.year); + + if (currentIndex !== null && nextIndex !== null && nextIndex > currentIndex + 1) { + const maxAllowedIndex = currentIndex + 1; + const maxAllowedStartYear = Math.floor(maxAllowedIndex / 2); + const maxAllowedSemester: AcademicSemester = maxAllowedIndex % 2 === 1 ? "even" : "odd"; + clampedNext = { + semester: maxAllowedSemester, + year: formatAcademicYear(maxAllowedStartYear), + }; + wasClamped = true; + } + } + + if (clampedNext.semester === effectiveSemester && clampedNext.year === effectiveYear) { + return { next: null, infoMessage: "You cannot view past the maximum allowed academic period" }; + } + + if (wasClamped) { + return { next: clampedNext, infoMessage: "Clamped to the nearest future academic period" }; + } + + return { next: clampedNext }; +} + +function useDashboardServerErrorToast(serverError: string | null | undefined, isRateLimitError: boolean) { + useEffect(() => { + if (!serverError) return; + + toast.error(isRateLimitError ? "EzyGo Rate Limit Reached" : "Dashboard Pre-fetch Failed", { + description: isRateLimitError ? "Too many requests. Please wait." : "Failed to pre-load your data.", + duration: 8000, + action: { label: "Retry", onClick: () => window.location.reload() }, + }); + }, [serverError, isRateLimitError]); +} + +function useDashboardSyncFailureToast(syncSettled: boolean, syncFailed: boolean) { + const hasShownSyncWarningRef = useRef(false); + + useEffect(() => { + if (!syncSettled || !syncFailed || hasShownSyncWarningRef.current) return; + + hasShownSyncWarningRef.current = true; + toast.warning("Background sync delayed", { + description: "Showing cached data. Latest attendance updates will appear when sync recovers.", + }); + }, [syncSettled, syncFailed]); +} + +async function executeAcademicChange(params: { + pendingChange: AcademicPeriod | null; + profileUsername: string | undefined; + isUpdating: boolean; + effectiveYear: string | null; + effectiveSemester: AcademicSemester | null; + setAcademicYearMutation: { mutateAsync: (payload: { default_academic_year: string }) => Promise }; + setSemesterMutation: { mutateAsync: (payload: { default_semester: AcademicSemester }) => Promise }; + refetchProfile: () => Promise; + setSelectedSemester: Dispatch>; + setSelectedYear: Dispatch>; + setIsUpdating: Dispatch>; + setIsShifting: Dispatch>; + setShowConfirmDialog: Dispatch>; + setPendingChange: Dispatch>; + academicShiftLockRef: MutableRefObject; +}) { + const { + pendingChange, + profileUsername, + isUpdating, + effectiveYear, + effectiveSemester, + setAcademicYearMutation, + setSemesterMutation, + refetchProfile, + setSelectedSemester, + setSelectedYear, + setIsUpdating, + setIsShifting, + setShowConfirmDialog, + setPendingChange, + academicShiftLockRef, + } = params; + + if (!pendingChange || !profileUsername || isUpdating) return; + + setIsUpdating(true); + setIsShifting(true); + setShowConfirmDialog(false); + + try { + if (pendingChange.year !== effectiveYear) { + await setAcademicYearMutation.mutateAsync({ + default_academic_year: pendingChange.year, + }); + } + if (pendingChange.semester !== effectiveSemester) { + await setSemesterMutation.mutateAsync({ + default_semester: pendingChange.semester, + }); + } + + try { + await refetchProfile(); + } catch (err) { + logger.error("Profile sync failed during transition, proceeding:", err); + } + + setSelectedSemester(pendingChange.semester); + setSelectedYear(pendingChange.year); + } catch (error) { + logger.error("Update Failed:", error); + toast.error("Failed to update settings"); + setIsShifting(false); + } finally { + setIsUpdating(false); + setPendingChange(null); + academicShiftLockRef.current = false; + } +} + +function computeInitialDataValidity( + initialData: InitialDashboardData | undefined, + selectedSemester: AcademicSemester | null, + selectedYear: string | null, + ezygoSemester: AcademicSemester | undefined, + ezygoYear: string | undefined, + effectiveSemester: AcademicSemester | undefined, + effectiveYear: string | undefined +): { isInitialDataValid: boolean; isAttendanceStale: boolean } { + const isInitial = (() => { + if (selectedSemester !== null || selectedYear !== null) return false; + if (ezygoSemester && ezygoSemester !== effectiveSemester) return false; + if (ezygoYear && ezygoYear !== effectiveYear) return false; + return true; + })(); + + const attendance = initialData?.attendance; + const isAttendanceStale = !!( + attendance && + typeof attendance === "object" && + "studentAttendanceData" in attendance && + attendance.studentAttendanceData && + typeof attendance.studentAttendanceData === "object" && + "stale" in (attendance.studentAttendanceData as { stale?: unknown }) && + (attendance.studentAttendanceData as { stale?: unknown }).stale === true + ); + + return { isInitialDataValid: isInitial, isAttendanceStale }; +} + interface DashboardClientProps { initialData?: InitialDashboardData; serverError?: string | null; } +const ACTIVE_ATTENDANCE_CODES = new Set([110, 111, 225, 112]); + +const isActiveAttendanceSession = (session: { attendance?: unknown; class_type?: unknown; course?: unknown }) => { + const attCode = Number(session.attendance); + return ACTIVE_ATTENDANCE_CODES.has(attCode) && session.class_type !== "Revision" && !!session.course; +}; + +const isActiveTrackingRecord = ( + record: TrackAttendance, + selectedSemester: string | null, + selectedYear: string | null, +) => { + const isSameSemester = !selectedSemester || record.semester === selectedSemester; + const isSameYear = !selectedYear || record.year === selectedYear; + return !!record.course && isSameSemester && isSameYear && record.attendance != null; +}; + +const buildCatalogCodes = ( + coursesData: { courses: Record } | undefined | null, + classCourses: ClassCourse[], +) => { + const catalogCodes = new Set(); + + if (coursesData?.courses) { + Object.values(coursesData.courses).forEach((c) => { + catalogCodes.add(normalizeCourseCode(c.code ?? String(c.id))); + }); + } + + classCourses.forEach((cc) => { + catalogCodes.add(normalizeCourseCode(cc.course_code ?? "")); + }); + + return catalogCodes; +}; + +const countNoDataCodes = ( + catalogCodes: Set, + activeCodes: Set, + disabledWithDataCodes: Set, + disabledCodes: Set, +) => Array.from(catalogCodes).reduce((count, code) => { + const hasNoData = !activeCodes.has(code) && !disabledWithDataCodes.has(code) && !disabledCodes.has(code); + return hasNoData ? count + 1 : count; +}, 0); + const getActiveCourseStats = ( attendanceData: AttendanceReport | undefined | null, trackingData: TrackAttendance[], @@ -159,8 +381,7 @@ const getActiveCourseStats = ( Object.values(attendanceData.studentAttendanceData).forEach((sessions) => { if (!sessions) return; Object.values(sessions).forEach((s) => { - const attCode = Number(s.attendance); - if ([110, 111, 225, 112].includes(attCode) && s.class_type !== "Revision" && s.course) { + if (isActiveAttendanceSession(s)) { activeIds.add(String(s.course)); } }); @@ -169,41 +390,26 @@ const getActiveCourseStats = ( if (trackingData) { trackingData.forEach((t) => { - const isSameSemester = !selectedSemester || t.semester === selectedSemester; - const isSameYear = !selectedYear || t.year === selectedYear; - if (t.course && isSameSemester && isSameYear && t.attendance != null) { + if (isActiveTrackingRecord(t, selectedSemester, selectedYear)) { activeIds.add(String(t.course)); } }); } - const catalogCodes = new Set(); - if (coursesData?.courses) { - Object.values(coursesData.courses).forEach((c) => { - catalogCodes.add(c.code ? c.code.toUpperCase().replace(/[\s\u00A0-]/g, "") : String(c.id).toUpperCase()); - }); - } - if (classCourses) { - classCourses.forEach((cc) => { - catalogCodes.add(cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, "")); - }); - } + const catalogCodes = buildCatalogCodes(coursesData, classCourses); const activeCodes = new Set(); const disabledWithDataCodes = new Set(); activeIds.forEach((id) => { - const code = String(getCourseCode(id) || id).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const code = normalizeCourseCode(String(getCourseCode(id) || id)); if (code !== "" && catalogCodes.has(code)) { if (disabledCodes.has(code)) disabledWithDataCodes.add(code); else activeCodes.add(code); } }); - let noDataCount = 0; - catalogCodes.forEach((code) => { - if (!activeCodes.has(code) && !disabledWithDataCodes.has(code) && !disabledCodes.has(code)) noDataCount++; - }); + const noDataCount = countNoDataCodes(catalogCodes, activeCodes, disabledWithDataCodes, disabledCodes); return { active: activeCodes.size, noData: noDataCount, disabled: disabledCodes.size, total: catalogCodes.size }; }; @@ -214,10 +420,31 @@ const getSortPriority = (item: { isDisabled?: boolean; isNew?: boolean }) => { return 0; }; +// eslint-disable-next-line sonarjs/cognitive-complexity -- This component orchestrates multiple guarded async data flows and UI states in one page-level boundary. export default function DashboardClient({ initialData, serverError }: DashboardClientProps) { - const { data: rawProfile, isLoading: isLoadingProfile } = useProfile({ sync: true, force: true }); + const { data: rawProfile, isLoading: isLoadingProfile, refetch: refetchProfile } = useProfile({ sync: true, force: true }); const profile = rawProfile as UserProfile | undefined; const queryClient = useQueryClient(); + + // The force variant uses its own ["profile", "synced"] query key to avoid + // deduplication with the navbar's no-force fetch. Once the EzyGo sync resolves, + // backfill the shared ["profile"] cache so the navbar and all other components + // see the latest data without needing their own round-trip. + useEffect(() => { + if (rawProfile) { + queryClient.setQueryData(["profile"], rawProfile); + + const userClass = rawProfile.class as { sem?: string; year?: string } | null | undefined; + if (userClass) { + if (userClass.sem) { + queryClient.setQueryData(["semester"], userClass.sem); + } + if (userClass.year) { + queryClient.setQueryData(["academic-year"], userClass.year); + } + } + } + }, [rawProfile, queryClient]); const setSemesterMutation = useSetSemester({ skipInvalidations: true }); const setAcademicYearMutation = useSetAcademicYear({ skipInvalidations: true }); const { targetPercentage } = useAttendanceSettings(); @@ -235,17 +462,10 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const currentSem = effectiveSemester || undefined; const currentYear = effectiveYear || undefined; + const isProfileReady = !!profile?.username && !isLoadingProfile; - useEffect(() => { - if (serverError) { - const isRateLimit = serverError.toLowerCase().includes("rate limit") || serverError.includes("429"); - toast.error(isRateLimit ? "EzyGo Rate Limit Reached" : "Dashboard Pre-fetch Failed", { - description: isRateLimit ? "Too many requests. Please wait." : "Failed to pre-load your data.", - duration: 8000, - action: { label: "Retry", onClick: () => window.location.reload() }, - }); - } - }, [serverError]); + const isRateLimitError = serverError ? (serverError.toLowerCase().includes("rate limit") || serverError.includes("429")) : false; + useDashboardServerErrorToast(serverError, isRateLimitError); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [isAddCourseOpen, setIsAddCourseOpen] = useState(false); @@ -254,43 +474,54 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const [isEditInstructorOpen, setIsEditInstructorOpen] = useState(false); const [selectedInstructorCourse, setSelectedInstructorCourse] = useState<{ code: string; name: string; initialName: string } | null>(null); const [isUpdating, setIsUpdating] = useState(false); + const [isShifting, setIsShifting] = useState(false); + const [hasRefetchedAllCourses, setHasRefetchedAllCourses] = useState(false); + const [hasSyncedAndLoaded, setHasSyncedAndLoaded] = useState(false); const [isSelectClassOpen, setIsSelectClassOpen] = useState(false); const academicShiftLockRef = useRef(false); - const { syncCompleted } = useSyncOnMount({ + const { syncSettled, syncFailed } = useSyncOnMount({ username: profile?.username, userId: profile?.id ? String(profile.id) : undefined, enabled: !!profile?.username, sentryLocation: "DashboardClient", - sentryTag: "background_sync" + sentryTag: "background_sync", + onSuccess: async (data) => { + const changed = (data.deletions ?? 0) + (data.updates ?? 0) + (data.conflicts ?? 0); + if (changed > 0) { + toast.info("Dashboard Updated", { + description: "Background sync applied latest attendance changes.", + }); + } + }, }); + const isSyncSettled = !profile?.username || syncSettled; + useDashboardSyncFailureToast(syncSettled, syncFailed); + useEffect(() => { - const shouldOpen = !!(syncCompleted && profile && !profile.class?.id); + const shouldOpen = !!(isSyncSettled && profile && !profile.class?.id); const timer = setTimeout(() => { setIsSelectClassOpen(shouldOpen); }, 0); return () => clearTimeout(timer); - }, [syncCompleted, profile]); + }, [isSyncSettled, profile]); + + const { isInitialDataValid, isAttendanceStale } = useMemo(() => + computeInitialDataValidity( + initialData, + selectedSemester, + selectedYear, + (ezygoSemester === "even" || ezygoSemester === "odd") ? ezygoSemester : undefined, + ezygoYear ?? undefined, + (effectiveSemester === "even" || effectiveSemester === "odd") ? effectiveSemester : undefined, + effectiveYear, + ), + [initialData, selectedSemester, selectedYear, ezygoSemester, ezygoYear, effectiveSemester, effectiveYear] + ); - const isInitialDataValid = useMemo(() => { - if (selectedSemester !== null || selectedYear !== null) return false; - if (ezygoSemester && ezygoSemester !== effectiveSemester) return false; - if (ezygoYear && ezygoYear !== effectiveYear) return false; - return true; - }, [selectedSemester, selectedYear, ezygoSemester, ezygoYear, effectiveSemester, effectiveYear]); - - const isAttendanceStale = - initialData?.attendance && - typeof initialData.attendance === "object" && - "studentAttendanceData" in initialData.attendance && - initialData.attendance.studentAttendanceData && - typeof initialData.attendance.studentAttendanceData === "object" && - "stale" in initialData.attendance.studentAttendanceData && - (initialData.attendance.studentAttendanceData as { stale?: unknown }).stale === true; - - const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { - enabled: syncCompleted, + const { data: rawAttendanceData, isLoading: isLoadingAttendance, isFetching: isFetchingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { + enabled: isProfileReady && !!currentSem && !!currentYear, initialData: (isInitialDataValid && !isAttendanceStale) ? (initialData?.attendance as AttendanceReport ?? undefined) : undefined, }); const attendanceData = rawAttendanceData as AttendanceReport | undefined; @@ -318,10 +549,10 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return undefined; }, [initialData]); - const { data: rawCoursesData, isLoading: isLoadingCourses } = useFetchCourses({ + const { data: rawCoursesData, isLoading: isLoadingCourses, isFetching: isFetchingCourses } = useFetchCourses({ semester: currentSem, year: currentYear, - enabled: syncCompleted && !!currentSem && !!currentYear, + enabled: isProfileReady && !!currentSem && !!currentYear, initialData: isInitialDataValid ? formattedInitialCourses : undefined, }); const coursesData = rawCoursesData as { courses: Record } | undefined; @@ -329,12 +560,12 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { data: rawTrackingData, refetch: refetchTracking } = useTrackingData(profile, { semester: currentSem, year: currentYear, - enabled: syncCompleted, + enabled: isProfileReady, }); const trackingData = rawTrackingData as TrackAttendance[] | undefined; - const { data: customInstructors } = useFetchCourseInstructors({ semester: currentSem, year: currentYear, enabled: syncCompleted }); - const { data: rawClassCourses } = useFetchClassCourses({ semester: currentSem, year: currentYear, enabled: syncCompleted && !!profile?.class?.id }); + const { data: customInstructors } = useFetchCourseInstructors({ semester: currentSem, year: currentYear, enabled: isProfileReady && !!currentSem && !!currentYear }); + const { data: rawClassCourses, isFetching: isFetchingClassCourses } = useFetchClassCourses({ semester: currentSem, year: currentYear, enabled: isProfileReady && !!currentSem && !!currentYear && !!profile?.class?.id }); const classCourses = rawClassCourses as ClassCourse[] | undefined; const { getCourseCodeById: getCourseCode } = useCourseLookup({ @@ -344,25 +575,140 @@ export default function DashboardClient({ initialData, serverError }: DashboardC attendanceData: attendanceData as any }) as { getCourseCodeById: (id: string | number) => string }; - const courseList = useMemo(() => { - const registry = new Map(); + const { courseList, courseRegistry } = useMemo(() => { + const listMap = new Map(); + const registry = new Map(); + if (coursesData?.courses) { - Object.entries(coursesData.courses).forEach(([code, c]) => { - const actualCode = c.code ?? code; - const key = actualCode.toUpperCase().replace(/[\s\u00A0-]/g, ""); - registry.set(key, { code: actualCode, id: Number(c.id), name: c.name || "" }); + Object.entries(coursesData.courses).forEach(([id, course]) => { + const courseCode = course.code ?? String(id); + const item = { code: courseCode, id: Number(course.id), name: course.name || "" }; + + // Deduplicate list by normalized code + const norm = normalizeCourseCode(courseCode || ""); + if (norm && !listMap.has(norm)) listMap.set(norm, item); + + // Populate registry keyed by id and by normalized code key + const basic: MergedCourse = { id: course.id || id, code: course.code, name: course.name, key: id }; + registry.set(String(id), basic); + const codeKey = normalizeCourseCode(course.code || ""); + if (codeKey && !registry.has(codeKey)) registry.set(codeKey, basic); }); } + if (classCourses) { classCourses.forEach((cc) => { - const key = cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (!registry.has(key)) registry.set(key, { code: cc.course_code, id: 0, name: cc.course_name || cc.course_code }); + const norm = normalizeCourseCode(cc.course_code); + if (!listMap.has(norm)) listMap.set(norm, { code: cc.course_code, id: 0, name: cc.course_name || cc.course_code }); + + const codeKey = normalizeCourseCode(cc.course_code); + if (!registry.has(codeKey)) { + registry.set(codeKey, { id: 0, code: cc.course_code, name: cc.course_name || cc.course_code, key: codeKey }); + } }); } - return Array.from(registry.values()); + + return { + courseList: Array.from(listMap.values()), + courseRegistry: Object.fromEntries(registry), + }; }, [coursesData, classCourses]); - const { data: allCourseSummaries, isLoading: isLoadingAllCourseSummaries } = useAllCourseDetails(courseList); + const isAllCourseDetailsEnabled = + !isUpdating && + (!isShifting || + (!isFetchingCourses && !isFetchingAttendance && !isFetchingClassCourses)); + + const { + data: allCourseSummaries, + isLoading: isLoadingAllCourseSummaries, + isFetching: isFetchingAllCourseSummaries, + } = useAllCourseDetails(courseList, currentSem, currentYear, { enabled: isAllCourseDetailsEnabled }); + + useEffect(() => { + if (syncSettled) { + queryClient.invalidateQueries({ queryKey: ["attendance-report"] }); + queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }); + queryClient.invalidateQueries({ queryKey: ["courses"] }); + } + }, [syncSettled, queryClient]); + + useEffect(() => { + if (syncSettled && !isFetchingAttendance && !isFetchingAllCourseSummaries) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional state update after sync completes and queries resolve + setHasSyncedAndLoaded(true); + } + }, [syncSettled, isFetchingAttendance, isFetchingAllCourseSummaries]); + + useEffect(() => { + if (!isShifting) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional state reset when shifting ends + setHasRefetchedAllCourses(false); + return; + } + + if ( + !isUpdating && + !isFetchingCourses && + !isFetchingAttendance && + !isFetchingClassCourses && + !isLoadingProfile && + !hasRefetchedAllCourses + ) { + setHasRefetchedAllCourses(true); + queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }); + } + }, [ + isShifting, + isUpdating, + isFetchingCourses, + isFetchingAttendance, + isFetchingClassCourses, + isLoadingProfile, + hasRefetchedAllCourses, + queryClient, + ]); + + useEffect(() => { + if ( + isShifting && + !isUpdating && + hasRefetchedAllCourses && + !isFetchingCourses && + !isFetchingAttendance && + !isFetchingClassCourses && + !isFetchingAllCourseSummaries + ) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional state reset when transition finishes + setIsShifting(false); + } + }, [ + isShifting, + isUpdating, + hasRefetchedAllCourses, + isFetchingCourses, + isFetchingAttendance, + isFetchingClassCourses, + isFetchingAllCourseSummaries, + ]); + + useEffect(() => { + if (selectedSemester !== null || selectedYear !== null) { + // Invalidate all active queries affected by the semester/year shift. + // Since they are now active under the new term parameters, this forces them + // to reload the fresh data. + queryClient.invalidateQueries({ queryKey: ["courses", currentSem, currentYear], exact: true }); + queryClient.invalidateQueries({ queryKey: ["attendance-report", currentSem, currentYear], exact: true }); + queryClient.invalidateQueries({ queryKey: ["class_courses"] }); + queryClient.invalidateQueries({ queryKey: ["course_instructors", currentSem, currentYear], exact: true }); + queryClient.invalidateQueries({ queryKey: ["track_data"] }); + queryClient.invalidateQueries({ queryKey: ["count"] }); + queryClient.invalidateQueries({ queryKey: ["exams", currentSem, currentYear], exact: true }); + queryClient.invalidateQueries({ queryKey: ["exam-answers"] }); + queryClient.invalidateQueries({ queryKey: ["exam-questions"] }); + queryClient.invalidateQueries({ queryKey: ["exam-details-batch"] }); + } + }, [selectedSemester, selectedYear, currentSem, currentYear, queryClient]); const { disabledCodes } = useDisabledCourses({ academicYear: currentYear, semester: currentSem }); @@ -370,69 +716,39 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const requestAcademicShift = (direction: "previous" | "next") => { if (!effectiveSemester || !effectiveYear || isUpdating || academicShiftLockRef.current) return; - - const nextPeriod = shiftAcademicPeriod(effectiveSemester, effectiveYear, direction); - if (!nextPeriod) { - toast.error("Could not compute the next academic period."); + const plan = planAcademicShift(direction, effectiveSemester, effectiveYear, defaultAcademicInfo); + if (plan.errorMessage) { + toast.error(plan.errorMessage); return; } + if (plan.infoMessage) { + toast.info(plan.infoMessage); + } + if (!plan.next) return; academicShiftLockRef.current = true; - setPendingChange(nextPeriod); + setPendingChange(plan.next); setShowConfirmDialog(true); }; const handleConfirmChange = async () => { - if (!pendingChange || !profile?.username || isUpdating) return; - setIsUpdating(true); - setShowConfirmDialog(false); - - try { - const mutations = []; - if (pendingChange.year !== effectiveYear) { - mutations.push( - setAcademicYearMutation.mutateAsync({ - default_academic_year: pendingChange.year, - }) - ); - } - if (pendingChange.semester !== effectiveSemester) { - mutations.push( - setSemesterMutation.mutateAsync({ - default_semester: pendingChange.semester, - }) - ); - } - - await Promise.all(mutations); - - setSelectedSemester(pendingChange.semester); - setSelectedYear(pendingChange.year); - - // Coordinated, single invalidation for all queries affected by the semester/year shift. - // This includes resetting the profile query which triggers the background sync once. - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["courses"] }), - queryClient.invalidateQueries({ queryKey: ["attendance-report"] }), - queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }), - queryClient.invalidateQueries({ queryKey: ["class_courses"] }), - queryClient.invalidateQueries({ queryKey: ["course_instructors"] }), - queryClient.invalidateQueries({ queryKey: ["track_data"] }), - queryClient.invalidateQueries({ queryKey: ["count"] }), - queryClient.invalidateQueries({ queryKey: ["profile"] }), - queryClient.invalidateQueries({ queryKey: ["exams"] }), - queryClient.invalidateQueries({ queryKey: ["exam-answers"] }), - queryClient.invalidateQueries({ queryKey: ["exam-questions"] }), - queryClient.invalidateQueries({ queryKey: ["exam-details-batch"] }), - ]); - } catch (error) { - logger.error("Update Failed:", error); - toast.error("Failed to update settings"); - } finally { - setIsUpdating(false); - setPendingChange(null); - academicShiftLockRef.current = false; - } + await executeAcademicChange({ + pendingChange, + profileUsername: profile?.username, + isUpdating, + effectiveYear, + effectiveSemester, + setAcademicYearMutation, + setSemesterMutation, + refetchProfile, + setSelectedSemester, + setSelectedYear, + setIsUpdating, + setIsShifting, + setShowConfirmDialog, + setPendingChange, + academicShiftLockRef, + }); }; const activeCourseCount = useMemo(() => getActiveCourseStats(attendanceData, trackingData || [], coursesData, classCourses || [], disabledCodes, effectiveSemester, effectiveYear, getCourseCode), [attendanceData, trackingData, coursesData, classCourses, disabledCodes, effectiveSemester, effectiveYear, getCourseCode]); @@ -454,31 +770,12 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const stats = useDashboardStats({ coursesData, attendanceData, trackingData, classCourses, disabledCodes, selectedSemester: effectiveSemester, selectedYear: effectiveYear }); - const courseRegistry = useMemo(() => { - const registry = new Map(); - if (coursesData?.courses) { - Object.entries(coursesData.courses).forEach(([id, course]) => { - const basic: MergedCourse = { id: course.id || id, code: course.code, name: course.name, key: id }; - registry.set(id, basic); - const codeKey = (course.code || "").toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (codeKey && !registry.has(codeKey)) registry.set(codeKey, basic); - }); - } - if (classCourses) { - classCourses.forEach((cc) => { - const codeKey = cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (!registry.has(codeKey)) { - registry.set(codeKey, { id: 0, code: cc.course_code, name: cc.course_name || cc.course_code, key: codeKey }); - } - }); - } - return Object.fromEntries(registry); - }, [coursesData, classCourses]); - const sortedCourses = useMemo(() => { + + const sortedCourses = useMemo(() => { const seen = new Set(); const unique = Object.values(courseRegistry).filter((c) => { - const key = (c.code || c.key).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const key = normalizeCourseCode(String(c.code || c.key)); if (seen.has(key)) return false; seen.add(key); return true; @@ -488,7 +785,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const summariesMap = new Map(allCourseSummaries ? Object.entries(allCourseSummaries) : []); return unique.map((course) => { - const codeKey = (course.code || course.key).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const codeKey = normalizeCourseCode(String(course.code || course.key)); const courseCode = String(course.code || ""); const activeDetails = summariesMap.get(courseCode); const stat = statsMap.get(codeKey) || statsMap.get(course.key) || { present: 0, total: 0, officialPresent: 0, officialTotal: 0 }; @@ -498,15 +795,24 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const safeRes = isNew ? { canBunk: 0 } : calculateAttendance(stat.officialPresent, stat.officialTotal, targetPercentage); return { - ...course, currentPercentage: stat.total > 0 ? Math.round((stat.present / stat.total) * 100) : 0, bunkable: res.canBunk, safeBunkable: safeRes.canBunk, required: res.requiredToAttend, isNew, isDisabled: disabledCodes.has(codeKey), ...stat, activeCourseDetails: activeDetails, name: String(course.name || "") - }; + ...course, + currentPercentage: stat.total > 0 ? Math.round((stat.present / stat.total) * 100) : 0, + bunkable: res.canBunk, + safeBunkable: safeRes.canBunk, + required: res.requiredToAttend, + isNew, + isDisabled: disabledCodes.has(codeKey), + ...stat, + activeCourseDetails: activeDetails, + name: String(course.name || "") + } as DashboardCourse; }).sort((a, b) => { const tA = getSortPriority(a); const tB = getSortPriority(b); if (tA !== tB) return tA - tB; if (tA === 0) { - if (b.bunkable !== a.bunkable) return b.bunkable - a.bunkable; - if (a.required !== b.required) return a.required - b.required; + if ((b.bunkable ?? 0) !== (a.bunkable ?? 0)) return (b.bunkable ?? 0) - (a.bunkable ?? 0); + if ((a.required ?? 0) !== (b.required ?? 0)) return (a.required ?? 0) - (b.required ?? 0); } return a.name.localeCompare(b.name); }); @@ -530,15 +836,31 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return
No data
; }; - if ((!profile && !isLoadingProfile) || !currentSem || !currentYear || !syncCompleted) return ; + if (!profile || isLoadingProfile || !currentSem || !currentYear) return ; + + const isDataLoading = !syncSettled || !hasSyncedAndLoaded || isSettingsLoading || isLoadingAttendance || (isAllCourseDetailsEnabled && isLoadingAllCourseSummaries); - const isGlobalLoading = isLoadingProfile || isUpdating || isSettingsLoading || setSemesterMutation.isPending || setAcademicYearMutation.isPending || !syncCompleted; + const isGlobalLoading = isLoadingProfile || isUpdating || isSettingsLoading || setSemesterMutation.isPending || setAcademicYearMutation.isPending || isShifting; return (
- {isGlobalLoading && (

Syncing...

)} + {isGlobalLoading && (

Syncing...

)} +
+ {serverError && ( +
+
+
+ {isRateLimitError ? "EzyGo Rate Limit" : "Data prefetch failed"} +
{isRateLimitError ? "Too many requests. Some data may be unavailable." : "Failed to preload dashboard data. You can retry or continue."}
+
+
+ +
+
+
+ )}

Welcome back, {profile?.first_name} {profile?.last_name}!

@@ -559,12 +881,24 @@ export default function DashboardClient({ initialData, serverError }: DashboardC >
- - []} customInstructors={customInstructors || []} allCourseSummaries={allCourseSummaries as Record} profile={profile as unknown as Record} onEditInstructor={(course: Record, _name: string, hasCustomName: boolean, customInstructor?: unknown) => { - const customInst = customInstructor as CustomInstructor | undefined; - setSelectedInstructorCourse({ code: String(course.code || course.id).toUpperCase().replace(/[\s\u00A0-]/g, ""), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); - setIsEditInstructorOpen(true); - }} onAddCourse={() => { - if (!profile?.class?.id) { - toast.error("You have not assigned a class yet."); - } else { - setIsAddCourseOpen(true); - } - }} /> - -
- - Attendance Calendar - - {renderAttendanceCalendarContent()} - - -
+ {isDataLoading ? ( +
+ +
+ ) : ( + <> + + } profile={profile ?? null} onEditInstructor={(course: DashboardCourse, _name: string, hasCustomName: boolean, customInstructor?: { instructor_name?: string | null } | undefined | null) => { + const customInst = customInstructor as CustomInstructor | undefined; + setSelectedInstructorCourse({ code: normalizeCourseCode(String(course.code || course.id)), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); + setIsEditInstructorOpen(true); + }} onAddCourse={() => { + if (!profile?.class?.id) { + toast.error("You have not assigned a class yet."); + } else { + setIsAddCourseOpen(true); + } + }} /> + +
+ + Attendance Calendar + + {renderAttendanceCalendarContent()} + + +
+ + )} ({ vi.mock('@tanstack/react-query', () => ({ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn(), + refetchQueries: vi.fn().mockResolvedValue({}), + setQueryData: vi.fn(), clear: vi.fn(), })), })); @@ -141,7 +143,7 @@ vi.mock('@/hooks/courses/useCourseLookup', () => ({ })); vi.mock('@/hooks/use-sync-on-mount', () => ({ - useSyncOnMount: vi.fn(() => ({ syncCompleted: true, isSyncing: false })), + useSyncOnMount: vi.fn(() => ({ isSyncing: false, syncSettled: true, syncFailed: false })), })); vi.mock('@/hooks/courses/useDisabledCourses', () => ({ diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx index 692cb24d..b6b0e758 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx @@ -61,7 +61,9 @@ vi.mock('@/hooks/use-sync-on-mount', () => ({ vi.mock('@tanstack/react-query', () => ({ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn(), + refetchQueries: vi.fn().mockResolvedValue({}), cancelQueries: vi.fn(), + setQueryData: vi.fn(), })), })); @@ -182,7 +184,7 @@ describe('DashboardClient', () => { vi.mocked(useDisabledCourses).mockReturnValue({ disabledCodes: new Set() } as any); vi.mocked(useCourseLookup).mockReturnValue({ getCourseCodeById: vi.fn((id) => id) } as any); vi.mocked(useAllCourseDetails).mockReturnValue({ data: [], isLoading: false, isFetching: false } as any); - vi.mocked(useSyncOnMount).mockReturnValue({ syncCompleted: true, isSyncing: false } as any); + vi.mocked(useSyncOnMount).mockReturnValue({ isSyncing: false, syncSettled: true, syncFailed: false } as any); vi.mocked(useSetSemester).mockReturnValue({ mutate: vi.fn(), mutateAsync: vi.fn().mockResolvedValue({}), isPending: false } as any); vi.mocked(useSetAcademicYear).mockReturnValue({ mutate: vi.fn(), mutateAsync: vi.fn().mockResolvedValue({}), isPending: false } as any); }); diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx index 7119932d..3b2b6bae 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as profileHooks from '@/hooks/users/profile'; import * as coursesHooks from '@/hooks/courses/courses'; import * as syncHooks from '@/hooks/use-sync-on-mount'; +import * as attendanceHooks from '@/hooks/courses/attendance'; type MockCourse = { id: number; @@ -72,7 +73,7 @@ vi.mock('@/hooks/use-dashboard-stats', () => ({ })); vi.mock('@/hooks/use-sync-on-mount', () => ({ - useSyncOnMount: vi.fn(() => ({ syncCompleted: true })), + useSyncOnMount: vi.fn(() => ({ syncSettled: true, syncFailed: false })), })); vi.mock('../components/CourseGrid', () => ({ @@ -204,7 +205,7 @@ const queryClient = new QueryClient({ describe('DashboardClient', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(syncHooks.useSyncOnMount).mockReturnValue({ syncCompleted: true, isSyncing: false }); + vi.mocked(syncHooks.useSyncOnMount).mockReturnValue({ isSyncing: false, syncSettled: true, syncFailed: false }); vi.mocked(profileHooks.useProfile).mockReturnValue({ data: { first_name: 'Test', last_name: 'User', username: 'testuser', id: 1, class: { name: 'Test Class' } }, isLoading: false, @@ -251,4 +252,31 @@ describe('DashboardClient', () => { expect(screen.getByTestId('edit-instructor-dialog')).toBeInTheDocument(); }); + + it('shows loading message when attendance is loading but profile has loaded', async () => { + vi.mocked(attendanceHooks.useAttendanceReport).mockReturnValue({ + data: null, + isLoading: true, + isFetching: true, + isError: false, + refetch: vi.fn(), + } as any); + + render( + + + + ); + + // Welcome message with name is visible + expect(await screen.findByText(/Welcome back,/i)).toBeInTheDocument(); + expect(screen.getByText(/Test User!/i)).toBeInTheDocument(); + + // Data panels and charts are NOT visible + expect(screen.queryByTestId('stats-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-charts')).not.toBeInTheDocument(); + + // Loader is visible + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); }); diff --git a/src/app/(protected)/dashboard/components/CourseGrid.tsx b/src/app/(protected)/dashboard/components/CourseGrid.tsx index d117dd26..56b17c2e 100644 --- a/src/app/(protected)/dashboard/components/CourseGrid.tsx +++ b/src/app/(protected)/dashboard/components/CourseGrid.tsx @@ -1,20 +1,32 @@ -import { CourseCard } from "@/components/attendance/course-card"; +import { CourseCard, ExtendedCourse } from "@/components/attendance/course-card"; import { Skeleton } from "@/components/ui/skeleton"; import { m as motion } from "framer-motion"; import { Plus } from "lucide-react"; +import { normalizeCourseCode } from "@/lib/utils"; + +export type DashboardCourse = ExtendedCourse & { + key: string | number; + isNew?: boolean; + isDisabled?: boolean; + currentPercentage?: number; + bunkable?: number; + safeBunkable?: number; + required?: number; + activeCourseDetails?: { present: number; absent: number; total: number } | null; +}; interface CourseGridProps { isLoadingCourses: boolean; isLoadingAllCourseSummaries: boolean; - sortedCourses: Array>; + sortedCourses: DashboardCourse[]; customInstructors: Array<{ course_code: string; instructor_name?: string | null }>; allCourseSummaries: Record | null; profile: { auth_id?: string | null } | null; onEditInstructor: ( - course: Record, + course: DashboardCourse, instructorName: string, hasCustomName: boolean, - customInstructor: unknown, + customInstructor: { instructor_name?: string | null } | undefined | null, ) => void; onAddCourse: () => void; } @@ -42,9 +54,7 @@ export function CourseGrid({ return ( <> {sortedCourses.map((course) => { - const courseCodeNormalized = - (String(course.code || "") || String(course.id)) - .toUpperCase().replace(/[\s\u00A0-]/g, ""); + const courseCodeNormalized = normalizeCourseCode(String(course.code || course.id)); const customInstructor = customInstructors ?.find( (ci) => ci.course_code === courseCodeNormalized, @@ -69,13 +79,13 @@ export function CourseGrid({ instructorName = `${ezygoInstructors[0].first_name} ${ezygoInstructors[0].last_name}`; } + const initialCourseDetails = allCourseSummaries?.[String(course.code || "")] as DashboardCourse["activeCourseDetails"]; + return (
- { isLoadingCourses: false, isLoadingAllCourseSummaries: false, sortedCourses: [ - { id: 1, code: 'CS101', key: '1', institution_users: [] }, - { id: 2, code: 'CS102', key: '2', institution_users: [] }, + { id: 1, name: 'Computer Science 1', code: 'CS101', key: '1', institution_users: [] }, + { id: 2, name: 'Computer Science 2', code: 'CS102', key: '2', institution_users: [] }, ], customInstructors: [], allCourseSummaries: {}, diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index 2285c22e..e6828018 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import { ErrorBoundary } from "@/components/error-boundary"; import { ensureCSRFToken } from "@/hooks/use-csrf-token"; import { OutageProvider } from "@/providers/outage-provider"; +import { Loading as CompLoading } from "@/components/loading"; function ProtectedChrome({ children }: { children: React.ReactNode }) { const [isHidden, setIsHidden] = useState(false); @@ -70,12 +71,7 @@ function ProtectedChrome({ children }: { children: React.ReactNode }) { isHidden ? "pointer-events-none" : "pointer-events-auto" )} style={{ paddingRight: "var(--scrollbar-width, 0px)" }} - {...((isHidden && - typeof HTMLElement !== "undefined" && - HTMLElement?.prototype && - "inert" in HTMLElement.prototype - ? { inert: true } - : {}) as unknown as { inert?: boolean })} + {...((isHidden && typeof HTMLElement !== "undefined") ? { inert: true } : {}) as { inert?: boolean }} > @@ -112,7 +108,7 @@ export default function ProtectedLayout({ }; }, []); - if (!isCsrfReady) return null; + if (!isCsrfReady) return ; return {children}; } \ No newline at end of file diff --git a/src/app/(protected)/leave-applications/LeaveClient.tsx b/src/app/(protected)/leave-applications/LeaveClient.tsx index 0ad6b167..ffd860df 100644 --- a/src/app/(protected)/leave-applications/LeaveClient.tsx +++ b/src/app/(protected)/leave-applications/LeaveClient.tsx @@ -79,6 +79,22 @@ const STATUS_MAP: Record = { }, }; +const normalizeActionType = (type?: string | null): string => { + if (!type) return ""; + const t = type.toLowerCase(); + if (t === "reject" || t === "rejected") return "reject"; + if (t === "approve" || t === "approved") return "approve"; + if (t === "forward" || t === "forwarded") return "forward"; + if (t === "recommend" || t === "recommended") return "recommend"; + if (t === "pending") return "pending"; + return t; +}; + +const isActed = (a: LeaveApprover) => + (a.action_at !== null && a.action_at !== undefined && a.action_at !== "") || + (a.action_by !== null && a.action_by !== undefined && a.action_by !== "") || + (a.action_by_user !== null && a.action_by_user !== undefined); + const getLeaveStatus = (approvers?: LeaveApprover[] | null): StatusInfo => { const defaultStatus: StatusInfo = { label: "Pending", @@ -96,13 +112,18 @@ const getLeaveStatus = (approvers?: LeaveApprover[] | null): StatusInfo => { if (!approvers || approvers.length === 0) return defaultStatus; // If any level has rejected, the entire leave is immediately Rejected - const hasRejected = approvers.some((a) => a.action_type === "reject"); + const hasRejected = approvers.some( + (a) => normalizeActionType(a.action_type) === "reject" && isActed(a) + ); if (hasRejected) { return STATUS_MAP.reject; } const actedApprovers = [...approvers] - .filter((a) => a.action_type !== null || a.action_at !== null) + .filter((a) => { + const norm = normalizeActionType(a.action_type); + return isActed(a) && norm !== "pending" && norm !== ""; + }) .sort( (a, b) => new Date(b.updated_at || "").getTime() - new Date(a.updated_at || "").getTime() @@ -110,10 +131,11 @@ const getLeaveStatus = (approvers?: LeaveApprover[] | null): StatusInfo => { if (actedApprovers.length === 0) return defaultStatus; - const hasPending = approvers.some( - (a) => a.action_type === null && a.action_at === null - ); - const lastAction = actedApprovers[0].action_type; + const hasPending = approvers.some((a) => { + const norm = normalizeActionType(a.action_type); + return norm === "pending" || (!isActed(a) && norm !== "reject"); + }); + const lastAction = normalizeActionType(actedApprovers[0].action_type); if (hasPending) { // If there are pending steps, we cannot be fully Approved. @@ -152,26 +174,37 @@ const formatBytes = (bytes: string | number) => { }; function getApproverStyles(type?: string | null) { - if (type === "approve") { + const norm = normalizeActionType(type); + if (norm === "approve") { return { bg: "bg-emerald-500/15", text: "text-emerald-600" }; } - if (type === "reject") { + if (norm === "reject") { return { bg: "bg-red-500/15", text: "text-red-600" }; } - if (type === "forward") { + if (norm === "forward") { return { bg: "bg-indigo-500/15", text: "text-indigo-600" }; } - if (type === "recommend") { + if (norm === "recommend") { return { bg: "bg-blue-500/15", text: "text-blue-600" }; } return { bg: "bg-primary/10", text: "text-primary" }; } function WorkflowHistoryItem({ approver }: { approver: LeaveApprover }) { - const statusInfo = STATUS_MAP[approver.action_type || ""] || { - label: approver.action_type || "Unknown", + const norm = normalizeActionType(approver.action_type); + let statusInfo = { + label: approver.action_type || "Pending", color: "bg-muted text-muted-foreground", }; + if (norm === "reject") { + statusInfo = STATUS_MAP.reject; + } else if (norm === "approve") { + statusInfo = STATUS_MAP.approve; + } else if (norm === "forward") { + statusInfo = STATUS_MAP.forward; + } else if (norm === "recommend") { + statusInfo = STATUS_MAP.recommend; + } const styles = getApproverStyles(approver.action_type); return ( @@ -420,6 +453,7 @@ export default function LeaveClient({ }, [initialData, semesterData, academicYearData]); const allSessions = initialData?.studentLeaves?.student_leave_sessions || {}; + const approvedCount = useMemo( () => leaves.filter((l) => getLeaveStatus(l.approvers).label === "Approved") diff --git a/src/app/(protected)/leave-applications/__tests__/LeaveClient.test.tsx b/src/app/(protected)/leave-applications/__tests__/LeaveClient.test.tsx index c1f175ae..1f8f248a 100644 --- a/src/app/(protected)/leave-applications/__tests__/LeaveClient.test.tsx +++ b/src/app/(protected)/leave-applications/__tests__/LeaveClient.test.tsx @@ -136,6 +136,156 @@ describe('LeaveClient', () => { expect(screen.queryByText('Test Reason 4')).not.toBeInTheDocument() }) + it('renders correctly for past-tense EzyGo action types (approved, rejected, forwarded, recommended, pending)', () => { + const initialData = { + studentLeaves: { + student_leaves: [ + createMockLeave(1, 'approved'), // Approved + createMockLeave(2, 'rejected'), // Rejected + createMockLeave(3, 'recommended'), // Recommended + createMockLeave(4, 'forwarded'), // Forwarded + createMockLeave(5, 'pending', { + approvers: [ + { + id: 50, + action_type: 'approved', + action_by: 'user-1', + action_by_user: { first_name: 'A', last_name: 'B' }, + action_at: '2026-03-26T10:00:00Z', + updated_at: '2026-03-26T10:00:00Z', + }, + { + id: 51, + action_type: 'pending', + action_by: 'user-2', + action_by_user: { first_name: 'C', last_name: 'D' }, + action_at: null, + updated_at: '2026-03-27T10:00:00Z', + } + ] + }), // In Progress (due to pending step) + ], + student_leave_sessions: {} + } + } + renderWithClient() + + // Total should be 5 + expect(screen.getAllByText('5').length).toBeGreaterThan(0) + // Approved count should be 1 + expect(screen.getAllByText('1').length).toBeGreaterThan(0) + + // Status badges should exist + expect(screen.getAllByText('Approved').length).toBeGreaterThan(0) + expect(screen.getAllByText('Rejected').length).toBeGreaterThan(0) + expect(screen.getAllByText('Recommended').length).toBeGreaterThan(0) + expect(screen.getAllByText('Forwarded').length).toBeGreaterThan(0) + expect(screen.getAllByText('In Progress').length).toBeGreaterThan(0) + }) + + it('ignores unacted placeholder actions (such as reject with null action_at/by)', () => { + const initialData = { + studentLeaves: { + student_leaves: [ + createMockLeave(1, null, { + leave_reason: 'Approved Leave with Reject Placeholder', + approvers: [ + { + id: 10, + action_type: 'reject', + action_by: null, + action_at: null, + action_by_user: null, + updated_at: '2026-03-25T14:00:50.000000Z', + }, + { + id: 11, + action_type: 'forward', + action_by: 5744, + action_at: '2026-04-01', + action_by_user: { first_name: 'Sony', last_name: 'P' }, + updated_at: '2026-04-01T07:44:59.000000Z', + }, + { + id: 12, + action_type: 'recommend', + action_by: 20045, + action_at: '2026-04-06', + action_by_user: { first_name: 'Sinu', last_name: 'T S' }, + updated_at: '2026-04-06T04:58:06.000000Z', + }, + { + id: 13, + action_type: 'approve', + action_by: 6053, + action_at: '2026-04-07', + action_by_user: { first_name: 'Dr Binu', last_name: 'V P' }, + updated_at: '2026-04-07T06:31:20.000000Z', + } + ] + }), + createMockLeave(2, null, { + leave_reason: 'Forwarded Leave with Reject Placeholder', + approvers: [ + { + id: 20, + action_type: 'reject', + action_by: null, + action_at: null, + action_by_user: null, + updated_at: '2026-03-25T14:00:50.000000Z', + }, + { + id: 21, + action_type: 'forward', + action_by: 5744, + action_at: '2026-04-01', + action_by_user: { first_name: 'Sony', last_name: 'P' }, + updated_at: '2026-04-01T07:44:59.000000Z', + } + ] + }), + createMockLeave(3, null, { + leave_reason: 'Fully Pending Leave', + approvers: [ + { + id: 30, + action_type: 'reject', + action_by: null, + action_at: null, + action_by_user: null, + updated_at: '2026-03-25T14:00:50.000000Z', + }, + { + id: 31, + action_type: 'approve', + action_by: null, + action_at: null, + action_by_user: null, + updated_at: '2026-03-25T14:00:50.000000Z', + } + ] + }) + ], + student_leave_sessions: {} + } + } + renderWithClient() + + // Total should be 3 + expect(screen.getAllByText('3').length).toBeGreaterThan(0) + + // Verify status badges are mapped correctly: + // Leave 1 is Approved + // Leave 2 is Forwarded + // Leave 3 is Pending + // Crucially, NO "Rejected" badge should exist since the reject placeholders are unacted! + expect(screen.getAllByText('Approved').length).toBeGreaterThan(0) + expect(screen.getAllByText('Forwarded').length).toBeGreaterThan(0) + expect(screen.getAllByText('Pending').length).toBeGreaterThan(0) + expect(screen.queryByText('Rejected')).not.toBeInTheDocument() + }) + it('renders files when provided', () => { const initialData = { studentLeaves: { diff --git a/src/app/(protected)/notifications/NotificationsClient.tsx b/src/app/(protected)/notifications/NotificationsClient.tsx index eee26312..9ffbfa9d 100644 --- a/src/app/(protected)/notifications/NotificationsClient.tsx +++ b/src/app/(protected)/notifications/NotificationsClient.tsx @@ -96,7 +96,7 @@ export default function NotificationsPage() { // Use mountId-based sync logic (now managed inside useSyncOnMount) // Sync attendance data on mount; deduplication handled by the hook. - const { isSyncing, syncCompleted } = useSyncOnMount({ + const { isSyncing, syncSettled, syncFailed } = useSyncOnMount({ username: user?.username, userId: user?.id, sentryLocation: "NotificationsClient", @@ -125,7 +125,7 @@ export default function NotificationsPage() { fetchNextPage, hasNextPage, isFetchingNextPage - } = useNotifications(syncCompleted); + } = useNotifications(true); const [readingId, setReadingId] = useState(null); @@ -134,28 +134,28 @@ export default function NotificationsPage() { const items: VirtualItem[] = []; // 1. ACTION REQUIRED (Unread Conflicts) - const unreadActions = actionNotifications.filter(n => !n.is_read); + const unreadActions = actionNotifications.filter((n: Notification) => !n.is_read); if (unreadActions.length > 0) { items.push({ type: 'header', id: 'action-header', label: 'ACTION REQUIRED' }); - unreadActions.forEach(n => { + unreadActions.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } // 2. UNREAD (Unread Regular) - const unreadRegular = regularNotifications.filter(n => !n.is_read); + const unreadRegular = regularNotifications.filter((n: Notification) => !n.is_read); if (unreadRegular.length > 0) { items.push({ type: 'header', id: 'unread-header', label: 'UNREAD' }); - unreadRegular.forEach(n => { + unreadRegular.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } // 3. EARLIER (All Read Notifications) - const readNotifications = regularNotifications.filter(n => n.is_read); + const readNotifications = regularNotifications.filter((n: Notification) => n.is_read); if (readNotifications.length > 0) { items.push({ type: 'header', id: 'earlier-header', label: 'EARLIER' }); - readNotifications.forEach(n => { + readNotifications.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } @@ -231,8 +231,8 @@ export default function NotificationsPage() { } }, [toggleRead, readingId, rowVirtualizer, user?.id]); - // Block rendering until user is available, data has loaded, and initial sync has completed. - if (!user?.id || isLoading || isSyncing || !syncCompleted) return ; + // Block rendering only on auth/data readiness; sync runs in the background. + if (!user?.id || isLoading) return ; const isEmpty = virtualItems.length === 0; @@ -254,6 +254,12 @@ export default function NotificationsPage() { Syncing
)} + {syncSettled && syncFailed && ( +
+ + Showing cached data +
+ )}
{unreadCount > 0 && ( diff --git a/src/app/(protected)/notifications/__tests__/NotificationsClient.minimal.test.tsx b/src/app/(protected)/notifications/__tests__/NotificationsClient.minimal.test.tsx index e159953b..9b8bd123 100644 --- a/src/app/(protected)/notifications/__tests__/NotificationsClient.minimal.test.tsx +++ b/src/app/(protected)/notifications/__tests__/NotificationsClient.minimal.test.tsx @@ -25,10 +25,11 @@ vi.mock("@/hooks/users/user", () => ({ })); vi.mock("@/hooks/use-sync-on-mount", () => ({ - useSyncOnMount: vi.fn(() => ({ - isSyncing: false, - syncCompleted: true, - })), + useSyncOnMount: vi.fn(() => ({ + isSyncing: false, + syncSettled: true, + syncFailed: false, + })), })); vi.mock("@/hooks/notifications/use-notification-virtualizer", () => ({ diff --git a/src/app/(protected)/notifications/__tests__/NotificationsClient.test.tsx b/src/app/(protected)/notifications/__tests__/NotificationsClient.test.tsx index c7fbfee5..7b87a28b 100644 --- a/src/app/(protected)/notifications/__tests__/NotificationsClient.test.tsx +++ b/src/app/(protected)/notifications/__tests__/NotificationsClient.test.tsx @@ -77,7 +77,8 @@ vi.mock('@/lib/logger', () => ({ vi.mock('@/hooks/use-sync-on-mount', () => ({ useSyncOnMount: vi.fn(() => ({ isSyncing: false, - syncCompleted: true, + syncSettled: true, + syncFailed: false, })), })); @@ -498,7 +499,7 @@ describe('NotificationsClient', () => { let partialSyncCallback: any; vi.mocked(useSyncOnMount).mockImplementation(({ onPartialSync }: any) => { partialSyncCallback = onPartialSync; - return { isSyncing: false, syncCompleted: true }; + return { isSyncing: false, syncSettled: true, syncFailed: false }; }); const { toast } = await import('sonner'); @@ -512,7 +513,7 @@ describe('NotificationsClient', () => { let successCallback: any; vi.mocked(useSyncOnMount).mockImplementation(({ onSuccess }: any) => { successCallback = onSuccess; - return { isSyncing: false, syncCompleted: true }; + return { isSyncing: false, syncSettled: true, syncFailed: false }; }); const { toast } = await import('sonner'); diff --git a/src/app/(protected)/scores/ScoresClient.tsx b/src/app/(protected)/scores/ScoresClient.tsx index 476c1054..7f9ec060 100644 --- a/src/app/(protected)/scores/ScoresClient.tsx +++ b/src/app/(protected)/scores/ScoresClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useCallback, useEffect } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { LazyMotion, @@ -27,6 +27,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useQueryClient } from "@tanstack/react-query"; import { Loading } from "@/components/loading"; import { useExams, useExamAnswers, useExamQuestions, useBatchExamDetails } from "@/hooks/courses/exams"; import { useFetchSemester, useFetchAcademicYear } from "@/hooks/users/settings"; @@ -207,7 +208,12 @@ function ScoreCard({ onClick={onClick} role="button" tabIndex={0} - onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onClick()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} aria-label={`View details for ${exam.name}`} > @@ -240,7 +246,7 @@ function ScoreCard({ {/* Course */}
- + {getCourseName(exam)}
@@ -461,11 +467,13 @@ function ExamDetailDrawer({ const computedTotal = useMemo(() => { if (!answers || answers.length === 0) return null; // Deduplicate by unique answer ID to prevent inflation from API duplicates - const uniqueAnswers = Array.from(new Map(answers.map((a) => [a.id, a])).values()); - const hasAnyScore = uniqueAnswers.some((a) => a.score != null); + const uniqueAnswers = Array.from( + new Map(answers.map((a: ExamAnswer) => [a.id, a])).values() + ); + const hasAnyScore = uniqueAnswers.some((a: ExamAnswer) => a.score != null); if (!hasAnyScore) return null; - return uniqueAnswers.reduce( - (sum, a) => sum + (a.score != null ? safeParseFloat(a.score) : 0), + return uniqueAnswers.reduce( + (sum: number, a: ExamAnswer) => sum + (a.score != null ? safeParseFloat(a.score) : 0), 0 ); }, [answers]); @@ -491,23 +499,25 @@ function ExamDetailDrawer({ if (!questions || questions.length === 0) return null; // Deduplicate by unique question ID - const uniqueQuestions = Array.from(new Map(questions.map((q) => [q.id, q])).values()); + const uniqueQuestions = Array.from( + new Map(questions.map((q: ExamQuestion) => [q.id, q])).values() + ); // Identify parents to find leaves const parentIds = new Set( uniqueQuestions - .map((q) => q.subquestion_parent_id) + .map((q: ExamQuestion) => q.subquestion_parent_id) .filter((id): id is number => id !== null) ); - const leaves = uniqueQuestions.filter((q) => !parentIds.has(q.id)); + const leaves = uniqueQuestions.filter((q: ExamQuestion) => !parentIds.has(q.id)); // Priority 3: Only sum leaves that have been graded (have a non-null score). // This is the most reliable way to handle flexible papers in EzyGo, // where unattempted optional questions are returned but shouldn't count. const gradedQuestionIds = new Set( - answers?.filter((a) => a.score !== null).map((a) => a.examquestion_id) || [] + answers?.filter((a: ExamAnswer) => a.score !== null).map((a: ExamAnswer) => a.examquestion_id) || [] ); - const gradedLeaves = leaves.filter((q) => gradedQuestionIds.has(q.id)); + const gradedLeaves = leaves.filter((q: ExamQuestion) => gradedQuestionIds.has(q.id)); const targetSet = gradedLeaves.length > 0 ? gradedLeaves : leaves; @@ -563,6 +573,59 @@ function ExamDetailDrawer({ }; }, []); + // Focus trap: constrain keyboard focus within the drawer while open. + const panelRef = useRef(null); + useEffect(() => { + const node = panelRef.current; + if (!node) return; + + const focusableSelector = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]'; + const elements = Array.from(node.querySelectorAll(focusableSelector)).filter((el) => el.offsetParent !== null || el.getAttribute('tabindex') !== '-1'); + const first = elements[0]; + const last = elements[elements.length - 1]; + + const prevActive = document.activeElement as HTMLElement | null; + // If there's a dedicated close button it has autoFocus, otherwise focus the panel or first element. + if (!first) { + node.focus?.(); + } else { + // Ensure something inside is focused so screen readers announce dialog content + // but do not steal focus if the close button already focused it (autoFocus). + if (document.activeElement === document.body && first) first.focus(); + } + + const handleKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + if (elements.length === 0) { + e.preventDefault(); + return; + } + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (!active || active === first || !node.contains(active)) { + e.preventDefault(); + last?.focus(); + } + } else { + if (!active || active === last || !node.contains(active)) { + e.preventDefault(); + first?.focus(); + } + } + }; + + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('keydown', handleKey); + // Restore previous focus when drawer closes + try { + prevActive?.focus?.(); + } catch { + // ignore + } + }; + }, []); + const isAssessment = exam.activity_type === "assessment"; return ( @@ -587,6 +650,7 @@ function ExamDetailDrawer({ exit={{ x: "100%" }} transition={{ type: "spring", damping: 28, stiffness: 280 }} className="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-card border-l-2 border-border shadow-[-8px_0_32px_rgba(0,0,0,0.35)]" + ref={panelRef} role="dialog" aria-modal="true" aria-label={`Exam details: ${exam.name}`} @@ -688,7 +752,7 @@ function ExamDetailDrawer({
{/* Footer — computed total (only for marked exams) */} - {computedTotal !== null && totalPossible !== null && ( + {computedTotal != null && totalPossible != null && (
@@ -793,7 +857,7 @@ function CourseGroupsSection({ className="h-4 w-4 text-primary shrink-0" aria-hidden="true" /> - + {group.label} {isCourseDisabled( @@ -835,6 +899,7 @@ function CourseGroupsSection({ export default function ScoresClient() { const [filter, setFilter] = useState("all"); const router = useRouter(); + const queryClient = useQueryClient(); const searchParams = useSearchParams(); const pathname = usePathname(); const { data: exams, isLoading: examsLoading, isError, refetch, isFetching } = useExams(); @@ -843,7 +908,7 @@ export default function ScoresClient() { const panel = searchParams.get("panel"); const selectedExam = useMemo(() => { if (!panel) return null; - return exams?.find((e) => e.id.toString() === panel) ?? null; + return exams?.find((e: Exam) => e.id.toString() === panel) ?? null; }, [panel, exams]); const { data: semesterData } = useFetchSemester(); const { data: academicYearData } = useFetchAcademicYear(); @@ -859,13 +924,32 @@ export default function ScoresClient() { const examIds = useMemo( () => exams - ?.filter((e) => e.participants && e.participants.length > 0) - .map((e) => e.id) ?? [], + ?.filter((e: Exam) => e.participants && e.participants.length > 0) + .map((e: Exam) => e.id) ?? [], [exams] ); // Pre-fetch all exam answers in parallel on load via a single batch request. const batchQuery = useBatchExamDetails(examIds); + useEffect(() => { + if (!batchQuery.data) return; + + for (const [idStr, detailsVal] of Object.entries(batchQuery.data)) { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; + const examId = Number.parseInt(idStr, 10); + if (!Number.isFinite(examId)) continue; + + queryClient.setQueryData( + ["exam-answers", examId, semesterData, academicYearData], + details.answers, + ); + queryClient.setQueryData( + ["exam-questions", examId, semesterData, academicYearData], + details.questions, + ); + } + }, [batchQuery.data, queryClient, semesterData, academicYearData]); + // Block render until exams list + batch details have settled. const isLoading = examsLoading || batchQuery.isPending; @@ -877,19 +961,20 @@ export default function ScoresClient() { const resMap = new Map(); if (!batchQuery.data) return resMap; - Object.entries(batchQuery.data).forEach(([idStr, details]) => { + Object.entries(batchQuery.data).forEach(([idStr, detailsVal]) => { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; const id = parseInt(idStr, 10); const answers = details.answers; if (answers && answers.length > 0) { const uniqueAnswers = Array.from( - new Map(answers.map((a) => [a.id, a])).values(), + new Map(answers.map((a: ExamAnswer) => [a.id, a])).values(), ); - const hasAnyScore = uniqueAnswers.some((a) => a.score != null); + const hasAnyScore = uniqueAnswers.some((a: ExamAnswer) => a.score != null); if (hasAnyScore) { resMap.set( id, - uniqueAnswers.reduce( - (sum, a) => sum + (a.score != null ? safeParseFloat(a.score) : 0), + uniqueAnswers.reduce( + (sum: number, a: ExamAnswer) => sum + (a.score != null ? safeParseFloat(a.score) : 0), 0, ), ); @@ -908,9 +993,10 @@ export default function ScoresClient() { const resMap = new Map(); if (!batchQuery.data) return resMap; - Object.entries(batchQuery.data).forEach(([idStr, details]) => { + Object.entries(batchQuery.data).forEach(([idStr, detailsVal]) => { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; const id = parseInt(idStr, 10); - const exam = exams?.find((e) => e.id === id); + const exam = exams?.find((e: Exam) => e.id === id); const qData = details.questions; if (id !== undefined) { @@ -961,7 +1047,7 @@ export default function ScoresClient() { */ const participatedExams = useMemo(() => { if (!exams) return []; - return exams.filter((e) => { + return exams.filter((e: Exam) => { if (!e.participants || e.participants.length === 0) return false; if (e.activity_type === "assignment") { const details = batchQuery.data?.[e.id]; @@ -977,7 +1063,7 @@ export default function ScoresClient() { const base = filter === "all" ? participatedExams - : participatedExams.filter((e) => e.activity_type === filter); + : participatedExams.filter((e: Exam) => e.activity_type === filter); // Marked (has resolved score) first, then pending return [...base].sort((a, b) => { const aScored = resolvedScores.has(a.id) || getScore(a) !== null ? 1 : 0; @@ -989,8 +1075,8 @@ export default function ScoresClient() { const counts: Record = useMemo(() => { return { all: participatedExams.length, - assessment: participatedExams.filter((e) => e.activity_type === "assessment").length, - assignment: participatedExams.filter((e) => e.activity_type === "assignment").length, + assessment: participatedExams.filter((e: Exam) => e.activity_type === "assessment").length, + assignment: participatedExams.filter((e: Exam) => e.activity_type === "assignment").length, }; }, [participatedExams]); diff --git a/src/app/(protected)/tracking/TrackingClient.tsx b/src/app/(protected)/tracking/TrackingClient.tsx index 90a97916..c6fb5310 100644 --- a/src/app/(protected)/tracking/TrackingClient.tsx +++ b/src/app/(protected)/tracking/TrackingClient.tsx @@ -1337,7 +1337,7 @@ export default function TrackingClient() { // --- AUTO SYNC --- - const { isSyncing, syncCompleted } = useSyncOnMount({ + const { isSyncing, syncSettled, syncFailed } = useSyncOnMount({ username: profile?.username, userId: profile?.id, sentryLocation: "TrackingClient", @@ -1463,9 +1463,8 @@ export default function TrackingClient() { // --- 2. OFFICIAL SESSION LOOKUP MAP --- const officialSessionsMap = useMemo(() => buildOfficialSessionsMap(attendanceData), [attendanceData]); - // Block rendering until tracking is enabled, base data has loaded, and initial sync has completed. - const isInitialLoading = !enabled || isDataLoading || isSyncing || - !syncCompleted; + // Block rendering only on base data readiness; sync runs in the background. + const isInitialLoading = !enabled || isDataLoading; const hasBaseErrors = isTrackingError || isCountError || isCoursesError || isAttendanceError || isSemesterError || isAcademicYearError; @@ -1558,6 +1557,17 @@ export default function TrackingClient() { Syncing with EzyGo... )} + {syncSettled && syncFailed && ( + + + Showing cached data until sync recovers. + + )} {hasCount && ( ({ vi.mock('@/hooks/use-sync-on-mount', () => ({ useSyncOnMount: vi.fn(() => ({ isSyncing: false, - syncCompleted: true, + syncSettled: true, + syncFailed: false, })), })); diff --git a/src/app/(protected)/tracking/__tests__/TrackingClient.test.tsx b/src/app/(protected)/tracking/__tests__/TrackingClient.test.tsx index 0d210b26..2f15f1db 100644 --- a/src/app/(protected)/tracking/__tests__/TrackingClient.test.tsx +++ b/src/app/(protected)/tracking/__tests__/TrackingClient.test.tsx @@ -92,7 +92,8 @@ vi.mock('@/hooks/courses/useDisabledCourses', () => ({ vi.mock('@/hooks/use-sync-on-mount', () => ({ useSyncOnMount: vi.fn(() => ({ isSyncing: false, - syncCompleted: true, + syncSettled: true, + syncFailed: false, })), })); diff --git a/src/app/(protected)/tracking/__tests__/simple.test.tsx b/src/app/(protected)/tracking/__tests__/simple.test.tsx index 4fda59cd..37512816 100644 --- a/src/app/(protected)/tracking/__tests__/simple.test.tsx +++ b/src/app/(protected)/tracking/__tests__/simple.test.tsx @@ -35,7 +35,7 @@ vi.mock('@/hooks/courses/useDisabledCourses', () => ({ })), })); vi.mock('@/hooks/use-sync-on-mount', () => ({ - useSyncOnMount: vi.fn(() => ({ isSyncing: false, syncCompleted: true })), + useSyncOnMount: vi.fn(() => ({ isSyncing: false, syncSettled: true, syncFailed: false })), })); diff --git a/src/app/actions/courses.ts b/src/app/actions/courses.ts index 8578eebe..541a5aa1 100644 --- a/src/app/actions/courses.ts +++ b/src/app/actions/courses.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; import { courseCodeSchema, courseNameSchema } from "@/lib/validation/text"; +import { normalizeCourseCode } from "@/lib/utils"; export async function addCourseAction(formData: FormData): Promise<{ error?: string }> { const courseCodeValue = formData.get("courseCode"); @@ -86,7 +87,7 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str .from("class_courses") .insert({ class_id: profile.class_id, - course_code: code.toUpperCase().replace(/[\s\u00A0-]/g, ""), + course_code: normalizeCourseCode(code), course_name: name, created_by: user.id }); diff --git a/src/app/actions/instructors.ts b/src/app/actions/instructors.ts index 3e5ee568..3f0b6b20 100644 --- a/src/app/actions/instructors.ts +++ b/src/app/actions/instructors.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; import { courseCodeSchema, personNameSchema } from "@/lib/validation/text"; +import { normalizeCourseCode } from "@/lib/utils"; export async function upsertInstructorAction( formData: FormData, @@ -88,7 +89,7 @@ export async function upsertInstructorAction( .from("course_instructors") .upsert({ class_id: profile.class_id, - course_code: courseCode.toUpperCase().replace(/[\s\u00A0-]/g, ""), + course_code: normalizeCourseCode(courseCode), instructor_name: instructorName, updated_by: user.id }, { diff --git a/src/app/actions/user.ts b/src/app/actions/user.ts index 366b0321..f55d6e23 100644 --- a/src/app/actions/user.ts +++ b/src/app/actions/user.ts @@ -1,6 +1,7 @@ "use server"; import { cookies } from "next/headers"; +import { isCookieSecure } from "@/lib/security/cookie-utils"; import { createClient } from "@/lib/supabase/server"; import { getAdminClient } from "@/lib/supabase/admin"; import { revalidatePath } from "next/cache"; @@ -85,7 +86,7 @@ export async function setTermsVersionCookie(version: string): Promise { path: "/", maxAge: 31536000, // 1 year sameSite: "lax", - secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', + secure: isCookieSecure(), httpOnly: true, // Secure cookie - checked server-side in proxy.ts }); } @@ -103,7 +104,7 @@ export async function clearTermsVersionCookie() { path: "/", maxAge: 0, sameSite: "lax", - secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', + secure: isCookieSecure(), httpOnly: true, }); } @@ -121,7 +122,7 @@ export async function clearTermsRedirectCountCookie() { path: "/", maxAge: 0, sameSite: "strict", - secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', + secure: isCookieSecure(), httpOnly: true, }); } diff --git a/src/app/api/auth/save-token/__tests__/route.test.ts b/src/app/api/auth/save-token/__tests__/route.test.ts index 4261ac3a..91b1d29f 100644 --- a/src/app/api/auth/save-token/__tests__/route.test.ts +++ b/src/app/api/auth/save-token/__tests__/route.test.ts @@ -87,6 +87,7 @@ import { headers, cookies } from "next/headers"; import { authRateLimiter } from "@/lib/ratelimit"; import { egressFetch } from "@/lib/utils.server"; import { getAdminClient } from "@/lib/supabase/admin"; +import { __resetAllowedHostsCache } from "@/lib/security/origin-validation"; describe("POST /api/auth/save-token", () => { const mockHeaders = { @@ -100,6 +101,7 @@ describe("POST /api/auth/save-token", () => { beforeEach(() => { vi.clearAllMocks(); + __resetAllowedHostsCache(); vi.mocked(authRateLimiter.limit).mockResolvedValue({ success: true, limit: 10, reset: 0, remaining: 9 } as any); mockHeaders.get.mockImplementation((name) => { if (name === "x-csrf-token") return "valid-csrf"; @@ -129,6 +131,44 @@ describe("POST /api/auth/save-token", () => { expect(body.message).toBe("Invalid origin"); }); + it("accepts requests behind a proxy when x-forwarded-host matches the app domain", async () => { + mockHeaders.get.mockImplementation((name) => { + if (name === "origin") return null; + if (name === "sec-fetch-site") return "same-origin"; + if (name === "x-forwarded-host") return "localhost:3000"; + if (name === "host") return "internal-container:3000"; + return "valid-csrf"; + }); + + vi.mocked(egressFetch).mockResolvedValue({ + status: 200, + json: async () => ({ username: "proxyuser", id: "99999", email: "proxy@example.com" }), + } as any); + + const mockSupabaseAdmin = { + auth: { + admin: { + createUser: vi.fn(() => Promise.resolve({ data: { user: { id: "proxy-auth-id" } }, error: null })), + }, + }, + from: vi.fn(() => ({ + upsert: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + single: vi.fn(() => Promise.resolve({ data: { id: "99999" }, error: null })), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn(() => Promise.resolve({ data: null })), + })), + }; + vi.mocked(getAdminClient).mockReturnValue(mockSupabaseAdmin as any); + + const req = { json: async () => ({ token: "a-very-long-token-that-is-valid-length" }) } as any; + const response = await POST(req, {} as any); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.userId).toBe("proxy-auth-id"); + }); + it("returns 429 when rate limited", async () => { vi.mocked(authRateLimiter.limit).mockResolvedValue({ success: false, limit: 1, reset: Date.now() + 1000, remaining: 0 } as any); @@ -176,6 +216,7 @@ describe("POST /api/auth/save-token", () => { const body = await response.json(); expect(body.success).toBe(true); expect(body.userId).toBe("new-auth-id"); + expect(body.ezygo_token).toBeUndefined(); }); it("handles existing user login", async () => { diff --git a/src/app/api/auth/save-token/route.ts b/src/app/api/auth/save-token/route.ts index 8660ee65..1920bfc5 100644 --- a/src/app/api/auth/save-token/route.ts +++ b/src/app/api/auth/save-token/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { encrypt, decrypt } from "@/lib/crypto"; import { authRateLimiter } from "@/lib/ratelimit"; import { headers, cookies } from "next/headers"; @@ -14,6 +14,7 @@ import { performProfileSync } from "@/lib/user/sync"; import { withSecurity } from "@/lib/security/app-check"; import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; import { getAuthLock, releaseAuthLock as releaseAuthLockUtil } from "@/lib/security/auth-lock"; +import { getAllowedHosts, resolveRequestHostname } from "@/lib/security/origin-validation"; export const dynamic = "force-dynamic"; @@ -44,21 +45,30 @@ const AUTH_LOCK_TTL = (() => { async function validateOrigin(headerList: Headers, isMobileApp: boolean) { if (isMobileApp || process.env.NODE_ENV === "development") return null; - const origin = headerList.get("origin"); - const host = headerList.get("host"); - if (!origin || !host) return "Invalid origin"; + let allowedHosts: Set | null; + try { + allowedHosts = getAllowedHosts(); + } catch { + return "Server configuration error"; + } + if (!allowedHosts) return "Server configuration error"; - const appDomain = process.env.NEXT_PUBLIC_APP_DOMAIN; - if (!appDomain?.trim() || appDomain.includes("://")) return "Server configuration error"; + const origin = headerList.get("origin"); + if (!origin) { + const secFetchSite = headerList.get("sec-fetch-site")?.toLowerCase(); + const requestHostname = resolveRequestHostname({ + headers: headerList, + nextUrl: { hostname: headerList.get("host") ?? "" }, + } as NextRequest); + if (!(secFetchSite === "same-origin" && !!requestHostname && allowedHosts.has(requestHostname))) { + return "Invalid origin"; + } + return null; + } try { const originHostname = new URL(origin).hostname.toLowerCase(); - const headerHostname = new URL(`http://${host}`).hostname.toLowerCase(); - if (originHostname !== headerHostname) return "Invalid origin"; - - const appDomainHostname = new URL(`https://${appDomain.trim()}`).hostname - .toLowerCase(); - if (originHostname !== appDomainHostname) return "Invalid origin"; + if (!allowedHosts.has(originHostname)) return "Invalid origin"; } catch { return "Invalid origin"; } @@ -185,6 +195,7 @@ async function provisionSupabaseAuthUser( email, password: canonicalPass, email_confirm: true, + user_metadata: { ezygo_id: verifiedId }, }); if (retryError || !retry.user) { @@ -396,7 +407,6 @@ const handler = async ( return NextResponse.json({ ...response, session: signInData.session, - ezygo_token: token, }); } catch (error: unknown) { diff --git a/src/app/api/health/ezygo/__tests__/route.test.ts b/src/app/api/health/ezygo/__tests__/route.test.ts index 01572d02..e74e020a 100644 --- a/src/app/api/health/ezygo/__tests__/route.test.ts +++ b/src/app/api/health/ezygo/__tests__/route.test.ts @@ -76,7 +76,7 @@ describe("EzyGo Health Check API Route", () => { vi.resetModules(); const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "CLOSED", failures: 0, isOpen: false, @@ -153,7 +153,7 @@ describe("EzyGo Health Check API Route", () => { vi.resetModules(); const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "OPEN", failures: 5, isOpen: true, @@ -175,7 +175,7 @@ describe("EzyGo Health Check API Route", () => { vi.resetModules(); const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "CLOSED", failures: 0, isOpen: false, @@ -207,7 +207,7 @@ describe("EzyGo Health Check API Route", () => { // Re-import circuit breaker after resetModules to get the fresh mocked instance const { ezygoCircuitBreaker: prodCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(prodCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(prodCircuitBreaker.getStatus).mockResolvedValue({ state: "OPEN", failures: 3, isOpen: true, @@ -225,7 +225,7 @@ describe("EzyGo Health Check API Route", () => { // Re-import circuit breaker after resetModules to get the fresh mocked instance const { ezygoCircuitBreaker: devCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(devCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(devCircuitBreaker.getStatus).mockResolvedValue({ state: "OPEN", failures: 3, isOpen: true, @@ -257,7 +257,7 @@ describe("EzyGo Health Check API Route", () => { vi.resetModules(); const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "OPEN", failures: 5, isOpen: true, @@ -279,7 +279,7 @@ describe("EzyGo Health Check API Route", () => { const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); const { getRateLimiterStats } = await import("@/lib/ezygo-batch-fetcher"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "CLOSED", failures: 0, isOpen: false, @@ -308,7 +308,7 @@ describe("EzyGo Health Check API Route", () => { const { ezygoCircuitBreaker } = await import("@/lib/circuit-breaker"); const { getRateLimiterStats } = await import("@/lib/ezygo-batch-fetcher"); - vi.mocked(ezygoCircuitBreaker.getStatus).mockReturnValue({ + vi.mocked(ezygoCircuitBreaker.getStatus).mockResolvedValue({ state: "CLOSED", failures: 0, isOpen: false, diff --git a/src/app/api/health/ezygo/route.ts b/src/app/api/health/ezygo/route.ts index 546cf7d9..b68f9d7f 100644 --- a/src/app/api/health/ezygo/route.ts +++ b/src/app/api/health/ezygo/route.ts @@ -11,7 +11,7 @@ import { ezygoCircuitBreaker } from "@/lib/circuit-breaker"; */ export async function GET() { const rateLimiterStats = getRateLimiterStats(); - const circuitBreakerStatus = ezygoCircuitBreaker.getStatus(); + const circuitBreakerStatus = await ezygoCircuitBreaker.getStatus(); const hasBacklog = rateLimiterStats.queueLength > 0; diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index b289e9de..5ff34920 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import { clearAuthCookie } from "@/lib/security/auth-cookie"; +import { isCookieSecure } from "@/lib/security/cookie-utils"; import { removeCsrfToken } from "@/lib/security/csrf"; import { clearTermsVersionCookie, clearTermsRedirectCountCookie } from "@/app/actions/user"; import { authRateLimiter } from "@/lib/ratelimit"; @@ -57,7 +58,7 @@ const handler = async (req: NextRequest) => { path: "/", expires: new Date(0), sameSite: "lax", - secure: process.env.HTTPS === "true" || process.env.NODE_ENV === "production", + secure: isCookieSecure(), }); // @supabase/ssr may also write a chunked variant: sb-{ref}-auth-token.0, .1 â€Ļ // Clear any chunks present in the current request cookies. @@ -68,7 +69,7 @@ const handler = async (req: NextRequest) => { path: "/", expires: new Date(0), sameSite: "lax", - secure: process.env.HTTPS === "true" || process.env.NODE_ENV === "production", + secure: isCookieSecure(), }); } } diff --git a/src/app/api/profile/__tests__/route.test.ts b/src/app/api/profile/__tests__/route.test.ts index 4d4331ca..b535c512 100644 --- a/src/app/api/profile/__tests__/route.test.ts +++ b/src/app/api/profile/__tests__/route.test.ts @@ -856,4 +856,98 @@ describe("Edge Case & Branch Coverage", () => { expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "Failed to update profile" }); }); + + it("backgrounds the profile sync when class sem/year match expected academic settings (no conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: expected.current_semester, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).not.toHaveBeenCalled(); }); + + it("performs blocking profile sync synchronously when class sem/year do not match expected settings (conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + const wrongSem = expected.current_semester === "even" ? "odd" : "even"; + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: wrongSem, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).toHaveBeenCalledOnce(); + }); + + it("performs blocking profile sync synchronously when force=true even when class sem/year match expected settings (no conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: expected.current_semester, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true&force=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index b98cf854..c8d3344a 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -13,6 +13,7 @@ import { withSecurity } from "@/lib/security/app-check"; import { performProfileSync } from "@/lib/user/sync"; import { getProfileBundle } from "@/lib/user/profile-bundle"; import { birthDateSchema, genderSchema, optionalPersonNameSchema, personNameSchema } from "@/lib/validation/text"; +import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; export const dynamic = "force-dynamic"; @@ -141,7 +142,7 @@ async function performSyncAndFetchUser( }; } - const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", userId).single(); + const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", userId).single(); return { updatedUser: updatedUser ?? existingUser, syncResult, @@ -233,9 +234,9 @@ const getHandler = async (req: NextRequest) => { const user = await authenticateUser(req, supabaseAdmin); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } }); - const { data: existingUserRaw } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", user.id).maybeSingle(); + const { data: existingUserRaw } = await supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", user.id).maybeSingle(); const searchParams = req.nextUrl.searchParams; - const shouldSync = searchParams.get("sync") === "true"; + let shouldSync = searchParams.get("sync") === "true"; const force = searchParams.get("force") === "true"; if (existingUserRaw && existingUserRaw.first_name) { const lastSyncedAtStr = existingUserRaw.last_synced_at as string | null | undefined; @@ -243,6 +244,18 @@ const getHandler = async (req: NextRequest) => { const minutesSinceSync = (Date.now() - lastSyncedAt.getTime()) / 60000; const isDebounced = !force && minutesSinceSync < 5; + // Check if semester/year has changed since last sync + const userClass = existingUserRaw.class as { sem?: string; year?: string } | null | undefined; + const expectedAcademic = calculateCurrentAcademicInfo(); + const hasAcademicConflict = !userClass || + userClass.sem !== expectedAcademic.current_semester || + userClass.year !== expectedAcademic.current_year; + + // If there is no conflict/change, we can optimize and do the profile sync in the background (non-blocking) + if (shouldSync && !hasAcademicConflict && !force) { + shouldSync = false; + } + return loadExistingUserBundle(existingUserRaw, user.id, shouldSync, isDebounced, supabaseAdmin); } diff --git a/src/components/attendance/AddAttendanceDialog.tsx b/src/components/attendance/AddAttendanceDialog.tsx index 5b7bb368..0f2e521e 100644 --- a/src/components/attendance/AddAttendanceDialog.tsx +++ b/src/components/attendance/AddAttendanceDialog.tsx @@ -44,7 +44,7 @@ import { startOfMonth, subMonths, } from "date-fns"; -import { cn } from "@/lib/utils"; +import { cn, normalizeCourseCode } from "@/lib/utils"; import { optionalReasonSchema } from "@/lib/validation/text"; import { Popover, @@ -124,8 +124,8 @@ function computeSortedCourses( if (classCourses) { classCourses.forEach(cc => { - const code = cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (!courses.some(c => c.key.toUpperCase().replace(/[\s\u00A0-]/g, "") === code)) { + const code = normalizeCourseCode(cc.course_code); + if (!courses.some(c => normalizeCourseCode(c.key) === code)) { courses.push({ key: cc.course_code, name: cc.course_name }); } }); @@ -471,11 +471,11 @@ export function AddAttendanceDialog({ return; } - let courseIdToSave = courseId.trim().toUpperCase().replace(/[\s\u00A0-]/g, ""); + let courseIdToSave = normalizeCourseCode(courseId.trim()); // eslint-disable-next-line security/detect-object-injection const selectedCourse = coursesData?.courses?.[courseId]; if (selectedCourse?.code) { - courseIdToSave = selectedCourse.code.trim().toUpperCase().replace(/[\s\u00A0-]/g, ""); + courseIdToSave = normalizeCourseCode(selectedCourse.code.trim()); } const finalRemarks = optionalReasonSchema.parse(remarks); @@ -881,12 +881,12 @@ export function AddAttendanceDialog({ className={cn( "custom-button transition-colors min-w-[120px]", statusType === "Present" && - "bg-green-600 hover:bg-green-700 text-white", + "bg-green-600! hover:bg-green-700! text-white! border-none!", statusType === "Absent" && - "bg-red-600 hover:bg-red-700 text-white", + "bg-red-600! hover:bg-red-700! text-white! border-none!", statusType === "Duty Leave" && - "bg-yellow-600 hover:bg-yellow-700 text-white", - "disabled:opacity-100 disabled:bg-muted disabled:text-muted-foreground disabled:border-border/70", + "bg-yellow-600! hover:bg-yellow-700! text-white! border-none!", + "disabled:opacity-100 disabled:!bg-muted disabled:!text-muted-foreground disabled:border-border/70", )} > {isSubmitting diff --git a/src/components/attendance/AddRecordTrigger.tsx b/src/components/attendance/AddRecordTrigger.tsx index 9ea3fdac..1c38c2a1 100644 --- a/src/components/attendance/AddRecordTrigger.tsx +++ b/src/components/attendance/AddRecordTrigger.tsx @@ -40,13 +40,13 @@ export function AddRecordTrigger({ user, onSuccess }: AddRecordTriggerProps) { const { data: selectedSemester } = useFetchSemester(); const { data: selectedYear } = useFetchAcademicYear(); - const { data: attendanceData, refetch: refetchAttendance } = useAttendanceReport( + const { data: attendanceData } = useAttendanceReport( selectedSemester ?? undefined, selectedYear ?? undefined, { enabled: isOpen }, ); - const { data: trackingData, refetch: refetchTracking } = useTrackingData(user, { + const { data: trackingData } = useTrackingData(user, { enabled: isOpen }); @@ -62,8 +62,7 @@ export function AddRecordTrigger({ user, onSuccess }: AddRecordTriggerProps) { queryClient.invalidateQueries({ queryKey: ["attendance-report"] }); queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }); queryClient.invalidateQueries({ queryKey: ["track_data"] }); - - await Promise.all([refetchAttendance(), refetchTracking()]); + await onSuccess(); }; diff --git a/src/components/attendance/__tests__/attendance-chart.test.tsx b/src/components/attendance/__tests__/attendance-chart.test.tsx index 63f5b194..c28b8a57 100644 --- a/src/components/attendance/__tests__/attendance-chart.test.tsx +++ b/src/components/attendance/__tests__/attendance-chart.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/providers/attendance-settings', () => ({ vi.mock('@/lib/utils', () => ({ generateSlotKey: (courseId: string, date: string, session: string) => `${courseId}-${date}-${session}`, + normalizeCourseCode: (code: string | undefined | null) => String(code ?? '').toUpperCase().replace(/[\s\u00A0-]/g, ''), })); vi.mock('lucide-react', () => ({ diff --git a/src/components/attendance/attendance-calendar.tsx b/src/components/attendance/attendance-calendar.tsx index 549025db..1ff7d138 100644 --- a/src/components/attendance/attendance-calendar.tsx +++ b/src/components/attendance/attendance-calendar.tsx @@ -56,7 +56,7 @@ import { useTrackingData } from "@/hooks/tracker/useTrackingData"; import { useTrackingCount } from "@/hooks/tracker/useTrackingCount"; import { isDutyLeaveConstraintError, getDutyLeaveErrorMessage } from "@/lib/error-handling"; import Link from "next/link"; -import { formatSessionName, generateSlotKey, normalizeSession, toRoman, normalizeToISODate, cn } from "@/lib/utils"; +import { formatSessionName, generateSlotKey, normalizeCourseCode, normalizeSession, toRoman, normalizeToISODate, cn } from "@/lib/utils"; import { isLegacyRemark } from "@/lib/logic/attendance-reconciliation"; import { useDisabledCourses } from "@/hooks/courses/useDisabledCourses"; import { useQueryClient } from "@tanstack/react-query"; @@ -155,31 +155,31 @@ function resolveCourseCode( classCourses?: ClassCourse[], attendanceData?: AttendanceReport ): string { - const normalizedInput = id.trim().toUpperCase().replace(/[\s\u00A0-]/g, ""); + const normalizedInput = normalizeCourseCode(id.trim()); // eslint-disable-next-line security/detect-object-injection const dictCourse = coursesData?.courses?.[id]; if (dictCourse) { - return (dictCourse.code || id).toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(dictCourse.code || id); } const course = Object.values(coursesData?.courses || {}).find(c => - String(c.id) === id || (c.code && c.code.toUpperCase().replace(/[\s\u00A0-]/g, "") === normalizedInput) + String(c.id) === id || (c.code && normalizeCourseCode(c.code) === normalizedInput) ); if (course?.code) { - return course.code.toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(course.code); } const custom = classCourses?.find(cc => - cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, "") === normalizedInput + normalizeCourseCode(cc.course_code) === normalizedInput ); if (custom) { - return custom.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(custom.course_code); } // eslint-disable-next-line security/detect-object-injection const altCourse = attendanceData?.courses?.[id]; - return (altCourse?.code ?? id).toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(altCourse?.code ?? id); } function resolveCourseName( @@ -198,9 +198,9 @@ function resolveCourseName( return course.name; } - const normalizedId = id.toUpperCase().replace(/[\s\u00A0-]/g, ""); + const normalizedId = normalizeCourseCode(id); const custom = classCourses?.find(cc => - cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, "") === normalizedId + normalizeCourseCode(cc.course_code) === normalizedId ); if (custom) { return custom.course_name || custom.course_code; @@ -330,16 +330,26 @@ function checkEventStatusForDate( let hasAbsent = false; let hasDutyLeave = false; let hasOtherLeave = false; + let hasPresent = false; if (dateEvents.length > 0) { dateEvents.forEach(ev => { const finalStatus = ev.status; - if (finalStatus === "Absent" && !isCourseDisabled(getCourseCodeById(ev.courseId))) { + const isDisabled = isCourseDisabled(getCourseCodeById(ev.courseId)); + if (isDisabled) { + if (finalStatus === "Present") { + hasPresent = true; + } + return; + } + if (finalStatus === "Absent") { hasAbsent = true; } else if (finalStatus === "Duty Leave") { hasDutyLeave = true; } else if (finalStatus.includes("Leave")) { hasOtherLeave = true; + } else if (finalStatus === "Present") { + hasPresent = true; } }); } else if (hasExtra) { @@ -351,21 +361,28 @@ function checkEventStatusForDate( if (Number(t.attendance) === 111) { label = "Absent"; } else if (Number(t.attendance) === 225) { label = "Duty Leave"; } - if (label === "Absent" && !isCourseDisabled(getCourseCodeById(String(t.course)))) { + const isDisabled = isCourseDisabled(getCourseCodeById(String(t.course))); + if (isDisabled) { + if (label === "Present") { + hasPresent = true; + } + return; + } + if (label === "Absent") { hasAbsent = true; } else if (label === "Duty Leave") { hasDutyLeave = true; + } else if (label === "Present") { + hasPresent = true; } }); } - if (dateEvents.length === 0 && !hasExtra) { - return null; - } if (hasAbsent) { return "absent"; } if (hasDutyLeave) { return "dutyLeave"; } if (hasOtherLeave) { return "otherLeave"; } - return "present"; + if (hasPresent) { return "present"; } + return null; } function mapOfficialEventsWithOverrides( @@ -587,7 +604,7 @@ function computeCellClassName( } if (isTodayLocal) { - baseClass += " ring-2 ring-offset-1 ring-offset-background ring-primary"; + baseClass += " ring-2 ring-offset-1 ring-offset-background ring-violet-600 dark:ring-violet-500"; } return baseClass; } @@ -1219,7 +1236,7 @@ export function AttendanceCalendar({
other leave
duty leave
present
-
today
+
today
diff --git a/src/components/attendance/attendance-chart.tsx b/src/components/attendance/attendance-chart.tsx index a9c4e9d8..bcdbe96e 100644 --- a/src/components/attendance/attendance-chart.tsx +++ b/src/components/attendance/attendance-chart.tsx @@ -12,7 +12,7 @@ import { } from "recharts"; import { AttendanceReport, TrackAttendance, Course } from "@/types"; import { useAttendanceSettings } from "@/providers/attendance-settings"; -import { generateSlotKey } from "@/lib/utils"; +import { generateSlotKey, normalizeCourseCode } from "@/lib/utils"; import { BarChart3 } from "lucide-react"; import { ATTENDANCE_STATUS } from "@/lib/logic/attendance-reconciliation"; @@ -256,7 +256,7 @@ function computeAttendanceChartData( // 1. Initialize Courses Object.entries(coursesData.courses).forEach(([key, course]) => { - const codeKey = (course.code || key).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const codeKey = normalizeCourseCode(course.code || key); idToCodeMap.set(key, codeKey); if (!courseAttendance.has(codeKey)) { diff --git a/src/components/attendance/course-card.tsx b/src/components/attendance/course-card.tsx index ce5eeb33..fd9fc1ff 100644 --- a/src/components/attendance/course-card.tsx +++ b/src/components/attendance/course-card.tsx @@ -35,6 +35,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; +import { normalizeCourseCode } from "@/lib/utils"; /** * Extended Course interface with additional attendance statistics. @@ -361,7 +362,7 @@ export function CourseCard({ onEditInstructor, supabaseUserId }: CourseCardProps) { - const courseCodeNormalized = (course.code || String(course.id)).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const courseCodeNormalized = normalizeCourseCode(course.code || String(course.id)); const { data: courseDetails, isLoading } = useCourseDetails( courseCodeNormalized, Number(course.id), diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index 5cb9bb0d..b43fd395 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -84,25 +84,6 @@ const PASSWORD_VALIDATION = { MAX_LENGTH: 128, // Prevent DOS attacks } as const; -const validatePassword = (password: string): string | null => { - // 1. Check empty - if (!password || password.trim().length === 0) { - return "Password is required"; - } - - // 2. Check minimum length - if (password.length < PASSWORD_VALIDATION.MIN_LENGTH) { - return `Password must be at least ${PASSWORD_VALIDATION.MIN_LENGTH} characters`; - } - - // 3. Check maximum length (prevent DOS) - if (password.length > PASSWORD_VALIDATION.MAX_LENGTH) { - return `Password must be less than ${PASSWORD_VALIDATION.MAX_LENGTH} characters`; - } - - return null; // Valid -}; - const detectLoginMethod = (value: string): LoginMethod => { const trimmedValue = value.trim(); @@ -124,6 +105,7 @@ function clearLocalUserStorage(): void { if (typeof window !== "undefined") { try { localStorage.clear(); + sessionStorage.removeItem("prefetchedSettings"); } catch { // Ignore @@ -353,19 +335,15 @@ export function LoginForm({ className, ...props }: LoginFormProps) { NProgress.start(); try { - const passwordError = validatePassword(formData.password); - if (passwordError) { - setError(passwordError); - setIsLoading(false); - return; - } + // Password is validated in real-time and the submit button is disabled + // while `passwordError` is present. No need to re-validate here. await ensureCsrfTokenPreloaded(); // 1. Login to Ezygo (public endpoint) const response = await axios.post("login", { username: formData.username.trim(), - password: formData.password.trim(), + password: formData.password, stay_logged_in: true }); @@ -412,7 +390,13 @@ export function LoginForm({ className, ...props }: LoginFormProps) { Sentry.captureException(error, { tags: { type: "network_error", location: "LoginForm/handleSubmit" } }); } setError(errorMsg); - announceToScreenReader(errorMsg); + // Avoid double-announcing password validation/auth errors to screen readers. + // Inline error text is linked via aria-describedby and will be announced + // by the browser/screen reader on focus change. Reserve the live region + // for other unexpected/global errors. + if (err.response?.status !== 401) { + announceToScreenReader(errorMsg); + } logger.error("Login failed:", err); } }; diff --git a/src/hooks/__tests__/use-sync-on-mount.test.tsx b/src/hooks/__tests__/use-sync-on-mount.test.tsx index fca38b5b..a4495dc1 100644 --- a/src/hooks/__tests__/use-sync-on-mount.test.tsx +++ b/src/hooks/__tests__/use-sync-on-mount.test.tsx @@ -51,7 +51,7 @@ describe("useSyncOnMount", () => { const { result } = renderHook(() => useSyncOnMount(defaultOptions)); await waitFor(() => { - expect(result.current.syncCompleted).toBe(true); + expect(result.current.syncSettled).toBe(true); }, { timeout: 10000 }); expect(axios.get).toHaveBeenCalledWith("/api/cron/sync", expect.any(Object)); @@ -61,7 +61,7 @@ describe("useSyncOnMount", () => { const { result } = renderHook(() => useSyncOnMount({ ...defaultOptions, enabled: false })); expect(result.current.isSyncing).toBe(false); - expect(result.current.syncCompleted).toBe(false); + expect(result.current.syncSettled).toBe(false); expect(axios.get).not.toHaveBeenCalled(); }); @@ -69,7 +69,7 @@ describe("useSyncOnMount", () => { const { result } = renderHook(() => useSyncOnMount({ ...defaultOptions, username: undefined })); expect(result.current.isSyncing).toBe(false); - expect(result.current.syncCompleted).toBe(true); + expect(result.current.syncSettled).toBe(true); expect(axios.get).not.toHaveBeenCalled(); }); @@ -107,7 +107,7 @@ describe("useSyncOnMount", () => { const { result } = renderHook(() => useSyncOnMount(defaultOptions)); await waitFor(() => { - expect(result.current.syncCompleted).toBe(true); + expect(result.current.syncSettled).toBe(true); }, { timeout: 10000 }); expect(logger.error).toHaveBeenCalled(); @@ -122,7 +122,7 @@ describe("useSyncOnMount", () => { const { result } = renderHook(() => useSyncOnMount(defaultOptions)); await waitFor(() => { - expect(result.current.syncCompleted).toBe(true); + expect(result.current.syncSettled).toBe(true); }, { timeout: 10000 }); expect(logger.error).toHaveBeenCalled(); @@ -167,8 +167,8 @@ describe("useSyncOnMount", () => { const { result: r2 } = renderHook(() => useSyncOnMount(defaultOptions)); await waitFor(() => { - expect(r1.current.syncCompleted).toBe(true); - expect(r2.current.syncCompleted).toBe(true); + expect(r1.current.syncSettled).toBe(true); + expect(r2.current.syncSettled).toBe(true); }); expect(axios.get).toHaveBeenCalledTimes(1); @@ -182,6 +182,10 @@ describe("useSyncOnMount", () => { const { unmount } = renderHook(() => useSyncOnMount(defaultOptions)); + await waitFor(() => { + expect(axios.get).toHaveBeenCalled(); + }); + unmount(); // Complete axios after unmount @@ -204,6 +208,10 @@ describe("useSyncOnMount", () => { const { unmount } = renderHook(() => useSyncOnMount(defaultOptions)); + await waitFor(() => { + expect(axios.get).toHaveBeenCalled(); + }); + unmount(); // Fail axios after unmount @@ -214,4 +222,37 @@ describe("useSyncOnMount", () => { // Verify no log error was recorded since the component had unmounted expect(logger.error).not.toHaveBeenCalled(); }); + + it("should defer sync until the load event if document is not fully loaded", async () => { + vi.mocked(axios.get).mockResolvedValue({ + status: 200, + data: { success: true }, + }); + + const originalReadyState = document.readyState; + Object.defineProperty(document, "readyState", { + get() { + return "loading"; + }, + configurable: true, + }); + + const { result } = renderHook(() => useSyncOnMount(defaultOptions)); + + expect(result.current.isSyncing).toBe(false); + expect(axios.get).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event("load")); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(1); + }, { timeout: 10000 }); + + Object.defineProperty(document, "readyState", { + get() { + return originalReadyState; + }, + configurable: true, + }); + }); }); diff --git a/src/hooks/courses/attendance.ts b/src/hooks/courses/attendance.ts index 7cbb57d8..11d476e7 100644 --- a/src/hooks/courses/attendance.ts +++ b/src/hooks/courses/attendance.ts @@ -6,6 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { logger } from "@/lib/logger"; import { useMemo } from "react"; import { AttendanceReport, CourseDetail } from "@/types"; +import { normalizeCourseCode } from "@/lib/utils"; import { retryOnce, retryTwice } from "@/lib/query-utils"; import { useFetchSemester, useFetchAcademicYear } from "../users/settings"; @@ -141,17 +142,25 @@ export const useCourseDetails = ( * Batch-prefetch all course summaries. * Accepts an array of objects containing code, id, and name to handle suppression and naming. */ -export const useAllCourseDetails = (courses: { code: string; id: number; name: string }[]) => { +export const useAllCourseDetails = ( + courses: { code: string; id: number; name: string }[], + semester?: string | null, + year?: string | null, + options?: { enabled?: boolean } +) => { const queryClient = useQueryClient(); - const { data: semester } = useFetchSemester(); - const { data: year } = useFetchAcademicYear(); + const { data: defaultSemester } = useFetchSemester(); + const { data: defaultYear } = useFetchAcademicYear(); + + const activeSemester = semester !== undefined ? semester : (defaultSemester ?? null); + const activeYear = year !== undefined ? year : (defaultYear ?? null); // Explicitly deduplicate courses by code to prevent redundant batching. // This ensures the queryKey remains stable and the API receives a clean list. const uniqueCourses = useMemo(() => { const seen = new Set(); return courses.filter((c: { code: string; id: number; name: string }) => { - const code = c.code.toUpperCase().replace(/[\s\u00A0-]/g, ""); + const code = normalizeCourseCode(c.code); if (!code || seen.has(code)) return false; seen.add(code); return true; @@ -159,12 +168,12 @@ export const useAllCourseDetails = (courses: { code: string; id: number; name: s }, [courses]); const sortedCodes = useMemo(() => - uniqueCourses.map(c => c.code.toUpperCase().replace(/[\s\u00A0-]/g, "")).sort(), + uniqueCourses.map(c => normalizeCourseCode(c.code)).sort(), [uniqueCourses] ); return useQuery>({ - queryKey: ["attendance-report-all", sortedCodes, semester ?? null, year ?? null], + queryKey: ["attendance-report-all", sortedCodes, activeSemester, activeYear], queryFn: async () => { const res = await axios.post("/api/attendance/summary-batch", { courses: uniqueCourses }, { baseURL: "" }); if (!res || !res.data) throw new Error("Failed to fetch batch course details"); @@ -185,7 +194,7 @@ export const useAllCourseDetails = (courses: { code: string; id: number; name: s const course = uniqueCourses.find((c: { code: string; id: number; name: string }) => c.code === code); if (course) { - const normalizedCode = code.toUpperCase().replace(/[\s\u00A0-]/g, ""); + const normalizedCode = normalizeCourseCode(code); queryClient.setQueryData(["attendance-report", normalizedCode, Number(course.id)], detail); } } @@ -193,7 +202,7 @@ export const useAllCourseDetails = (courses: { code: string; id: number; name: s return data; }, - enabled: uniqueCourses.length > 0, + enabled: options?.enabled !== false && uniqueCourses.length > 0, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000, refetchOnReconnect: true, diff --git a/src/hooks/courses/courses.ts b/src/hooks/courses/courses.ts index 7e05ed8f..2e12edb6 100644 --- a/src/hooks/courses/courses.ts +++ b/src/hooks/courses/courses.ts @@ -53,7 +53,8 @@ export const useFetchCourses = (options?: { return formattedData; }, - enabled: options?.enabled, + // Default to enabled unless caller explicitly disables. + enabled: options?.enabled !== false, initialData: options?.initialData ?? undefined, staleTime: 10 * 60 * 1000, gcTime: 15 * 60 * 1000, diff --git a/src/hooks/courses/exams.ts b/src/hooks/courses/exams.ts index c1b57bef..c3a82c73 100644 --- a/src/hooks/courses/exams.ts +++ b/src/hooks/courses/exams.ts @@ -140,7 +140,7 @@ export const useAllExamAnswers = (examIds: number[]) => { if (!res) throw new Error("Failed to fetch exam answers"); return res.data as ExamAnswer[]; }, - staleTime: 0, + staleTime: 10 * 60 * 1000, gcTime: 15 * 60 * 1000, retry: retryOnce, })), diff --git a/src/hooks/courses/useCourseLookup.ts b/src/hooks/courses/useCourseLookup.ts index 95e288fe..9a23c25d 100644 --- a/src/hooks/courses/useCourseLookup.ts +++ b/src/hooks/courses/useCourseLookup.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback } from "react"; +import { normalizeCourseCode } from "@/lib/utils"; import { Course } from "@/types"; interface UseCourseLookupProps { @@ -14,8 +15,7 @@ export function useCourseLookup({ classCourses, attendanceData, }: UseCourseLookupProps) { - const normalize = (s?: string) => - (s || "").trim().toUpperCase().replace(/[\s\u00A0-]/g, ""); + const normalize = (s?: string) => normalizeCourseCode(s); const getCourseCodeById = useCallback( (id: string): string => { diff --git a/src/hooks/use-dashboard-stats.ts b/src/hooks/use-dashboard-stats.ts index e8f1a8b6..765adfef 100644 --- a/src/hooks/use-dashboard-stats.ts +++ b/src/hooks/use-dashboard-stats.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { generateSlotKey } from "@/lib/utils"; +import { generateSlotKey, normalizeCourseCode } from "@/lib/utils"; import { isPositive, getOfficialSessionRaw, @@ -71,34 +71,27 @@ function createCodeResolver(coursesData: UseDashboardStatsOptions["coursesData"] /* eslint-disable-next-line security/detect-object-injection */ const target = courses[id]; const codeStr = target?.code || id; - return codeStr.toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(codeStr); } - return id.toUpperCase().replace(/[\s\u00A0-]/g, ""); + return normalizeCourseCode(id); }; } -function initFromCoursesData( - coursesData: UseDashboardStatsOptions["coursesData"], - resolveCode: (id: string) => string, - map: Map -) { - if (coursesData?.courses) { - for (const id of Object.keys(coursesData.courses)) { - const key = resolveCode(id); - if (!map.has(key)) map.set(key, createEmptyCourseStat()); - } +function addCourseStatIfMissing(map: Map, key: string) { + if (key && !map.has(key)) { + map.set(key, createEmptyCourseStat()); } } -function initFromClassCourses( - classCourses: UseDashboardStatsOptions["classCourses"], - map: Map +function populateClassCourseStats( + map: Map, + classCourses: UseDashboardStatsOptions["classCourses"] ) { - if (classCourses) { - for (const cc of classCourses) { - const key = cc.course_code ? cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, "") : ""; - if (key && !map.has(key)) map.set(key, createEmptyCourseStat()); - } + if (!classCourses) return; + + for (const cc of classCourses) { + const key = cc.course_code ? normalizeCourseCode(cc.course_code) : ""; + addCourseStatIfMissing(map, key); } } @@ -108,8 +101,18 @@ function initCourseStatsMap( resolveCode: (id: string) => string ): Map { const map = new Map(); - initFromCoursesData(coursesData, resolveCode, map); - initFromClassCourses(classCourses, map); + + // Populate from catalog courses (resolveCode to normalize) + if (coursesData?.courses) { + for (const id of Object.keys(coursesData.courses)) { + const key = resolveCode(id); + addCourseStatIfMissing(map, key); + } + } + + // Populate from class courses (already contain course_code strings) + populateClassCourseStats(map, classCourses); + return map; } @@ -311,7 +314,7 @@ export function useDashboardStats({ const resolveCode = createCodeResolver(coursesData); const normalizedDisabledCodes = new Set( - Array.from(disabledCodes).map((c) => c.toUpperCase().replace(/[\s\u00A0-]/g, "")) + Array.from(disabledCodes).map((c) => normalizeCourseCode(c)) ); const officialStats: OfficialAccumulator = { present: 0, absent: 0, dl: 0, total: 0 }; diff --git a/src/hooks/use-sync-on-mount.ts b/src/hooks/use-sync-on-mount.ts index eec2a6a6..df20553a 100644 --- a/src/hooks/use-sync-on-mount.ts +++ b/src/hooks/use-sync-on-mount.ts @@ -31,17 +31,43 @@ export interface UseSyncOnMountOptions { export interface UseSyncOnMountReturn { isSyncing: boolean; - syncCompleted: boolean; + // True when a sync attempt has settled (either success or failure) + syncSettled: boolean; + // True when the last sync attempt failed + syncFailed: boolean; } -// Module-level global state to persist sync status and promise across all component mounts/unmounts +// Module-level global state to persist sync status and promise across all component mounts/unmounts. +// Use a `globalThis`-backed singleton so Fast Refresh / HMR doesn't reset this state in development. const SYNC_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown -let lastSyncSuccessTime = 0; -let lastSyncUsername: string | null = null; -let activeSyncPromise: { +type ActiveSyncHandle = { username: string; promise: Promise<{ data: SyncResponse; status: number }>; -} | null = null; +} | null; + +interface SyncMountState { + lastSyncSuccessTime: number; + lastSyncUsername: string | null; + activeSyncPromise: ActiveSyncHandle; +} + +declare global { + var __ghostclass_useSyncOnMount_state_v1: SyncMountState | undefined; +} + +const _global = globalThis.__ghostclass_useSyncOnMount_state_v1 ??= { + lastSyncSuccessTime: 0, + lastSyncUsername: null, + activeSyncPromise: null, +}; + +// Local aliases for clarity; always read/write to `_global` to persist across HMR +const getLastSyncSuccessTime = () => _global.lastSyncSuccessTime; +const setLastSyncSuccessTime = (v: number) => { _global.lastSyncSuccessTime = v; }; +const getLastSyncUsername = () => _global.lastSyncUsername; +const setLastSyncUsername = (v: string | null) => { _global.lastSyncUsername = v; }; +const getActiveSyncPromise = () => _global.activeSyncPromise; +const setActiveSyncPromise = (v: ActiveSyncHandle) => { _global.activeSyncPromise = v; }; async function executeGlobalSync() { const res = await axios.get(`/api/cron/sync`, { @@ -96,7 +122,8 @@ export function useSyncOnMount({ }); const [isSyncing, setIsSyncing] = useState(false); - const [syncCompleted, setSyncCompleted] = useState(false); + const [syncSettled, setSyncSettled] = useState(false); + const [syncFailed, setSyncFailed] = useState(false); useEffect(() => { if (!enabled || !username) return; @@ -104,10 +131,10 @@ export function useSyncOnMount({ // Check if successfully synced within cooldown period const now = Date.now(); const isAlreadySynced = - lastSyncUsername === username && (now - lastSyncSuccessTime) < SYNC_COOLDOWN_MS; + getLastSyncUsername() === username && (now - getLastSyncSuccessTime()) < SYNC_COOLDOWN_MS; if (isAlreadySynced || syncFinishedRef.current) { - setSyncCompleted(true); + setSyncSettled(true); return; } @@ -116,8 +143,12 @@ export function useSyncOnMount({ const finalizeSync = (status: number, data: SyncResponse) => { if (isCleanedUp) return; syncFinishedRef.current = true; - lastSyncSuccessTime = Date.now(); - lastSyncUsername = username; + setLastSyncSuccessTime(Date.now()); + setLastSyncUsername(username); + + // mark settled and clear failure state on success + setSyncSettled(true); + setSyncFailed(false); if (status === 207) { captureSentryMessage(`Partial sync failure in ${sentryLocation}`, { @@ -140,15 +171,15 @@ export function useSyncOnMount({ const runSync = async () => { // Re-check inside async run to handle concurrent mounts firing in the same tick const innerNow = Date.now(); - if (lastSyncUsername === username && (innerNow - lastSyncSuccessTime) < SYNC_COOLDOWN_MS) { - setSyncCompleted(true); + if (getLastSyncUsername() === username && (innerNow - getLastSyncSuccessTime()) < SYNC_COOLDOWN_MS) { + setSyncSettled(true); return; } setIsSyncing(true); try { - if (!activeSyncPromise || activeSyncPromise.username !== username) { + if (!getActiveSyncPromise() || getActiveSyncPromise()!.username !== username) { logger.dev(`[${sentryLocation}] Initiating global EzyGo sync request`); // H-6: Use a shared object reference so the promise identity is captured // before any async continuation (catch/finally) fires. The previous pattern @@ -159,51 +190,109 @@ export function useSyncOnMount({ try { return await executeGlobalSync(); } catch (err) { - if (activeSyncPromise?.promise === syncHandle.promise) { - activeSyncPromise = null; + if (getActiveSyncPromise()?.promise === syncHandle.promise) { + setActiveSyncPromise(null); } throw err; } finally { - if (activeSyncPromise?.promise === syncHandle.promise) { - activeSyncPromise = null; + if (getActiveSyncPromise()?.promise === syncHandle.promise) { + setActiveSyncPromise(null); } } })(); syncHandle.promise = promise; - activeSyncPromise = { username, promise }; + setActiveSyncPromise({ username, promise }); } else { logger.dev(`[${sentryLocation}] Awaiting existing active EzyGo sync request`); } - const result = await activeSyncPromise.promise; + const result = await getActiveSyncPromise()!.promise; if (isCleanedUp) return; finalizeSync(result.status, result.data); } catch (error: unknown) { if (isCleanedUp) return; + // mark failure for callers that need to know + setSyncFailed(true); handleSyncError(error, sentryLocation, sentryTag, userId, setIsSyncing); } finally { if (!isCleanedUp) { setIsSyncing(false); - setSyncCompleted(true); + // Mark the sync attempt as settled even on failure (intentional fail-open) + setSyncSettled(true); } } }; - runSync(); + let deferHandle: number | null = null; + let idleHandle: number | null = null; + + const scheduleSync = () => { + if (typeof window !== "undefined") { + const win = window as unknown as Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (id: number) => void; + }; + if (win.requestIdleCallback) { + idleHandle = win.requestIdleCallback(() => { + if (!isCleanedUp) runSync(); + }, { timeout: 1000 }); + } else { + deferHandle = setTimeout(() => { + if (!isCleanedUp) runSync(); + }, 200) as unknown as number; + } + } + }; + + if (typeof document !== "undefined") { + if (document.readyState === "complete") { + scheduleSync(); + } else { + const handleLoad = () => { + scheduleSync(); + }; + const win = window as unknown as Window & { + cancelIdleCallback?: (id: number) => void; + }; + win.addEventListener("load", handleLoad); + return () => { + isCleanedUp = true; + win.removeEventListener("load", handleLoad); + if (idleHandle !== null && win.cancelIdleCallback) { + win.cancelIdleCallback(idleHandle); + } + if (deferHandle !== null) { + clearTimeout(deferHandle); + } + }; + } + } + return () => { isCleanedUp = true; + if (typeof window !== "undefined") { + const win = window as unknown as Window & { + cancelIdleCallback?: (id: number) => void; + }; + if (idleHandle !== null && win.cancelIdleCallback) { + win.cancelIdleCallback(idleHandle); + } + if (deferHandle !== null) { + clearTimeout(deferHandle); + } + } }; }, [enabled, username, userId, sentryLocation, sentryTag]); - const isComplete = syncCompleted || (!username && !!userId); + const effectiveSettled = syncSettled || (!username && !!userId); - return { isSyncing, syncCompleted: isComplete }; + return { isSyncing, syncSettled: effectiveSettled, syncFailed }; } /** TEST ONLY: Reset module-level singleton state. */ export function _resetModuleState() { - lastSyncSuccessTime = 0; - lastSyncUsername = null; - activeSyncPromise = null; + setLastSyncSuccessTime(0); + setLastSyncUsername(null); + setActiveSyncPromise(null); } diff --git a/src/hooks/users/__tests__/profile.test.tsx b/src/hooks/users/__tests__/profile.test.tsx index bf6b73f9..99263258 100644 --- a/src/hooks/users/__tests__/profile.test.tsx +++ b/src/hooks/users/__tests__/profile.test.tsx @@ -56,11 +56,11 @@ describe("profile hooks", () => { expect(result.current.data).toEqual(mockProfile); expect(axiosInstance.get).toHaveBeenCalledWith( "/api/profile", - expect.objectContaining({ params: { sync: "true", force: "true" } }), + expect.objectContaining({ params: undefined }), ); }); - it("should send sync and force params when requested", async () => { + it("should send sync and force params when explicitly requested (uses separate query key)", async () => { const mockProfile = { id: "1", first_name: "Test" }; (axiosInstance.get as any).mockResolvedValueOnce({ data: mockProfile }); diff --git a/src/hooks/users/profile.ts b/src/hooks/users/profile.ts index c027d099..465b3c99 100644 --- a/src/hooks/users/profile.ts +++ b/src/hooks/users/profile.ts @@ -11,15 +11,28 @@ interface UpdateProfileData { last_name?: string | null; gender?: string | null; birth_date?: string | null; + class_id?: string | null; } export const useProfile = (options?: { initialData?: UserProfile; sync?: boolean; force?: boolean }) => { + // Force callers (e.g. dashboard) use a separate query key so they are never + // deduplicated with the navbar's no-force fetch. When both share the same key, + // React Query merges observers into one in-flight request and the navbar's + // queryFn (no sync/force params) wins — the EzyGo sync never runs. + // staleTime:0 on the force key means it is always considered stale and always + // re-fetches on mount, regardless of what the shared ["profile"] cache holds. + const queryKey: unknown[] = options?.force ? ["profile", "synced"] : ["profile"]; + return useQuery({ - queryKey: ["profile"], + queryKey, queryFn: async () => { const params: Record = {}; - params.sync = "true"; - params.force = "true"; + // Only request a full EzyGo sync when the caller explicitly opts in. + // Without force=true the server uses its debounce window (5 min) and + // returns the locally-cached DB row immediately — the avatar URL is + // already stored there, so it loads without waiting for an EzyGo round-trip. + if (options?.sync) params.sync = "true"; + if (options?.force) params.force = "true"; const res = await axiosInstance.get("/api/profile", { params: Object.keys(params).length > 0 ? params : undefined, @@ -28,8 +41,9 @@ export const useProfile = (options?: { initialData?: UserProfile; sync?: boolean return res.data; }, initialData: options?.initialData, - // Cache for 5 mins to avoid spamming the sync logic - staleTime: 1000 * 60 * 5, + // Force variant: staleTime 0 so it always re-fetches on mount. + // Normal variant: 5-min cache to avoid hammering the sync logic. + staleTime: options?.force ? 0 : 1000 * 60 * 5, gcTime: 30 * 60 * 1000, // Never retry 4xx errors (rate limit, auth, bad request) — retrying a 429 // would waste a rate-limit slot. Retries once for 5xx / network errors. @@ -45,7 +59,7 @@ export function useUpdateProfile() { const res = await axiosInstance.patch("/api/profile", data, { baseURL: "", // Override baseURL to hit top-level /api/profile }); - return res.data; + return res.data as UpdateProfileData; }, // Optimistic Update: Update UI instantly // 1. SNAPSHOT & OPTIMISTIC UPDATE @@ -80,9 +94,20 @@ export function useUpdateProfile() { }, // 3. FINAL VERIFICATION - onSettled: () => { + onSettled: (data) => { // Always refetch from server at the end to ensure 100% consistency queryClient.invalidateQueries({ queryKey: ["profile"] }); + + // If the returned payload included a class assignment change, invalidate + // class-specific caches so the UI doesn't show stale courses. + try { + if (data && (data as UpdateProfileData).class_id !== undefined) { + queryClient.invalidateQueries({ queryKey: ["class_courses"] }); + queryClient.invalidateQueries({ queryKey: ["courses"] }); + } + } catch { + // Swallow - this is best-effort cache maintenance + } }, }); } \ No newline at end of file diff --git a/src/hooks/users/settings.ts b/src/hooks/users/settings.ts index bf0674ce..9ef46891 100644 --- a/src/hooks/users/settings.ts +++ b/src/hooks/users/settings.ts @@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@/lib/logger"; import { makeRetryFn } from "@/lib/query-utils"; +import { UserProfile } from "@/types"; type SemesterData = { default_semester: "even" | "odd"; @@ -24,17 +25,90 @@ export type UserSettings = { // Shared retry logic for settings queries — skip all 4xx, retry twice for 5xx/network const settingsRetryFn = makeRetryFn(2); +function extractClassField( + uClass: { sem?: string; year?: string } | null | undefined, + field: "sem" | "year" +): T | null { + if (!uClass) return null; + if (field === "sem" && uClass.sem) return uClass.sem as T; + if (field === "year" && uClass.year) return uClass.year as T; + return null; +} + +async function resolveSettingFromProfileQuery( + queryClient: ReturnType, + field: "sem" | "year", + fallbackApiCall: () => Promise +): Promise { + const cachedProfile = queryClient.getQueryData(["profile"]) || queryClient.getQueryData(["profile", "synced"]); + const userClass = cachedProfile?.class as { sem?: string; year?: string } | null | undefined; + const cachedValue = extractClassField(userClass, field); + if (cachedValue) { + return cachedValue; + } + + const syncedState = queryClient.getQueryState(["profile", "synced"]); + const normalState = queryClient.getQueryState(["profile"]); + const isSyncedPending = syncedState && syncedState.status === "pending"; + const isNormalPending = normalState && normalState.status === "pending"; + + if (isSyncedPending || isNormalPending) { + const targetKey = isSyncedPending ? "synced" : "normal"; + return new Promise((resolve) => { + let isSettled = false; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + const key = event.query.queryKey; + const matchesKey = targetKey === "synced" + ? (key[0] === "profile" && key[1] === "synced") + : (key[0] === "profile" && key.length === 1); + + if (matchesKey) { + if (event.query.state.status === "success") { + unsubscribe(); + isSettled = true; + const profile = event.query.state.data as UserProfile | null; + const uClass = profile?.class as { sem?: string; year?: string } | null | undefined; + const value = extractClassField(uClass, field); + if (value) { + resolve(value); + } else { + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + } else if (event.query.state.status === "error") { + unsubscribe(); + isSettled = true; + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + } + }); + // Safety timeout to prevent hanging if the profile sync fails/hangs + setTimeout(() => { + if (!isSettled) { + unsubscribe(); + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + }, 30000); + }); + } + + return fallbackApiCall(); +} + export const useFetchSemester = () => { + const queryClient = useQueryClient(); + return useQuery<"even" | "odd" | null>({ queryKey: ["semester"], queryFn: async () => { - try { - const res = await axios.get("/user/setting/default_semester"); - return res.data; - } catch (error: unknown) { - if (isAxiosError(error) && error.response?.status === 404) return null; - throw error; - } + return resolveSettingFromProfileQuery(queryClient, "sem", async () => { + try { + const res = await axios.get("/user/setting/default_semester"); + return res.data; + } catch (error: unknown) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } + }); }, retry: settingsRetryFn, staleTime: 1000 * 60 * 5, @@ -43,16 +117,20 @@ export const useFetchSemester = () => { }; export const useFetchAcademicYear = () => { + const queryClient = useQueryClient(); + return useQuery({ queryKey: ["academic-year"], queryFn: async () => { - try { - const res = await axios.get("/user/setting/default_academic_year"); - return res.data; - } catch (error: unknown) { - if (isAxiosError(error) && error.response?.status === 404) return null; - throw error; - } + return resolveSettingFromProfileQuery(queryClient, "year", async () => { + try { + const res = await axios.get("/user/setting/default_academic_year"); + return res.data; + } catch (error: unknown) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } + }); }, retry: settingsRetryFn, staleTime: 1000 * 60 * 5, diff --git a/src/lib/__tests__/circuit-breaker.test.ts b/src/lib/__tests__/circuit-breaker.test.ts index 028e5bea..03c59567 100644 --- a/src/lib/__tests__/circuit-breaker.test.ts +++ b/src/lib/__tests__/circuit-breaker.test.ts @@ -23,7 +23,7 @@ describe('CircuitBreaker', () => { const fn = vi.fn().mockResolvedValue('success'); const result = await ezygoCircuitBreaker.execute(fn); expect(result).toBe('success'); - expect(ezygoCircuitBreaker.getStatus().state).toBe('CLOSED'); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('CLOSED'); }); it('increments failures and opens after threshold', async () => { @@ -31,15 +31,15 @@ describe('CircuitBreaker', () => { // 1st failure await expect(ezygoCircuitBreaker.execute(fn)).rejects.toThrow('fail'); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(1); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(1); // 2nd failure await expect(ezygoCircuitBreaker.execute(fn)).rejects.toThrow('fail'); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(2); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(2); // 3rd failure - should open await expect(ezygoCircuitBreaker.execute(fn)).rejects.toThrow('fail'); - expect(ezygoCircuitBreaker.getStatus().state).toBe('OPEN'); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('OPEN'); expect(Sentry.captureMessage).toHaveBeenCalled(); }); @@ -50,7 +50,7 @@ describe('CircuitBreaker', () => { await expect(ezygoCircuitBreaker.execute(fn)).rejects.toThrow(); } - expect(ezygoCircuitBreaker.getStatus().state).toBe('OPEN'); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('OPEN'); // Next request should fail fast const fn2 = vi.fn(); @@ -71,7 +71,7 @@ describe('CircuitBreaker', () => { const fn2 = vi.fn().mockResolvedValue('half-open-success'); const result = await ezygoCircuitBreaker.execute(fn2); expect(result).toBe('half-open-success'); - expect(ezygoCircuitBreaker.getStatus().state).toBe('HALF_OPEN'); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('HALF_OPEN'); }); it('closes after enough successes in HALF_OPEN', async () => { @@ -84,13 +84,13 @@ describe('CircuitBreaker', () => { // 1st success in HALF_OPEN await ezygoCircuitBreaker.execute(() => Promise.resolve('ok')); - expect(ezygoCircuitBreaker.getStatus().state).toBe('HALF_OPEN'); - expect(ezygoCircuitBreaker.getStatus().successCount).toBe(1); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('HALF_OPEN'); + expect((await ezygoCircuitBreaker.getStatus()).successCount).toBe(1); // 2nd success in HALF_OPEN - should close await ezygoCircuitBreaker.execute(() => Promise.resolve('ok')); - expect(ezygoCircuitBreaker.getStatus().state).toBe('CLOSED'); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(0); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('CLOSED'); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(0); }); it('reopens if a request fails in HALF_OPEN', async () => { @@ -104,7 +104,7 @@ describe('CircuitBreaker', () => { // Failure in HALF_OPEN const fnFail = vi.fn().mockRejectedValue(new Error('still failing')); await expect(ezygoCircuitBreaker.execute(fnFail)).rejects.toThrow('still failing'); - expect(ezygoCircuitBreaker.getStatus().state).toBe('OPEN'); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('OPEN'); }); it('limits concurrent requests in HALF_OPEN', async () => { @@ -135,8 +135,8 @@ describe('CircuitBreaker', () => { const fn = vi.fn().mockRejectedValue(new NonBreakerError('404 Not Found')); await expect(ezygoCircuitBreaker.execute(fn)).rejects.toThrow(NonBreakerError); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(0); - expect(ezygoCircuitBreaker.getStatus().state).toBe('CLOSED'); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(0); + expect((await ezygoCircuitBreaker.getStatus()).state).toBe('CLOSED'); }); it('NonBreakerError counts as success in HALF_OPEN', async () => { @@ -150,7 +150,7 @@ describe('CircuitBreaker', () => { // NonBreakerError in HALF_OPEN const fn404 = vi.fn().mockRejectedValue(new NonBreakerError('404')); await expect(ezygoCircuitBreaker.execute(fn404)).rejects.toThrow(NonBreakerError); - expect(ezygoCircuitBreaker.getStatus().successCount).toBe(1); + expect((await ezygoCircuitBreaker.getStatus()).successCount).toBe(1); }); it('UpstreamServerError carries status and body', () => { @@ -162,19 +162,19 @@ describe('CircuitBreaker', () => { it('resets failures on success in CLOSED state', async () => { const fnFail = vi.fn().mockRejectedValue(new Error('fail')); await expect(ezygoCircuitBreaker.execute(fnFail)).rejects.toThrow(); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(1); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(1); await ezygoCircuitBreaker.execute(() => Promise.resolve('ok')); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(0); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(0); }); it('handles success when failures are already 0', async () => { await ezygoCircuitBreaker.execute(() => Promise.resolve('ok')); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(0); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(0); }); it('handles non-Error failures', async () => { await expect(ezygoCircuitBreaker.execute(() => Promise.reject('string error'))).rejects.toBe('string error'); - expect(ezygoCircuitBreaker.getStatus().failures).toBe(1); + expect((await ezygoCircuitBreaker.getStatus()).failures).toBe(1); }); }); diff --git a/src/lib/__tests__/redis.test.ts b/src/lib/__tests__/redis.test.ts index 60eb2826..b920c9fc 100644 --- a/src/lib/__tests__/redis.test.ts +++ b/src/lib/__tests__/redis.test.ts @@ -3,24 +3,27 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; describe('redis.ts', () => { beforeEach(() => { vi.resetModules(); + vi.unstubAllEnvs(); + process.env.UPSTASH_REDIS_REST_URL = ''; + process.env.UPSTASH_REDIS_REST_TOKEN = ''; + process.env.VITEST = 'false'; + vi.stubEnv('NODE_ENV', 'test'); }); it('throws if URL is missing', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', ''); const { getRedis } = await import('../redis'); expect(() => getRedis()).toThrow('UPSTASH_REDIS_REST_URL is not defined'); }); it('throws if Token is missing', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', ''); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; const { getRedis } = await import('../redis'); expect(() => getRedis()).toThrow('UPSTASH_REDIS_REST_TOKEN is not defined'); }); it('initializes and caches instance', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token'); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; vi.stubEnv('NODE_ENV', 'production'); const { getRedis } = await import('../redis'); @@ -30,8 +33,8 @@ describe('redis.ts', () => { }); it('logs in development', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token'); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; vi.stubEnv('NODE_ENV', 'development'); const { logger } = await import('../logger'); @@ -43,16 +46,16 @@ describe('redis.ts', () => { }); it('proxy works for functions', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token'); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; const { redis } = await import('../redis'); expect(typeof redis.set).toBe('function'); }); it('proxy works for properties', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token'); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; const { redis } = await import('../redis'); // @ts-expect-error - accessing internal prop for test @@ -60,8 +63,8 @@ describe('redis.ts', () => { }); it('proxy caches client', async () => { - vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.url'); - vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token'); + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.url'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; const { redis } = await import('../redis'); // First call initializes diff --git a/src/lib/circuit-breaker.ts b/src/lib/circuit-breaker.ts index 2b9d6c7a..b389427d 100644 --- a/src/lib/circuit-breaker.ts +++ b/src/lib/circuit-breaker.ts @@ -305,7 +305,13 @@ class CircuitBreaker { await this.clearHalfOpenInFlight(); } } else { - // Reset failure count on success + // Optimize: avoid a Redis read on every successful request by checking + // the local mirror first. Successful requests are the common case when + // the circuit is CLOSED and localFailures will typically be 0. + if (this.localFailures === 0 && this.localLastFailTime === 0) return; + + // If local mirror indicates failures > 0, read authoritative value + // from Redis and reset it if needed. const failures = await this.getFailures(); if (failures > 0) { logger.dev('[Circuit Breaker] Resetting failure count', { @@ -366,22 +372,31 @@ class CircuitBreaker { /** * Get current circuit breaker status (for monitoring). * - * Raw lastFailTime (Unix ms) is omitted — it would leak information about - * when EzyGo last failed if this object is ever surfaced via a health endpoint. - * timeUntilReset is the only operationally useful derivative and carries no - * additional timing information beyond what the client already knows. + * Status is read from Redis first so monitoring sees the authoritative + * breaker state even after a process restart. Raw lastFailTime (Unix ms) is + * omitted — it would leak information about when EzyGo last failed if this + * object is ever surfaced via a health endpoint. timeUntilReset is the only + * operationally useful derivative and carries no additional timing + * information beyond what the client already knows. */ - getStatus() { + async getStatus() { + const [state, failures, lastFailTime, successCount] = await Promise.all([ + this.getState(), + this.getFailures(), + this.getLastFailTime(), + this.getRedisValue('circuit:success_count', val => parseInt(val, 10), this.localSuccessCount), + ]); + const timeUntilReset = - this.localState === 'OPEN' - ? Math.max(0, Math.ceil((this.resetTimeout - (Date.now() - this.localLastFailTime)) / 1000)) + state === 'OPEN' + ? Math.max(0, Math.ceil((this.resetTimeout - (Date.now() - lastFailTime)) / 1000)) : 0; return { - state: this.localState, - failures: this.localFailures, + state, + failures, timeUntilReset, - successCount: this.localSuccessCount, - isOpen: this.localState === 'OPEN', + successCount, + isOpen: state === 'OPEN', }; } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index fa50efbb..2a6b9837 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -19,41 +19,72 @@ const isDevelopment = process.env.NODE_ENV === 'development'; // Detect test environment via the VITEST env var (set automatically by Vitest runner). -// NOTE: vitest.config.ts sets NODE_ENV='development' (not 'test') so we use VITEST instead. const isTest = process.env.VITEST === "true"; +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v)); + } catch { + return '"[unserializable]"'; + } +} + +function buildStructuredPayload(level: string, args: unknown[]) { + const timestamp = new Date().toISOString(); + + let meta: unknown = null; + const message = typeof args[0] === "string" ? String(args[0]) : ""; + + if (args.length > 0) { + if (typeof args[0] === "string") { + if (args.length > 1) meta = args.slice(1); + } else if (args.length === 1) { + // single non-string argument — treat as meta + meta = args[0]; + } else { + meta = args; + } + } + + const payload: Record = { + ts: timestamp, + level, + }; + if (message) payload.msg = message; + if (meta !== null) payload.meta = meta; + return safeStringify(payload); +} + export const logger = { - /** - * Development-only logging - * Suppressed in production to keep logs clean - */ dev: (...args: unknown[]) => { if (isDevelopment) { console.log(...args); } }, - /** - * Warning messages - always logged (suppressed in test to avoid noisy CI output) - * Use for non-critical issues that should be investigated - */ warn: (...args: unknown[]) => { - if (!isTest) console.warn(...args); + if (isTest) return; + if (process.env.NODE_ENV === 'production') { + console.warn(buildStructuredPayload('warn', args)); + } else { + console.warn(...args); + } }, - /** - * Error messages - always logged (suppressed in test to avoid noisy CI output) - * Use for errors that need immediate attention - */ error: (...args: unknown[]) => { - if (!isTest) console.error(...args); + if (isTest) return; + if (process.env.NODE_ENV === 'production') { + console.error(buildStructuredPayload('error', args)); + } else { + console.error(...args); + } }, - /** - * Info messages - always logged for important production events - * Uses console.info (semantically distinct from logger.dev/console.log) - */ info: (...args: unknown[]) => { - console.info(...args); + if (process.env.NODE_ENV === 'production') { + console.info(buildStructuredPayload('info', args)); + } else { + console.info(...args); + } }, }; diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 04c850f0..9df71d05 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -7,8 +7,37 @@ import { logger } from '@/lib/logger'; * @throws {Error} If required environment variables are missing */ function createRedisClient(): Redis { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; + // During unit tests (Vitest) we avoid making network calls to Upstash. + // Provide a lightweight in-memory mock that implements the small subset + // of Redis operations used by the application. This keeps tests hermetic + // and avoids requiring real UPSTASH credentials in CI or local environments. + if (process.env.VITEST === 'true') { + const store = new Map(); + const mock: Partial = { + get: async (key: string): Promise => { + return store.has(key) ? (store.get(key) as unknown as TData) : null; + }, + set: async (key: string, value: TData): Promise<"OK" | TData | null> => { + store.set(key, String(value)); + return 'OK'; + }, + incr: async (key: string): Promise => { + const cur = parseInt(store.get(key) ?? '0', 10) || 0; + const next = cur + 1; + store.set(key, String(next)); + return next; + }, + decr: async (key: string): Promise => { + const cur = parseInt(store.get(key) ?? '0', 10) || 0; + const next = Math.max(0, cur - 1); + store.set(key, String(next)); + return next; + }, + }; + return mock as unknown as Redis; + } + const url = process.env["UPSTASH_REDIS_REST_URL"]; + const token = process.env["UPSTASH_REDIS_REST_TOKEN"]; if (!url) { throw new Error( diff --git a/src/lib/security/auth-cookie.ts b/src/lib/security/auth-cookie.ts index 7d468382..002c84c6 100644 --- a/src/lib/security/auth-cookie.ts +++ b/src/lib/security/auth-cookie.ts @@ -5,6 +5,7 @@ import { createClient } from "@/lib/supabase/server"; import { getAdminClient } from "@/lib/supabase/admin"; import { decrypt } from "@/lib/crypto"; import { logger } from "@/lib/logger"; +import { isCookieSecure } from "@/lib/security/cookie-utils"; import { redact } from "@/lib/utils.server"; import { redis } from "@/lib/redis"; @@ -13,7 +14,7 @@ export async function setAuthCookie(token: string, days = 31) { const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000); (await cookies()).set("ezygo_access_token", token, { httpOnly: true, - secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', + secure: isCookieSecure(), sameSite: "lax", path: "/", expires, diff --git a/src/lib/security/cookie-utils.ts b/src/lib/security/cookie-utils.ts new file mode 100644 index 00000000..b9b0b9a7 --- /dev/null +++ b/src/lib/security/cookie-utils.ts @@ -0,0 +1,5 @@ +export function isCookieSecure(): boolean { + // Use NODE_ENV=production as the authoritative indicator of production + // deployment; fall back to HTTPS env var for legacy environments. + return process.env.NODE_ENV === "production" || process.env.HTTPS === "true"; +} diff --git a/src/lib/security/csrf.ts b/src/lib/security/csrf.ts index 6f141b8c..90244de1 100644 --- a/src/lib/security/csrf.ts +++ b/src/lib/security/csrf.ts @@ -57,6 +57,7 @@ import { cookies } from "next/headers"; import crypto from "crypto"; import { logger } from "@/lib/logger"; +import { isCookieSecure } from "@/lib/security/cookie-utils"; import { redis } from "@/lib/redis"; // Configuration @@ -134,7 +135,7 @@ export async function setCsrfCookie(token: string): Promise { name: CSRF_COOKIE_NAME, value: token, httpOnly: true, // Server-side validation token (not accessible to JavaScript) - secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', + secure: isCookieSecure(), sameSite: "strict", maxAge: CSRF_COOKIE_MAX_AGE, path: "/", diff --git a/src/lib/user/profile-bundle.ts b/src/lib/user/profile-bundle.ts index 1c7b405b..a938856a 100644 --- a/src/lib/user/profile-bundle.ts +++ b/src/lib/user/profile-bundle.ts @@ -24,7 +24,7 @@ export async function getProfileBundle( // 1. Fetch user and settings in parallel const [userRes, settingsRes] = await Promise.all([ - preFetchedUser ? Promise.resolve({ data: preFetchedUser }) : supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", authId).maybeSingle(), + preFetchedUser ? Promise.resolve({ data: preFetchedUser }) : supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", authId).maybeSingle(), supabaseAdmin.from("user_settings").select("*").eq("user_id", authId).maybeSingle() ]); diff --git a/src/lib/user/sync.ts b/src/lib/user/sync.ts index 6dd86157..53eb4e48 100644 --- a/src/lib/user/sync.ts +++ b/src/lib/user/sync.ts @@ -1,6 +1,7 @@ import { logger } from "@/lib/logger"; import { getAdminClient } from "@/lib/supabase/admin"; import { egressFetch, redact } from "@/lib/utils.server"; +import { normalizeCourseCode } from "@/lib/utils"; import { decrypt, encrypt } from "@/lib/crypto"; import * as Sentry from "@sentry/nextjs"; import { safeResponseJson } from "@/lib/json"; @@ -86,7 +87,7 @@ async function processCoursesData(coursesRes: Response): Promise<{ coursesMap: R entries.push([String(c.id), c]); } if (c.code) { - const normalized = String(c.code).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const normalized = normalizeCourseCode(String(c.code)); entries.push([normalized, c]); } } @@ -534,7 +535,7 @@ async function populateCourseCatalogAndMigrateTrackers( .filter(c => c.id !== undefined && c.id !== null && c.code) .map(c => ({ ezygo_id: String(c.id), - university_code: String(c.code).toUpperCase().replace(/[\s\u00A0-]/g, ""), + university_code: normalizeCourseCode(String(c.code)), course_name: c.name, last_seen_at: new Date().toISOString(), })); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c1184a6d..0aa98d30 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -230,6 +230,15 @@ export const formatCourseCode = (code: string): string => { return code.toUpperCase().replace(/[\s\u00A0-]/g, ""); }; +/** + * Alias for `formatCourseCode` kept for semantic clarity. + * Use this when normalizing course codes across the codebase. + */ +export const normalizeCourseCode = (code: string | undefined | null): string => { + if (!code) return ""; + return formatCourseCode(String(code)); +}; + function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/src/lib/validate-env.ts b/src/lib/validate-env.ts index 0a618037..b11f8896 100644 --- a/src/lib/validate-env.ts +++ b/src/lib/validate-env.ts @@ -415,7 +415,13 @@ function validateGaAndLimits(errors: string[], warnings: string[]) { } checkRateLimitVar(process.env.NEXT_PUBLIC_ATTENDANCE_TARGET_MIN, 1, 100, "❌ NEXT_PUBLIC_ATTENDANCE_TARGET_MIN must be a number between 1 and 100 (default: 75)", errors); - checkRateLimitVar(process.env.AUTH_LOCK_TTL, 15, 60, "❌ AUTH_LOCK_TTL must be a number between 15 and 60 seconds (default: 20)", errors); + // Explicitly reject '0' because code-level logic treats non-positive values as a fallback default (20s). + // This avoids the silent mismatch where AUTH_LOCK_TTL=0 would be interpreted as default 20 at runtime. + if (process.env.AUTH_LOCK_TTL && process.env.AUTH_LOCK_TTL.trim() === "0") { + errors.push("❌ AUTH_LOCK_TTL must be between 15 and 60 seconds — setting 0 is not allowed"); + } else { + checkRateLimitVar(process.env.AUTH_LOCK_TTL, 15, 60, "❌ AUTH_LOCK_TTL must be a number between 15 and 60 seconds (default: 20)", errors); + } const sentryReplayRate = process.env.NEXT_PUBLIC_SENTRY_REPLAY_RATE; if (sentryReplayRate) { diff --git a/src/lib/validation/text.ts b/src/lib/validation/text.ts index c3dfb2e3..27b257bf 100644 --- a/src/lib/validation/text.ts +++ b/src/lib/validation/text.ts @@ -113,7 +113,9 @@ export const reasonTextSchema = makeTextSchema({ export const emailSchema = z.string().trim().email("Invalid email format").max(255, "Email too long").transform((value) => value.toLowerCase()); -export const courseCodeSchema = z.string().trim().min(1, "Course code is required").max(32, "Course code too long").transform((value) => value.toUpperCase().replace(/[\s\u00A0-]/g, "")); +import { normalizeCourseCode } from "@/lib/utils"; + +export const courseCodeSchema = z.string().trim().min(1, "Course code is required").max(32, "Course code too long").transform((value) => normalizeCourseCode(value)); export const academicYearSchema = z.string().trim().regex(/^\d{4}-(\d{4}|\d{2})$/, "Invalid academic year format (expected YYYY-YYYY or YYYY-YY)"); diff --git a/src/providers/user-settings.tsx b/src/providers/user-settings.tsx index a3c4c8bc..aea76db4 100644 --- a/src/providers/user-settings.tsx +++ b/src/providers/user-settings.tsx @@ -105,9 +105,12 @@ const loadPrefetchedSettings = (currentUserId: string | null): UserSettings | nu return { bunk_calculator_enabled: storedBunk === "true", target_percentage: storedTarget ? parseInt(storedTarget, 10) : DEFAULT_TARGET_PERCENTAGE, - disabled_courses: (storedDisabled && typeof storedDisabled === "string") - ? JSON.parse(storedDisabled) - : {}, + disabled_courses: (() => { + if (!storedDisabled || typeof storedDisabled !== "string") return {}; + + const disabledCourses = disabledCoursesSchema.safeParse(JSON.parse(storedDisabled)); + return disabledCourses.success ? disabledCourses.data : {}; + })(), }; } catch (err) { logger.error("Failed to load prefetched settings:", err); diff --git a/src/types/react-intrinsic.d.ts b/src/types/react-intrinsic.d.ts new file mode 100644 index 00000000..01213ce0 --- /dev/null +++ b/src/types/react-intrinsic.d.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars */ +import "react"; + +declare module "react" { + // Augment React's HTMLAttributes so `inert` can be used in JSX without casts. + interface HTMLAttributes { + inert?: boolean; + } +} diff --git a/vitest.setup.ts b/vitest.setup.ts index 7cf04930..af0cfa0a 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -262,6 +262,8 @@ vi.mock('@/hooks/use-sync-on-mount', () => ({ useSyncOnMount: vi.fn(() => ({ isSyncing: false, syncCompleted: true, + syncSettled: true, + syncFailed: false, })), })); diff --git a/workers/package-lock.json b/workers/package-lock.json index c1729930..8c0ccf94 100644 --- a/workers/package-lock.json +++ b/workers/package-lock.json @@ -8,7 +8,7 @@ "name": "wrangler-install", "version": "1.0.0", "dependencies": { - "wrangler": "^4.94.0" + "wrangler": "^4.95.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -36,9 +36,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260521.1.tgz", - "integrity": "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260526.1.tgz", + "integrity": "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==", "cpu": [ "x64" ], @@ -52,9 +52,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260521.1.tgz", - "integrity": "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260526.1.tgz", + "integrity": "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==", "cpu": [ "arm64" ], @@ -68,9 +68,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260521.1.tgz", - "integrity": "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260526.1.tgz", + "integrity": "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260521.1.tgz", - "integrity": "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260526.1.tgz", + "integrity": "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==", "cpu": [ "arm64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260521.1.tgz", - "integrity": "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260526.1.tgz", + "integrity": "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==", "cpu": [ "x64" ], @@ -1237,15 +1237,15 @@ } }, "node_modules/miniflare": { - "version": "4.20260521.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260521.0.tgz", - "integrity": "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==", + "version": "4.20260526.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260526.0.tgz", + "integrity": "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA==", "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", - "workerd": "1.20260521.1", + "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, @@ -1418,9 +1418,9 @@ } }, "node_modules/workerd": { - "version": "1.20260521.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260521.1.tgz", - "integrity": "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ==", + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260526.1.tgz", + "integrity": "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -1430,28 +1430,28 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260521.1", - "@cloudflare/workerd-darwin-arm64": "1.20260521.1", - "@cloudflare/workerd-linux-64": "1.20260521.1", - "@cloudflare/workerd-linux-arm64": "1.20260521.1", - "@cloudflare/workerd-windows-64": "1.20260521.1" + "@cloudflare/workerd-darwin-64": "1.20260526.1", + "@cloudflare/workerd-darwin-arm64": "1.20260526.1", + "@cloudflare/workerd-linux-64": "1.20260526.1", + "@cloudflare/workerd-linux-arm64": "1.20260526.1", + "@cloudflare/workerd-windows-64": "1.20260526.1" } }, "node_modules/wrangler": { - "version": "4.94.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.94.0.tgz", - "integrity": "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==", + "version": "4.95.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.95.0.tgz", + "integrity": "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg==", "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260521.0", + "miniflare": "4.20260526.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", - "workerd": "1.20260521.1" + "workerd": "1.20260526.1" }, "bin": { "wrangler": "bin/wrangler.js", @@ -1464,7 +1464,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260521.1" + "@cloudflare/workers-types": "^4.20260526.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/workers/package.json b/workers/package.json index 98c1acdd..38b344e4 100644 --- a/workers/package.json +++ b/workers/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "wrangler": "^4.94.0" + "wrangler": "^4.95.0" }, "overrides": { "undici": "8.1.0",