From ccb4354b265d5a7bb27d54031f99c9735d7c10e4 Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Sat, 7 Jun 2025 23:49:28 +1000 Subject: [PATCH] feat: assessments overview mark as complete --- .../built-in/assessmentsOverview/api.ts | 114 ++-- .../built-in/assessmentsOverview/index.ts | 84 +-- .../built-in/assessmentsOverview/styles.css | 113 ++++ .../built-in/assessmentsOverview/ui.ts | 531 +++++++++++++----- .../built-in/assessmentsOverview/utils.ts | 112 ++-- 5 files changed, 669 insertions(+), 285 deletions(-) diff --git a/src/plugins/built-in/assessmentsOverview/api.ts b/src/plugins/built-in/assessmentsOverview/api.ts index 0d746403..84fd349d 100644 --- a/src/plugins/built-in/assessmentsOverview/api.ts +++ b/src/plugins/built-in/assessmentsOverview/api.ts @@ -1,5 +1,13 @@ -interface Subject { code: string; programme: number; metaclass: number; title: string; } -interface PrefItem { name: string; value: string; } +interface Subject { + code: string; + programme: number; + metaclass: number; + title: string; +} +interface PrefItem { + name: string; + value: string; +} let cache: { time: number; data: any } | null = null; const CACHE_MS = 10 * 60 * 1000; @@ -7,29 +15,31 @@ const student = 69; async function fetchJSON(url: string, body: any) { const res = await fetch(`${location.origin}${url}`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body), }); return res.json(); } async function loadSubjects() { - const res = await fetchJSON('/seqta/student/load/subjects?', {}); - return res.payload.filter((s: any) => s.active === 1).flatMap((s: any) => s.subjects); + const res = await fetchJSON("/seqta/student/load/subjects?", {}); + return res.payload + .filter((s: any) => s.active === 1) + .flatMap((s: any) => s.subjects); } async function loadPrefs(student: number) { - const res = await fetchJSON('/seqta/student/load/prefs?', { - request: 'userPrefs', + const res = await fetchJSON("/seqta/student/load/prefs?", { + request: "userPrefs", asArray: true, user: student, }); const colors: Record = {}; res.payload.forEach((p: PrefItem) => { - if (p.name.startsWith('timetable.subject.colour.')) { - const code = p.name.replace('timetable.subject.colour.', ''); + if (p.name.startsWith("timetable.subject.colour.")) { + const code = p.name.replace("timetable.subject.colour.", ""); colors[code] = p.value; } }); @@ -37,45 +47,57 @@ async function loadPrefs(student: number) { } async function loadUpcoming(student: number) { - const res = await fetchJSON('/seqta/student/assessment/list/upcoming?', { student }); + const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", { + student, + }); return res.payload; } async function loadPast(student: number, subjects: Subject[]) { const map: Record = {}; - await Promise.all(subjects.map(async (s) => { - const res = await fetchJSON('/seqta/student/assessment/list/past?', { - programme: s.programme, - metaclass: s.metaclass, - student, - }); - if (res.payload.tasks) { - res.payload.tasks.forEach((t: any) => { map[t.id] = t; }); - } - })); + await Promise.all( + subjects.map(async (s) => { + const res = await fetchJSON("/seqta/student/assessment/list/past?", { + programme: s.programme, + metaclass: s.metaclass, + student, + }); + if (res.payload.tasks) { + res.payload.tasks.forEach((t: any) => { + map[t.id] = t; + }); + } + }), + ); return map; } async function loadSubmissions(student: number, assessments: any[]) { const submissionMap: Record = {}; - - // Fetch submission status for each assessment - await Promise.all(assessments.map(async (assessment) => { - try { - const res = await fetchJSON('/seqta/student/assessment/submissions/get', { - assessment: assessment.id, - metaclass: assessment.metaclassID, - student, - }); - - // If there are any submissions, mark as submitted - submissionMap[assessment.id] = res.payload && res.payload.length > 0; - } catch (error) { - console.warn(`Failed to fetch submission for assessment ${assessment.id}:`, error); - submissionMap[assessment.id] = false; - } - })); - + + await Promise.all( + assessments.map(async (assessment) => { + try { + const res = await fetchJSON( + "/seqta/student/assessment/submissions/get", + { + assessment: assessment.id, + metaclass: assessment.metaclassID, + student, + }, + ); + + submissionMap[assessment.id] = res.payload && res.payload.length > 0; + } catch (error) { + console.warn( + `Failed to fetch submission for assessment ${assessment.id}:`, + error, + ); + submissionMap[assessment.id] = false; + } + }), + ); + return submissionMap; } @@ -88,21 +110,21 @@ export async function getAssessmentsData() { ]); const pastMap = await loadPast(student, subjects); const map: Record = {}; - upcoming.forEach((a: any) => { map[a.id] = { ...a }; }); + upcoming.forEach((a: any) => { + map[a.id] = { ...a }; + }); Object.values(pastMap).forEach((t: any) => { if (map[t.id]) Object.assign(map[t.id], t); else map[t.id] = t; }); - - // Load submission data for all assessments + const allAssessments = Object.values(map); const submissions = await loadSubmissions(student, allAssessments); - - // Add submission status to each assessment + allAssessments.forEach((assessment: any) => { assessment.submitted = submissions[assessment.id] || false; }); - + const data = { assessments: allAssessments, subjects, colors }; cache = { time: Date.now(), data }; return data; diff --git a/src/plugins/built-in/assessmentsOverview/index.ts b/src/plugins/built-in/assessmentsOverview/index.ts index 33888ae7..24ac48e1 100644 --- a/src/plugins/built-in/assessmentsOverview/index.ts +++ b/src/plugins/built-in/assessmentsOverview/index.ts @@ -1,29 +1,35 @@ -import type { Plugin } from '../../core/types'; -import { waitForElm } from '@/seqta/utils/waitForElm'; -import { getAssessmentsData } from './api'; -import { renderSkeletonLoader, renderErrorState } from './ui'; -import styles from './styles.css?inline'; -import { delay } from '@/seqta/utils/delay'; +import type { Plugin } from "../../core/types"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import { getAssessmentsData } from "./api"; +import { renderSkeletonLoader, renderErrorState } from "./ui"; +import styles from "./styles.css?inline"; +import { delay } from "@/seqta/utils/delay"; const assessmentsOverviewPlugin: Plugin<{}> = { - id: 'assessments-overview', - name: 'Assessments Overview', - description: 'Adds an overview option to the assessments page that organizes assessments by status', - version: '1.0.0', + id: "assessments-overview", + name: "Assessments Overview", + description: + "Adds an overview option to the assessments page that organizes assessments by status", + version: "1.0.0", settings: {}, disableToggle: false, styles, run: async () => { - const menu = (await waitForElm('[data-key="assessments"] > .sub > ul', true, 100, 60)) as HTMLElement; - const gridItem = document.createElement('li'); - gridItem.className = 'item'; - const label = document.createElement('label'); - label.textContent = 'Overview'; + const menu = (await waitForElm( + '[data-key="assessments"] > .sub > ul', + true, + 100, + 60, + )) as HTMLElement; + const gridItem = document.createElement("li"); + gridItem.className = "item"; + const label = document.createElement("label"); + label.textContent = "Overview"; gridItem.appendChild(label); menu.insertBefore(gridItem, menu.children[1] || null); - if (window.location.hash.includes('/assessments/overview')) { + if (window.location.hash.includes("/assessments/overview")) { loadGridView(); } @@ -31,42 +37,50 @@ const assessmentsOverviewPlugin: Plugin<{}> = { e.preventDefault(); loadGridView(); }; - gridItem.addEventListener('click', clickHandler); + gridItem.addEventListener("click", clickHandler); async function loadGridView() { await delay(1); - window.history.pushState({}, '', '/#?page=/assessments/overview'); - const main = document.getElementById('main'); + window.history.pushState({}, "", "/#?page=/assessments/overview"); + document.title = "Overview ― SEQTA Learn"; + const main = document.getElementById("main"); if (!main) return; - // Update navigation state - document.querySelectorAll('[data-key="assessments"] .item').forEach(item => { - item.classList.remove('active'); - }); - gridItem.classList.add('active'); - document.querySelector('[data-key="assessments"]')?.classList.add('active'); - - // Clear main content and add container + document + .querySelectorAll('[data-key="assessments"] .item') + .forEach((item) => { + item.classList.remove("active"); + }); + gridItem.classList.add("active"); + document + .querySelector('[data-key="assessments"]') + ?.classList.add("active"); + main.innerHTML = '
'; - const container = document.getElementById('grid-view-container') as HTMLElement; - + const container = document.getElementById( + "grid-view-container", + ) as HTMLElement; + renderSkeletonLoader(container); - + try { const data = await getAssessmentsData(); - const { renderGrid } = await import('./ui'); + const { renderGrid } = await import("./ui"); renderGrid(container, data); } catch (err) { - console.error('Failed to load assessments:', err); - renderErrorState(container, err instanceof Error ? err.message : 'Unknown error'); + console.error("Failed to load assessments:", err); + renderErrorState( + container, + err instanceof Error ? err.message : "Unknown error", + ); } } return () => { - gridItem.removeEventListener('click', clickHandler); + gridItem.removeEventListener("click", clickHandler); gridItem.remove(); }; }, }; -export default assessmentsOverviewPlugin; \ No newline at end of file +export default assessmentsOverviewPlugin; diff --git a/src/plugins/built-in/assessmentsOverview/styles.css b/src/plugins/built-in/assessmentsOverview/styles.css index 6e908b7a..2b47624c 100644 --- a/src/plugins/built-in/assessmentsOverview/styles.css +++ b/src/plugins/built-in/assessmentsOverview/styles.css @@ -224,12 +224,125 @@ background: var(--subject-color, #d41e3a); } +.card-menu { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 20; +} + +.menu-button { + background: transparent !important; + border: none !important; + padding: 0.25rem !important; + cursor: pointer; + border-radius: 4px; + color: #64748b; + transition: all 0.2s ease; + display: flex !important; + align-items: center; + justify-content: center; + width: 24px !important; + height: 24px !important; + margin: 0 !important; + position: static !important; + transform: none !important; + box-shadow: none !important; + outline: none !important; +} + +.menu-button:hover { + background: #f1f5f9 !important; + color: #1a1a1a; +} + +.menu-button svg { + width: 16px !important; + height: 16px !important; + fill: currentColor !important; + display: block !important; +} + +.dark .menu-button { + color: var(--text-primary); + opacity: 0.7; +} + +.dark .menu-button:hover { + background: rgba(255, 255, 255, 0.1) !important; + opacity: 1; +} + +.menu-dropdown { + position: absolute; + top: 100%; + right: 0; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 140px; + z-index: 30; + margin-top: 4px; +} + +.dark .menu-dropdown { + background: var(--background-secondary); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.menu-item { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + font-size: 0.875rem; + color: #1a1a1a; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.menu-item:hover { + background: #f8fafc; +} + +.dark .menu-item { + color: var(--text-primary); +} + +.dark .menu-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.menu-item.mark-completed { + color: #059669; + font-weight: 500; +} + +.dark .menu-item.mark-completed { + color: #10b981; +} + +.menu-item.mark-not-completed { + color: #dc2626; + font-weight: 500; +} + +.dark .menu-item.mark-not-completed { + color: #ef4444; +} + .assessment-title { font-size: 0.875rem; font-weight: 600; color: #1a1a1a; margin: 0 0 0.75rem 0; line-height: 1.4; + padding-right: 2rem; /* Make room for menu button */ } .dark .assessment-title { diff --git a/src/plugins/built-in/assessmentsOverview/ui.ts b/src/plugins/built-in/assessmentsOverview/ui.ts index cb9a3ab6..75c41ed1 100644 --- a/src/plugins/built-in/assessmentsOverview/ui.ts +++ b/src/plugins/built-in/assessmentsOverview/ui.ts @@ -1,40 +1,53 @@ -import { determineStatus, formatDate, getGradeValue } from './utils'; -import { settingsState } from '@/seqta/utils/listeners/SettingsState'; +import { determineStatus, formatDate, getGradeValue } from "./utils"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; function percentageToLetter(percentage: number): string { const letterMap: Record = { - 100: 'A+', 95: 'A', 90: 'A-', 85: 'B+', 80: 'B', 75: 'B-', - 70: 'C+', 65: 'C', 60: 'C-', 55: 'D+', 50: 'D', 45: 'D-', - 40: 'E+', 35: 'E', 30: 'E-', 0: 'F' + 100: "A+", + 95: "A", + 90: "A-", + 85: "B+", + 80: "B", + 75: "B-", + 70: "C+", + 65: "C", + 60: "C-", + 55: "D+", + 50: "D", + 45: "D-", + 40: "E+", + 35: "E", + 30: "E-", + 0: "F", }; - + const rounded = Math.ceil(percentage / 5) * 5; - return letterMap[rounded] || 'F'; + return letterMap[rounded] || "F"; } interface FilterOptions { subject: string; - sortBy: 'due' | 'grade' | 'subject' | 'title'; + sortBy: "due" | "grade" | "subject" | "title"; } let currentFilters: FilterOptions = { - subject: 'all', - sortBy: 'due' + subject: "all", + sortBy: "due", }; export function renderGrid(container: HTMLElement, data: any) { - container.innerHTML = ''; - container.className = ''; - container.id = 'grid-view-container'; - - const header = document.createElement('div'); - header.className = 'grid-view-header'; - header.innerHTML = /* html */` + container.innerHTML = ""; + container.className = ""; + container.id = "grid-view-container"; + + const header = document.createElement("div"); + header.className = "grid-view-header"; + header.innerHTML = `

Assessments

`; - + container.appendChild(header); - - const subjectFilter = header.querySelector('#subject-filter') as HTMLSelectElement; - const sortFilter = header.querySelector('#sort-filter') as HTMLSelectElement; - - subjectFilter.addEventListener('change', () => { + + const subjectFilter = header.querySelector( + "#subject-filter", + ) as HTMLSelectElement; + const sortFilter = header.querySelector("#sort-filter") as HTMLSelectElement; + + subjectFilter.addEventListener("change", () => { currentFilters.subject = subjectFilter.value; renderAssessments(); }); - - sortFilter.addEventListener('change', () => { + + sortFilter.addEventListener("change", () => { currentFilters.sortBy = sortFilter.value as any; renderAssessments(); }); - const mainContent = document.createElement('div'); - mainContent.id = 'main-grid-content'; + const mainContent = document.createElement("div"); + mainContent.id = "main-grid-content"; container.appendChild(mainContent); function renderAssessments() { - const contentArea = container.querySelector('#main-grid-content') as HTMLElement; - contentArea.innerHTML = ''; + const contentArea = container.querySelector( + "#main-grid-content", + ) as HTMLElement; + contentArea.innerHTML = ""; - // Filter assessments by subject let filteredAssessments = data.assessments.filter((a: any) => { - const subjectMatch = currentFilters.subject === 'all' || a.code === currentFilters.subject; + const subjectMatch = + currentFilters.subject === "all" || a.code === currentFilters.subject; return subjectMatch; }); - // Sort assessments filteredAssessments.sort((a: any, b: any) => { switch (currentFilters.sortBy) { - case 'due': + case "due": return new Date(a.due).getTime() - new Date(b.due).getTime(); - case 'grade': + case "grade": const gradeA = getGradeValue(a); const gradeB = getGradeValue(b); if (gradeA === null && gradeB === null) return 0; if (gradeA === null) return 1; if (gradeB === null) return -1; return gradeB - gradeA; - case 'subject': + case "subject": return a.code.localeCompare(b.code); - case 'title': + case "title": return a.title.localeCompare(b.title); default: return 0; @@ -108,74 +124,77 @@ export function renderGrid(container: HTMLElement, data: any) { renderKanbanBoard(contentArea, filteredAssessments, data); } - function renderKanbanBoard(container: HTMLElement, assessments: any[], data: any) { - // Group assessments by status + function renderKanbanBoard( + container: HTMLElement, + assessments: any[], + data: any, + ) { const statusGroups = { - 'UPCOMING': [] as any[], - 'DUE_SOON': [] as any[], - 'OVERDUE': [] as any[], - 'SUBMITTED': [] as any[], - 'MARKS_RELEASED': [] as any[] + UPCOMING: [] as any[], + DUE_SOON: [] as any[], + OVERDUE: [] as any[], + SUBMITTED: [] as any[], + MARKS_RELEASED: [] as any[], }; - assessments.forEach(assessment => { + assessments.forEach((assessment) => { const status = determineStatus(assessment); if (statusGroups[status as keyof typeof statusGroups]) { statusGroups[status as keyof typeof statusGroups].push(assessment); } }); - const board = document.createElement('div'); - board.className = 'kanban-board'; + const board = document.createElement("div"); + board.className = "kanban-board"; const columns = [ { - key: 'UPCOMING', - title: 'Upcoming', - className: 'column-upcoming', - icon: '📅' + key: "UPCOMING", + title: "Upcoming", + className: "column-upcoming", + icon: "📅", }, { - key: 'DUE_SOON', - title: 'Due Soon', - className: 'column-due-soon', - icon: '⏰' + key: "DUE_SOON", + title: "Due Soon", + className: "column-due-soon", + icon: "⏰", }, { - key: 'OVERDUE', - title: 'Overdue', - className: 'column-overdue', - icon: '🚨' + key: "OVERDUE", + title: "Overdue", + className: "column-overdue", + icon: "🚨", }, { - key: 'SUBMITTED', - title: 'Submitted', - className: 'column-submitted', - icon: '📝' + key: "SUBMITTED", + title: "Submitted", + className: "column-submitted", + icon: "📝", }, { - key: 'MARKS_RELEASED', - title: 'Marked', - className: 'column-marked', - icon: '✅' - } + key: "MARKS_RELEASED", + title: "Marked", + className: "column-marked", + icon: "✅", + }, ]; - columns.forEach(column => { - const assessmentList = statusGroups[column.key as keyof typeof statusGroups]; - - // Skip SUBMITTED column if it's empty - if (column.key === 'SUBMITTED' && assessmentList.length === 0) { + columns.forEach((column) => { + const assessmentList = + statusGroups[column.key as keyof typeof statusGroups]; + + if (column.key === "SUBMITTED" && assessmentList.length === 0) { return; } - - const columnParentEl = document.createElement('div'); - columnParentEl.className = 'kanban-column-parent'; - const columnEl = document.createElement('div'); + const columnParentEl = document.createElement("div"); + columnParentEl.className = "kanban-column-parent"; + + const columnEl = document.createElement("div"); columnEl.className = `kanban-column ${column.className}`; - - columnEl.innerHTML = /* html */` + + columnEl.innerHTML = `
${column.icon} ${column.title} @@ -185,18 +204,25 @@ export function renderGrid(container: HTMLElement, data: any) {
`; - const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement; - + const cardsContainer = columnEl.querySelector( + `#${column.key.toLowerCase()}-cards`, + ) as HTMLElement; + if (assessmentList.length === 0) { - cardsContainer.innerHTML = /* html */` + cardsContainer.innerHTML = `
${column.icon}

No ${column.title.toLowerCase()} assessments

`; } else { - assessmentList.forEach(assessment => { - cardsContainer.appendChild(createKanbanCard(assessment, data.colors[assessment.code] || '#6366f1')); + assessmentList.forEach((assessment) => { + cardsContainer.appendChild( + createKanbanCard( + assessment, + data.colors[assessment.code] || "#6366f1", + ), + ); }); } @@ -211,35 +237,80 @@ export function renderGrid(container: HTMLElement, data: any) { const status = determineStatus(assessment); const dueDateClass = getDueDateClass(assessment); - const card = document.createElement('div'); - card.className = 'assessment-card'; + const completedKey = "betterseqta-completed-assessments"; + const completed = JSON.parse(localStorage.getItem(completedKey) || "[]"); + const isManuallyCompleted = completed.includes(assessment.id); + + const card = document.createElement("div"); + card.className = "assessment-card"; card.dataset.subject = assessment.code; card.dataset.status = status; - card.style.setProperty('--subject-color', color); + card.style.setProperty("--subject-color", color); card.innerHTML = `
${assessment.code} - ${assessment.submitted ? '' : ''} + ${assessment.submitted ? '' : ""} + ${isManuallyCompleted && status === "MARKS_RELEASED" && !assessment.results ? 'Completed' : ""}
+ ${ + status !== "MARKS_RELEASED" + ? ` +
+ + +
+ ` + : isManuallyCompleted + ? ` +
+ + +
+ ` + : "" + }

${assessment.title}

- ${!assessment.results ? ` + ${ + !assessment.results && !isManuallyCompleted + ? `
📅 ${formatDate(assessment.due, assessment.submitted)}
- ` : ''} - ${assessment.results - ? /* html */` + ` + : "" + } + ${ + assessment.results + ? `