diff --git a/README.md b/README.md index 66d59ed..5f18913 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Playwright](https://img.shields.io/badge/Playwright-1.49-45ba4b?style=flat-square&logo=playwright)](https://playwright.dev/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -**Roll Book** is an attendance tracker and planning engine designed with a strict principle: **never assume a lecture happened just because the timetable says it should have.** +**Roll Book** is an intelligent attendance tracker, forecasting lab, and academic trajectory management suite designed with a strict principle: **never assume a lecture happened just because the timetable says it should have.** --- @@ -15,13 +15,13 @@ 1. **Authentic Actual Mode**: Attendance statistics are derived *strictly* from verified portal snapshots and confirmed manual logs. Unlogged dates remain unlogged—never silently assumed or blended into statistics. 2. **Predictive Planning Mode**: Driven by your recurring weekly timetable, allowing you to simulate *Plan to Attend* and *Plan to Skip* choices into the future and visualize your projected percentage trajectory (rendered with dashed indicators and trend curves) without corrupting your verified history. -3. **Playful Geometric Design System (Light + Dark Mode)**: Neo-brutalist sticker styling with chunky 2px borders, hard offset shadows, bouncy Framer Motion micro-interactions, Outfit display font, and instant theme switching via `next-themes`. -4. **AI Attendance Advisor (Google Gemini Free Tier)**: Built-in intelligent chatbot powered by `@google/genai` (Gemini 2.5 Flash) with deterministic function/tool calling directly into SQLite data—never hallucinating numbers. -5. **1-Click Batch Actions**: Fast bulk marking for today's lectures, past backlog audit items, and future planning days with a single tap. -6. **Holiday & Exam Declaration Engine**: Built-in calendar registry for official university recesses and examination days with automatic exclusion from past unlogged audits and future class simulations. -7. **Deterministic Math Engine**: Computes exact skippable buffers (how many classes you can afford to miss) or mandatory recovery streaks (how many consecutive attendances you need to restore compliance). -8. **1-Click Section Onboarding**: Automatically imports all subjects and weekly timetable schedules for 22 MIT Bengaluru CSE Stream sections (`C01`–`C22`). -9. **MAHE SLCM 2.0 Bridge**: A Playwright-based Salesforce response interceptor that synchronizes verified attendance counts directly from MAHE's portal without hardcoding volatile tokens. +3. **1-Click Live SLCM Attendance Sync**: Native local scraper bridge (`scraper/agent.js` on `http://localhost:4747`). Click "Sync Now" in the UI and Roll Book talks to the local agent, executes the Playwright session, and reconciles courses directly without manual file picking. +4. **Playful Geometric Design System (Dual Light/Dark Mode)**: High-contrast neo-brutalist sticker styling with Electric Teal primary accents (`#0D9488` light / `#2DD4BF` dark), chunky 2px borders, hard offset shadows, bouncy Framer Motion micro-interactions, Outfit display font, and instant theme switching via `next-themes`. +5. **AI Attendance Advisor (Google Gemini Free Tier)**: Built-in intelligent chatbot powered by `@google/genai` (Gemini 2.5 Flash) with deterministic function/tool calling directly into SQLite data—never hallucinating numbers. +6. **1-Click Batch Actions**: Fast bulk marking for today's lectures, past backlog audit items, and future planning days with a single tap. +7. **Holiday & Exam Declaration Engine**: Built-in calendar registry for official university recesses and examination days with automatic exclusion from past unlogged audits and future class simulations. +8. **Deterministic Math Engine**: Computes exact skippable buffers (how many classes you can afford to miss) or mandatory recovery streaks (how many consecutive attendances you need to restore compliance). +9. **1-Click Section Onboarding**: Automatically imports all subjects and weekly timetable schedules for 22 MIT Bengaluru CSE Stream sections (`C01`–`C22`). 10. **Local Single-Account Auth**: Protected by Web Crypto HMAC-SHA256 session middleware with out-of-the-box local credentials. 11. **Local-First & Portable**: Powered by SQLite via Prisma with zero external cloud dependencies. Full CSV exports and JSON backup/restore built in. @@ -81,12 +81,20 @@ DATABASE_URL="file:./dev.db" APP_USERNAME=admin APP_PASSWORD=rollbook APP_SESSION_SECRET=change-this-to-a-long-random-string +GEMINI_API_KEY=your-gemini-api-key-here ``` -### 4. Start Development Server +### 4. Start Development Server & Local Sync Agent +In terminal 1 (Next.js App): ```bash npm run dev ``` + +In terminal 2 (Local SLCM Sync Bridge): +```bash +npm run scraper:agent +``` + Open **[http://localhost:3000](http://localhost:3000)** in your browser and sign in with **`admin`** / **`rollbook`**. --- @@ -94,38 +102,34 @@ Open **[http://localhost:3000](http://localhost:3000)** in your browser and sign ## 🧭 First-Time Setup & Onboarding If your database is empty, the **Onboarding Wizard** will appear automatically: -1. Tap **"Get Started"**. +1. Tap **"Choose My Section"**. 2. Select your section (e.g. **`C06`**). -3. Confirm—your 10 subjects and weekly schedule will be populated instantly. +3. Confirm—your subjects and weekly schedule will be populated instantly. +4. Tap **"Sync Now (1-Click)"** to immediately pull your verified attendance baseline from SLCM. --- -## 🤖 SLCM Scraper Workflow +## 🤖 1-Click SLCM Scraper Workflow -The scraper lives independently in `/scraper`: +The scraper runs as a lightweight local HTTP daemon (`scraper/agent.js`) on port `4747`: ```bash cd scraper npm install npx playwright install chromium +node agent.js ``` -### 1. One-Time Login (or when session expires) -```bash -node login.js -``` -A browser window will open. Complete your MAHE Microsoft SSO and MFA login. Once redirected to `/s/attendance`, your authenticated session state is saved to `auth.json`. - -### 2. Pull Live Attendance Numbers -```bash -node sync.js -``` -Headlessly captures the `getCOPList` Apex action, unwraps the nested Salesforce envelope, discards telemetry noise, and writes `sync-output.json`. +### 1. 1-Click Sync (Recommended) +Inside Roll Book, navigate to **Settings** $\rightarrow$ **SLCM Sync Bridge** and click **Sync Now (1-Click)**. +- If it's your first time or your session expired, a visible browser will open automatically for Microsoft SSO / MFA. +- Once authenticated, it headlessly intercepts the `getCOPList` Apex payload, reconciles courses with your database, and presents the diff modal. -### 3. Import into Roll Book -- Open Roll Book $\rightarrow$ **Settings & Sync** $\rightarrow$ **SLCM Sync Bridge**. -- Click **Load Synced Data (JSON)** and select `scraper/sync-output.json`. -- Review the reconciliation diff and choose your course merge mappings before confirming. +### 2. Manual CLI Fallback +If you prefer running manual commands: +- Login: `cd scraper && node login.js` +- Sync: `node sync.js` +- Upload: In Settings, click *"Advanced: upload sync-output.json manually"*. --- @@ -138,39 +142,44 @@ RollBook/ │ ├── FORMULAS.md # Mathematical models and boundary proofs │ └── SLCM_SCRAPER_GUIDE.md # Salesforce Aura interception guide ├── prisma/ -│ ├── schema.prisma # Course, TimetableSlot, AttendanceRecord models +│ ├── schema.prisma # Course, TimetableSlot, AttendanceRecord, Holiday models │ └── seed.js # Sample semester seed data ├── scraper/ -│ ├── login.js # Interactive SSO/MFA Playwright login -│ ├── sync.js # Headless getCOPList response listener +│ ├── agent.js # Local HTTP bridge server (http://localhost:4747) +│ ├── login.js # Modular SSO/MFA Playwright login +│ ├── sync.js # Modular headless getCOPList response listener │ ├── package.json # Scraper dependencies │ └── README.md # Scraper quickstart ├── src/ │ ├── app/ │ │ ├── api/ -│ │ │ ├── attendance/ # Attendance record CRUD +│ │ │ ├── attendance/ # Attendance record CRUD & bulk logger │ │ │ ├── auth/ # Login, logout, session check +│ │ │ ├── chat/ # Google Gemini AI Attendance Advisor │ │ │ ├── courses/ # Subject management │ │ │ ├── export/ # CSV and JSON backup/restore +│ │ │ ├── holidays/ # Holiday and exam-day registry │ │ │ ├── reset/ # Full database atomic reset │ │ │ ├── sections/ # Official department section schedules │ │ │ ├── sync/ # SLCM reconciliation diff engine │ │ │ └── timetable/ # Weekly slot management -│ │ ├── login/ # Dark-theme login page -│ │ ├── globals.css # Design tokens & theme -│ │ ├── layout.tsx # Shell +│ │ ├── login/ # High-contrast login page +│ │ ├── globals.css # Playful Geometric design tokens & CSS variables +│ │ ├── layout.tsx # Shell with Outfit and Plus Jakarta Sans fonts │ │ └── page.tsx # App orchestrator │ ├── components/ -│ │ ├── Navigation.tsx # Header, stats badge, and sign-out -│ │ ├── HomeView.tsx # Dashboard with Today's Quick Logger +│ │ ├── Navigation.tsx # Header, stats badge, theme switcher, and sign-out +│ │ ├── HomeView.tsx # Dashboard with Today's schedule and past audit │ │ ├── SubjectsView.tsx # Cards, sparklines & audit trail │ │ ├── CalendarView.tsx # Month matrix & live planning simulator -│ │ ├── SettingsView.tsx # Timetable editor, sync bridge, reset -│ │ ├── OnboardingWizard.tsx # 1-click section setup flow +│ │ ├── SettingsView.tsx # Timetable editor, 1-click sync bridge, reset +│ │ ├── ChatWidget.tsx # Google Gemini AI Attendance Advisor widget +│ │ ├── OnboardingWizard.tsx # 1-click section setup flow with SLCM sync prompt │ │ ├── CourseModal.tsx │ │ ├── SlotModal.tsx │ │ ├── SectionImportModal.tsx # Department timetable selector -│ │ └── SyncModal.tsx # SLCM diff & merge selector +│ │ ├── SyncModal.tsx # SLCM diff & merge selector +│ │ └── ThemeProvider.tsx # next-themes client provider │ ├── lib/ │ │ ├── attendance.ts # Deterministic math engine │ │ ├── auth.ts # HMAC-SHA256 Web Crypto session token helpers diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 61c50af..e219178 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,7 +148,12 @@ When launching with an empty database, Roll Book automatically presents an onboa ### 9. AI Attendance Advisor (`/api/chat`) - `POST /api/chat` — Google Gemini (`@google/genai`) AI endpoint with native function/tool calling against Prisma database (`get_attendance_summary`, `get_course_detail`, `get_upcoming_classes`, `get_unlogged_sessions`). -### 10. Data Portability (`/api/export`) +### 10. SLCM Scraper Agent Bridge (`http://localhost:4747`) +- `GET /status` — Checks if `auth.json` is present and valid. +- `POST /sync` — Headlessly captures portal attendance via Playwright (prompts SSO browser if unauthenticated) and directly returns `{ courses, syncedAt, capturedVia }`. +- `POST /login` — Launches interactive browser for Microsoft SSO & MFA authentication. + +### 11. Data Portability (`/api/export`) - `GET /api/export?format=csv` — Downloads complete attendance audit trail as spreadsheet CSV. - `GET /api/export?format=json` — Generates a full database backup snapshot. - `POST /api/export` — Restores database state from a backup JSON file. diff --git a/package.json b/package.json index 6d8c885..3a91d12 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "db:push": "prisma db push", "db:seed": "node prisma/seed.js", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "scraper:agent": "node scraper/agent.js" }, "dependencies": { "@google/genai": "^2.23.0", diff --git a/scraper/agent.js b/scraper/agent.js new file mode 100644 index 0000000..80bc73a --- /dev/null +++ b/scraper/agent.js @@ -0,0 +1,104 @@ +// agent.js — Local scraper bridge server on http://localhost:4747 +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { runLogin } = require('./login'); +const { runScrape } = require('./sync'); + +const PORT = 4747; +const AUTH_PATH = path.resolve(__dirname, 'auth.json'); + +function setCorsHeaders(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +} + +const server = http.createServer(async (req, res) => { + setCorsHeaders(res); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url, `http://${req.headers.host || 'localhost:4747'}`); + + // Endpoint: GET /status + if (req.method === 'GET' && url.pathname === '/status') { + const hasSession = fs.existsSync(AUTH_PATH) && fs.statSync(AUTH_PATH).size > 10; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + status: 'online', + hasSession, + port: PORT, + }) + ); + return; + } + + // Endpoint: POST /login + if (req.method === 'POST' && url.pathname === '/login') { + try { + console.log('[Agent] Triggering interactive login...'); + const result = await runLogin(AUTH_PATH); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, message: 'Login complete', result })); + } catch (err) { + console.error('[Agent] Login failed:', err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: err.message })); + } + return; + } + + // Endpoint: POST /sync + if (req.method === 'POST' && url.pathname === '/sync') { + try { + console.log('[Agent] 1-Click Sync requested.'); + + // If no session exists, run interactive login first + if (!fs.existsSync(AUTH_PATH)) { + console.log('[Agent] auth.json missing. Opening login browser...'); + await runLogin(AUTH_PATH); + } + + let payload; + try { + payload = await runScrape(AUTH_PATH); + } catch (scrapeErr) { + if (scrapeErr.message === 'SESSION_EXPIRED' || scrapeErr.message === 'DID_NOT_CAPTURE_COURSES') { + console.log('[Agent] Session expired or stale. Re-launching login...'); + await runLogin(AUTH_PATH); + payload = await runScrape(AUTH_PATH); + } else { + throw scrapeErr; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + } catch (err) { + console.error('[Agent] Sync error:', err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: err.message || 'Scrape failed. Please check portal credentials or rerun login.', + }) + ); + } + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); +}); + +server.listen(PORT, () => { + console.log(`\n========================================`); + console.log(`🚀 SLCM Scraper Agent listening on http://localhost:${PORT}`); + console.log(`Ready for 1-Click Sync requests from Roll Book.`); + console.log(`========================================\n`); +}); diff --git a/scraper/login.js b/scraper/login.js index 517875b..70de9be 100644 --- a/scraper/login.js +++ b/scraper/login.js @@ -1,31 +1,41 @@ -// login.js — run this once, and again whenever your session expires. -// Opens a real, visible browser so you can log in through Microsoft SSO -// (including MFA) yourself. Once you land back on the attendance page, -// it saves the authenticated session to auth.json for sync.js to reuse. -// -// Run: node login.js - +// login.js — interactive Microsoft SSO + MFA login module const { chromium } = require('playwright'); +const path = require('path'); -(async () => { +async function runLogin(authPath = path.resolve(__dirname, 'auth.json')) { + console.log('[Login] Launching interactive browser for MAHE Microsoft SSO...'); const browser = await chromium.launch({ headless: false }); const context = await browser.newContext(); const page = await context.newPage(); - await page.goto('https://maheslcmtech.manipal.edu/s/attendance'); - - console.log('A browser window has opened.'); - console.log('Log in with your MAHE Microsoft account, complete MFA if prompted.'); - console.log('Waiting for you to land back on the attendance page (up to 5 minutes)...'); - - // Wait until SSO finishes redirecting us back to the attendance page - await page.waitForURL('**/s/attendance**', { timeout: 5 * 60 * 1000 }); - - // Give the single-page app a moment to finish its own internal loading - await page.waitForTimeout(5000); - - await context.storageState({ path: 'auth.json' }); - console.log('Session saved to auth.json. You can now run: node sync.js'); - - await browser.close(); -})(); + try { + await page.goto('https://maheslcmtech.manipal.edu/s/attendance', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + + console.log('[Login] A browser window has opened.'); + console.log('[Login] Log in with your MAHE Microsoft account, complete MFA if prompted.'); + console.log('[Login] Waiting for redirect back to attendance page (up to 5 minutes)...'); + + await page.waitForURL('**/s/attendance**', { timeout: 5 * 60 * 1000 }); + await page.waitForTimeout(5000); + + await context.storageState({ path: authPath }); + console.log(`[Login] Session saved to ${authPath}`); + return { success: true, authPath }; + } finally { + await browser.close(); + } +} + +if (require.main === module) { + runLogin() + .then(() => console.log('Login complete. You can now run: node sync.js')) + .catch((err) => { + console.error('Login error:', err); + process.exit(1); + }); +} + +module.exports = { runLogin }; diff --git a/scraper/sync.js b/scraper/sync.js index 305856f..9a1c62d 100644 --- a/scraper/sync.js +++ b/scraper/sync.js @@ -1,7 +1,4 @@ // sync.js — robust SLCM attendance synchronizer. -// Specifically targets the getCOPList / commonLWCApexMethods Apex action request -// and retains the largest non-empty semester attendance capture. - const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); @@ -81,12 +78,9 @@ function hasValidCourseRecords(list) { }); } -(async () => { - const authPath = path.resolve(__dirname, 'auth.json'); +async function runScrape(authPath = path.resolve(__dirname, 'auth.json')) { if (!fs.existsSync(authPath)) { - console.error('\n[Error] auth.json not found in scraper directory.'); - console.error('Please run: node login.js first to log in and save your session.\n'); - process.exit(1); + throw new Error('auth.json not found. Please log in first.'); } const browser = await chromium.launch({ @@ -94,188 +88,175 @@ function hasValidCourseRecords(list) { args: ['--disable-blink-features=AutomationControlled'], }); - const context = await browser.newContext({ - storageState: authPath, - userAgent: - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - viewport: { width: 1280, height: 800 }, - }); + try { + const context = await browser.newContext({ + storageState: authPath, + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 }, + }); - const page = await context.newPage(); - let validCourseList = null; - let rawCapturedData = null; - let capturedActionDescriptor = ''; + const page = await context.newPage(); + let validCourseList = null; + let rawCapturedData = null; + let capturedActionDescriptor = ''; - console.log('Connecting to MAHE SLCM portal...'); + console.log('[Scraper] Connecting to MAHE SLCM portal...'); - // Set up response listener targeting the specific getCOPList / commonLWCApexMethods POST request - page.on('response', async (response) => { - const url = response.url(); - if (!url.includes('/s/sfsites/aura') || response.request().method() !== 'POST') { - return; - } + page.on('response', async (response) => { + const url = response.url(); + if (!url.includes('/s/sfsites/aura') || response.request().method() !== 'POST') { + return; + } - try { - const postData = response.request().postData() || ''; - const decodedPost = decodeURIComponent(postData); - - // Check if this outgoing request is specifically for getCOPList / attendance - const isTargetRequest = - decodedPost.includes('getCOPList') || - decodedPost.includes('commonLWCApexMethods') || - postData.includes('getCOPList'); - - const json = await response.json(); - if (!json || !json.actions || !Array.isArray(json.actions)) return; - - for (const action of json.actions) { - if (action.state !== 'SUCCESS' || !action.returnValue) continue; - - const descriptor = action.descriptor || ''; - - // Discard any O11y / telemetry logging beacons - if ( - descriptor.includes('instrumentation') || - descriptor.includes('telemetry') || - descriptor.includes('logMetrics') - ) { - continue; - } + try { + const postData = response.request().postData() || ''; + const decodedPost = decodeURIComponent(postData); + + const isTargetRequest = + decodedPost.includes('getCOPList') || + decodedPost.includes('commonLWCApexMethods') || + postData.includes('getCOPList'); + + const json = await response.json(); + if (!json || !json.actions || !Array.isArray(json.actions)) return; - const candidateList = extractAttendanceList(action.returnValue); - - // Accept if request matches getCOPList OR candidate list has valid course objects - if (isTargetRequest || hasValidCourseRecords(candidateList)) { - // CRITICAL: Only overwrite if candidateList is non-empty and larger than current capture. - // This prevents empty sibling semester tabs (0 items) from overwriting real data (10 items). - if (candidateList.length > 0 && candidateList.length > (validCourseList?.length || 0)) { - validCourseList = candidateList; - rawCapturedData = action.returnValue; - capturedActionDescriptor = descriptor || 'getCOPList'; - console.log( - `✓ Intercepted Apex attendance call [${capturedActionDescriptor}] with ${candidateList.length} items.` - ); - } else if (candidateList.length === 0) { - console.log( - ` (ignored empty getCOPList response — keeping previous capture of ${validCourseList?.length || 0} items)` - ); + for (const action of json.actions) { + if (action.state !== 'SUCCESS' || !action.returnValue) continue; + + const descriptor = action.descriptor || ''; + if ( + descriptor.includes('instrumentation') || + descriptor.includes('telemetry') || + descriptor.includes('logMetrics') + ) { + continue; } - } else { - if (descriptor && !descriptor.includes('O11y')) { - console.log(`[Aura Event] Received: ${descriptor}`); + + const candidateList = extractAttendanceList(action.returnValue); + + if (isTargetRequest || hasValidCourseRecords(candidateList)) { + if (candidateList.length > 0 && candidateList.length > (validCourseList?.length || 0)) { + validCourseList = candidateList; + rawCapturedData = action.returnValue; + capturedActionDescriptor = descriptor || 'getCOPList'; + console.log( + `✓ Intercepted Apex attendance call [${capturedActionDescriptor}] with ${candidateList.length} items.` + ); + } } } + } catch (e) { + // Ignore non-JSON } - } catch (e) { - // Ignore non-JSON responses - } - }); - - try { - await page.goto('https://maheslcmtech.manipal.edu/s/attendance', { - waitUntil: 'domcontentloaded', - timeout: 45000, }); - } catch (err) { - console.warn('Navigation note:', err.message); - } - // Allow single-page application components time to dispatch all semester Aura calls - console.log('Awaiting portal component telemetry...'); - const maxWaitMs = 15000; - const pollInterval = 500; - let elapsed = 0; - - while (elapsed < maxWaitMs) { - await page.waitForTimeout(pollInterval); - elapsed += pollInterval; - - // Check if redirected to Microsoft SSO / login page - const currentUrl = page.url(); - if ( - currentUrl.includes('login.microsoftonline.com') || - currentUrl.includes('/login') || - currentUrl.includes('/s/login') - ) { - console.error('\n[Session Expired] The portal redirected to the login page.'); - console.error('Resolution: Run "node login.js" to authenticate, then retry "node sync.js".\n'); - await browser.close(); - process.exit(1); + try { + await page.goto('https://maheslcmtech.manipal.edu/s/attendance', { + waitUntil: 'domcontentloaded', + timeout: 45000, + }); + } catch (err) { + console.warn('[Scraper] Navigation notice:', err.message); } - // Once we have captured valid records and waited at least 4 seconds for sibling calls to settle - if (validCourseList && validCourseList.length > 0 && elapsed >= 4000) { - break; - } - } + const maxWaitMs = 15000; + const pollInterval = 500; + let elapsed = 0; + + while (elapsed < maxWaitMs) { + await page.waitForTimeout(pollInterval); + elapsed += pollInterval; + + const currentUrl = page.url(); + if ( + currentUrl.includes('login.microsoftonline.com') || + currentUrl.includes('/login') || + currentUrl.includes('/s/login') + ) { + throw new Error('SESSION_EXPIRED'); + } - if (!validCourseList || validCourseList.length === 0) { - const finalUrl = page.url(); - console.error('\n[Error] Did not capture attendance course list from SLCM.'); - console.error(`Current Page URL: ${finalUrl}`); - console.error('Please rerun: node login.js to refresh your authentication session.\n'); - await browser.close(); - process.exit(1); - } + if (validCourseList && validCourseList.length > 0 && elapsed >= 4000) { + break; + } + } - // Format into standard Roll Book schema - const courses = validCourseList.map((c) => { - const present = Number( - c.Total_number_of_classes_attended__c ?? - c.classesAttended ?? - c.present ?? - c.Attended_Classes__c ?? - c.AttendedClasses ?? - 0 - ); + if (!validCourseList || validCourseList.length === 0) { + throw new Error('DID_NOT_CAPTURE_COURSES'); + } - const total = Number( - c.Total_Classes__c ?? - c.totalClasses ?? - c.TotalClasses ?? - c.total ?? - (present + (c.absent || 0)) - ); + const courses = validCourseList.map((c) => { + const present = Number( + c.Total_number_of_classes_attended__c ?? + c.classesAttended ?? + c.present ?? + c.Attended_Classes__c ?? + c.AttendedClasses ?? + 0 + ); + + const total = Number( + c.Total_Classes__c ?? + c.totalClasses ?? + c.TotalClasses ?? + c.total ?? + (present + (c.absent || 0)) + ); + + const absent = Math.max(0, total - present); + + const name = + c.CourseOffering?.LearningCourse?.Name ?? + c.CourseOffering?.Name ?? + c.Course_Title__c ?? + c.courseName ?? + c.Name ?? + c.name ?? + 'Unknown Course'; + + const code = + c.Course_Code__c ?? + c.CourseOffering?.Course_Code__c ?? + c.courseCode ?? + c.code ?? + ''; + + return { + name, + code, + present, + absent, + }; + }); - const absent = Math.max(0, total - present); - - const name = - c.CourseOffering?.LearningCourse?.Name ?? - c.CourseOffering?.Name ?? - c.Course_Title__c ?? - c.courseName ?? - c.Name ?? - c.name ?? - 'Unknown Course'; - - const code = - c.Course_Code__c ?? - c.CourseOffering?.Course_Code__c ?? - c.courseCode ?? - c.code ?? - ''; - - return { - name, - code, - present, - absent, + const payload = { + courses, + syncedAt: new Date().toISOString(), + capturedVia: capturedActionDescriptor, }; - }); - const outputPath = path.resolve(__dirname, 'sync-output.json'); - const payload = { - courses, - syncedAt: new Date().toISOString(), - capturedVia: capturedActionDescriptor, - }; + const outputPath = path.resolve(__dirname, 'sync-output.json'); + fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2)); + console.log(`[Scraper] Saved payload to: ${outputPath}`); - fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2)); + return payload; + } finally { + await browser.close(); + } +} - console.log(`\n========================================`); - console.log(`✓ Synchronized ${courses.length} courses successfully!`); - console.log(`Saved output to: ${outputPath}`); - console.log(`========================================\n`); +if (require.main === module) { + runScrape() + .then((res) => { + console.log(`\n========================================`); + console.log(`✓ Synchronized ${res.courses.length} courses successfully!`); + console.log(`========================================\n`); + }) + .catch((err) => { + console.error('Scrape error:', err.message); + process.exit(1); + }); +} - await browser.close(); -})(); +module.exports = { runScrape, extractAttendanceList, hasValidCourseRecords }; diff --git a/src/app/globals.css b/src/app/globals.css index 15ee551..67811f7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,7 +3,7 @@ @tailwind utilities; @layer base { - /* LIGHT MODE: Warm Cream Paper Palette */ + /* LIGHT MODE: Warm Cream Paper Palette with Electric Teal Primary Accent */ :root { --background: #FFFDF5; --foreground: #1E293B; @@ -11,26 +11,28 @@ --card-foreground: #1E293B; --popover: #FFFFFF; --popover-foreground: #1E293B; - --primary: #8B5CF6; /* Vibrant Violet */ + --primary: #0D9488; /* Electric Teal-600 */ --primary-foreground: #FFFFFF; --secondary: #F472B6; /* Bubblegum Pink */ --secondary-foreground: #1E293B; --muted: #F1EEDA; --muted-foreground: #64748B; - --accent: #FBBF24; /* Sunny Amber */ - --accent-foreground: #1E293B; + --accent: #0D9488; /* Primary Teal Accent */ + --accent-foreground: #FFFFFF; + --tertiary: #FBBF24; /* Sunny Amber */ + --quaternary: #34D399; /* Mint Green */ --destructive: #F43F5E; --destructive-foreground: #FFFFFF; --border: #1E293B; --input: #1E293B; - --ring: #8B5CF6; + --ring: #0D9488; --radius: 1rem; --shadow-color: #1E293B; --shadow-offset: 4px; --dot-color: rgba(30, 41, 59, 0.08); } - /* DARK MODE: Warm Charcoal Paper with Vibrant Violet/Pink Accents */ + /* DARK MODE: Warm Charcoal Paper with Vibrant Luminous Teal Accent */ .dark { --background: #15131A; --foreground: #F5F3FF; @@ -38,19 +40,21 @@ --card-foreground: #F5F3FF; --popover: #1E1B26; --popover-foreground: #F5F3FF; - --primary: #8B5CF6; - --primary-foreground: #FFFFFF; - --secondary: #F472B6; + --primary: #2DD4BF; /* Luminous Teal-400 */ + --primary-foreground: #15131A; + --secondary: #F472B6; /* Bubblegum Pink */ --secondary-foreground: #FFFFFF; --muted: #282433; --muted-foreground: #A19DAC; - --accent: #FBBF24; + --accent: #2DD4BF; /* Primary Teal Accent */ --accent-foreground: #15131A; + --tertiary: #FBBF24; /* Sunny Amber */ + --quaternary: #34D399; /* Mint Green */ --destructive: #FB7185; --destructive-foreground: #FFFFFF; --border: #3A3545; --input: #3A3545; - --ring: #A78BFA; + --ring: #2DD4BF; --radius: 1rem; --shadow-color: #0D0B12; --shadow-offset: 4px; @@ -80,7 +84,7 @@ .bg-confetti-shapes { background-color: var(--background); background-image: - radial-gradient(circle at 15% 20%, rgba(139, 92, 246, 0.08) 0%, transparent 25%), + radial-gradient(circle at 15% 20%, rgba(45, 212, 191, 0.08) 0%, transparent 25%), radial-gradient(circle at 85% 15%, rgba(244, 114, 182, 0.08) 0%, transparent 25%), radial-gradient(circle at 50% 85%, rgba(251, 191, 36, 0.08) 0%, transparent 30%); } @@ -89,7 +93,7 @@ .sticker-card { background: var(--card); color: var(--card-foreground); - border: 2.5px solid var(--border); + border: 2px solid var(--border); border-radius: 1.25rem; box-shadow: 4px 4px 0px var(--shadow-color); transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 522897e..0ab6fdd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -29,7 +29,7 @@ export default function RootLayout({ }) { return ( - + RB @@ -89,7 +89,7 @@ function LoginForm() { onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" - className="w-full bg-[var(--background)] border-2 border-[var(--border)] rounded-xl pl-10 pr-3.5 py-2.5 text-sm font-mono text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:border-violet-500 transition-colors font-bold" + className="w-full bg-[var(--background)] border-2 border-[var(--border)] rounded-xl pl-10 pr-3.5 py-2.5 text-sm font-mono text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:border-teal-500 transition-colors font-bold" placeholder="admin" /> @@ -106,7 +106,7 @@ function LoginForm() { value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" - className="w-full bg-[var(--background)] border-2 border-[var(--border)] rounded-xl pl-10 pr-3.5 py-2.5 text-sm font-mono text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:border-violet-500 transition-colors font-bold" + className="w-full bg-[var(--background)] border-2 border-[var(--border)] rounded-xl pl-10 pr-3.5 py-2.5 text-sm font-mono text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:border-teal-500 transition-colors font-bold" placeholder="••••••••" /> @@ -127,7 +127,7 @@ function LoginForm() { whileTap={{ scale: 0.98 }} type="submit" disabled={isSubmitting} - className="pill-btn w-full flex items-center justify-center gap-2 px-4 py-3 bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold transition-all disabled:opacity-50" + className="pill-btn w-full flex items-center justify-center gap-2 px-4 py-3 bg-teal-600 hover:bg-teal-500 text-white text-xs font-bold transition-all disabled:opacity-50" > {isSubmitting ? 'Authenticating...' : 'Sign In'} {!isSubmitting && } diff --git a/src/app/page.tsx b/src/app/page.tsx index 2148b59..beb310c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,24 @@ 'use client' import React, { useState, useEffect, useCallback, useMemo } from 'react' +import dynamic from 'next/dynamic' import { Navigation, TabType } from '@/components/Navigation' import { HomeView } from '@/components/HomeView' import { SubjectsView } from '@/components/SubjectsView' import { CalendarView } from '@/components/CalendarView' import { SettingsView } from '@/components/SettingsView' import { CourseModal } from '@/components/CourseModal' -import { ChatWidget } from '@/components/ChatWidget' -import { Course, CourseWithStats, TimetableSlot, AttendanceRecord, Holiday } from '@/types' +import { CourseWithStats, TimetableSlot, AttendanceRecord, Holiday } from '@/types' import { calculateAttendance } from '@/lib/attendance' import { Sparkles } from 'lucide-react' import { OnboardingWizard } from '@/components/OnboardingWizard' +// Lazy-load ChatWidget on client to optimize First Load JS +const ChatWidget = dynamic( + () => import('@/components/ChatWidget').then((mod) => mod.ChatWidget), + { ssr: false } +) + export default function App() { const [activeTab, setActiveTab] = useState('home') const [courses, setCourses] = useState([]) @@ -263,7 +269,7 @@ export default function App() { if (isLoading) { return (
-
+
@@ -271,7 +277,7 @@ export default function App() { Preparing Flight Deck...

-
+
@@ -365,7 +371,7 @@ export default function App() { )} - {/* AI Attendance Chatbot Widget */} + {/* AI Attendance Chatbot Widget (dynamic client load) */} {/* Subject Modal */} diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx index 533e966..2a9c940 100644 --- a/src/components/CalendarView.tsx +++ b/src/components/CalendarView.tsx @@ -315,7 +315,7 @@ export const CalendarView: React.FC = ({

- + Trajectory Lab & Flight Calendar

@@ -328,7 +328,7 @@ export const CalendarView: React.FC = ({ - fileInputRef.current?.click()} - disabled={isParsingSync} - className="pill-btn flex items-center gap-2 px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold transition-transform disabled:opacity-50 font-mono" + onClick={handleOneClickSync} + disabled={isAgentSyncing} + className="pill-btn flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-500 text-white text-xs font-bold transition-transform disabled:opacity-50 font-mono shadow-[3px_3px_0px_var(--shadow-color)]" > - - {isParsingSync ? 'Parsing File...' : 'Load Synced Data (JSON)'} + + {isAgentSyncing ? 'Syncing...' : 'Sync Now (1-Click)'}

+ {agentStatusNote && ( +
+ + {agentStatusNote} +
+ )} + {syncError && (
@@ -420,44 +491,54 @@ export const SettingsView: React.FC = ({
)} - {/* Step by Step Scraper Instructions */} -
-
- - Local SLCM Scraper Instructions -
- -
-
-
- - 1 - - One-Time Interactive Login -
-

- Opens Playwright browser to log into MAHE Microsoft SSO + MFA and saves session to auth.json: -

-
- cd scraper && node login.js -
+ {/* Agent Offline Quick Setup Card */} + {agentOffline && ( +
+
+ + Local Scraper Agent Offline
- -
-
- - 2 - - Sync Attendance Figures -
-

- Headlessly intercepts the Apex response and writes sync-output.json: -

-
- node sync.js +

+ Start the local scraper agent once in your terminal to enable 1-Click Sync: +

+
+
+ cd scraper && node agent.js
+ +
+ )} + + {/* Advanced Manual JSON Fallback Link */} +
+ Looking for manual file import? + +
@@ -481,7 +562,7 @@ export const SettingsView: React.FC = ({ setEditingCourse(null) setIsCourseModalOpen(true) }} - className="pill-btn flex items-center gap-1.5 px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold transition-transform" + className="pill-btn flex items-center gap-1.5 px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white text-xs font-bold transition-transform" > Add Subject @@ -496,7 +577,7 @@ export const SettingsView: React.FC = ({
@@ -512,7 +593,7 @@ export const SettingsView: React.FC = ({ {' • '} {course.stats.present} Present / {course.stats.absent} Absent ({course.stats.percentage}%) {course.syncedAt && ( - + [Synced: {course.syncedPresent}P/{course.syncedAbsent}A] )} @@ -568,7 +649,7 @@ export const SettingsView: React.FC = ({ setEditingSlot(null) setIsSlotModalOpen(true) }} - className="pill-btn flex items-center gap-1.5 px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold transition-transform" + className="pill-btn flex items-center gap-1.5 px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white text-xs font-bold transition-transform" > Add Slot @@ -609,7 +690,7 @@ export const SettingsView: React.FC = ({ {course?.name} @@ -675,7 +756,7 @@ export const SettingsView: React.FC = ({ className="bg-[var(--background)] border-2 border-[var(--border)] rounded-2xl p-4.5 space-y-3.5 shadow-[3px_3px_0px_var(--shadow-color)]" >
- Declare Holiday or Exam Date + Declare Holiday or Exam Date
@@ -686,7 +767,7 @@ export const SettingsView: React.FC = ({ value={newHolidayDate} onChange={(e) => setNewHolidayDate(e.target.value)} required - className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-violet-500 font-mono" + className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-teal-500 font-mono" />
@@ -697,7 +778,7 @@ export const SettingsView: React.FC = ({ value={newHolidayLabel} onChange={(e) => setNewHolidayLabel(e.target.value)} required - className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-violet-500" + className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-teal-500" />
@@ -705,7 +786,7 @@ export const SettingsView: React.FC = ({ setNewLogStatus(e.target.value as any)} - className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-violet-500 font-bold" + className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-teal-500 font-bold" > @@ -481,7 +481,7 @@ export const SubjectsView: React.FC = ({ placeholder="e.g. Lab, Quiz" value={newLogNote} onChange={(e) => setNewLogNote(e.target.value)} - className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-violet-500" + className="w-full bg-[var(--card)] border-2 border-[var(--border)] rounded-xl px-3 py-2 text-xs text-[var(--foreground)] focus:outline-none focus:border-teal-500" />
@@ -489,7 +489,7 @@ export const SubjectsView: React.FC = ({ diff --git a/src/components/SyncModal.tsx b/src/components/SyncModal.tsx index ddbd26e..fb76ecd 100644 --- a/src/components/SyncModal.tsx +++ b/src/components/SyncModal.tsx @@ -134,7 +134,7 @@ export const SyncModal: React.FC = ({ {/* Header */}
-
+
@@ -146,7 +146,7 @@ export const SyncModal: React.FC = ({ ? `Portal data from ${new Date(syncedAt).toLocaleString()}` : 'Portal Snapshot'} {' • '} - + {diff.length} course(s) in sync file

@@ -169,10 +169,10 @@ export const SyncModal: React.FC = ({ )} {/* Info Banner */} -
- +
+
-

+

Verified Portal Snapshot

@@ -200,7 +200,7 @@ export const SyncModal: React.FC = ({ {/* Incoming Course Info */}

- + {item.syncedCode} @@ -225,7 +225,7 @@ export const SyncModal: React.FC = ({