diff --git a/src/plugins/built-in/assessmentsOverview/api.ts b/src/plugins/built-in/assessmentsOverview/api.ts new file mode 100644 index 00000000..0d746403 --- /dev/null +++ b/src/plugins/built-in/assessmentsOverview/api.ts @@ -0,0 +1,109 @@ +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; +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' }, + 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); +} + +async function loadPrefs(student: number) { + 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.', ''); + colors[code] = p.value; + } + }); + return colors; +} + +async function loadUpcoming(student: number) { + 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; }); + } + })); + 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; + } + })); + + return submissionMap; +} + +export async function getAssessmentsData() { + if (cache && Date.now() - cache.time < CACHE_MS) return cache.data; + const [subjects, colors, upcoming] = await Promise.all([ + loadSubjects(), + loadPrefs(student), + loadUpcoming(student), + ]); + const pastMap = await loadPast(student, subjects); + const map: Record = {}; + 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 new file mode 100644 index 00000000..71746fcc --- /dev/null +++ b/src/plugins/built-in/assessmentsOverview/index.ts @@ -0,0 +1,72 @@ +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 gridViewPlugin: Plugin<{}> = { + id: 'assessments-grid-view', + 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'; + gridItem.appendChild(label); + menu.insertBefore(gridItem, menu.children[1] || null); + + if (window.location.hash.includes('/assessments/overview')) { + loadGridView(); + } + + const clickHandler = (e: Event) => { + e.preventDefault(); + loadGridView(); + }; + gridItem.addEventListener('click', clickHandler); + + async function loadGridView() { + await delay(1); + window.history.pushState({}, '', '/#?page=/assessments/overview'); + 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 + main.innerHTML = '
'; + const container = document.getElementById('grid-view-container') as HTMLElement; + + renderSkeletonLoader(container); + + try { + const data = await getAssessmentsData(); + 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'); + } + } + + return () => { + gridItem.removeEventListener('click', clickHandler); + gridItem.remove(); + }; + }, +}; + +export default gridViewPlugin; \ No newline at end of file diff --git a/src/plugins/built-in/assessmentsOverview/styles.css b/src/plugins/built-in/assessmentsOverview/styles.css new file mode 100644 index 00000000..fe97e42f --- /dev/null +++ b/src/plugins/built-in/assessmentsOverview/styles.css @@ -0,0 +1,681 @@ +#grid-view-container { + background: transparent; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.grid-view-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + flex-shrink: 0; +} + +.grid-view-title { + font-size: 1.875rem !important; + font-weight: 700; + color: #1a1a1a; + margin: 0; +} + +/* Dark mode support */ +.dark .grid-view-title { + color: #f8fafc; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.grid-view-filters { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.filter-select { + background: #ffffff; + border: 2px solid #e2e8f0; + border-radius: 8px; + color: #1a1a1a; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + min-width: 180px; +} + +.filter-select:focus { + outline: none; + border-color: #d41e3a; + box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1); +} + +.filter-select:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +/* Dark mode dropdowns */ +.dark .filter-select { + background: var(--background-primary); + border-color: var(--background-secondary); + color: var(--text-primary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.dark .filter-select:focus { + border-color: #d41e3a; + box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2); +} + +.dark .filter-select:hover { + border-color: var(--background-secondary); + background: var(--background-secondary); +} + +.dark .filter-select option { + background: var(--background-primary); + color: var(--text-primary); +} + +#main-grid-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* Kanban Board Layout */ +.kanban-board { + display: flex; + gap: 1.5rem; + overflow-x: auto; + padding: 1rem; + flex: 0 1 auto; +} + +.kanban-column-parent { + flex: 0 0 320px; + +} + +.kanban-column { + max-height: 100%; + background: #f8fafc; + border-radius: 12px; + box-shadow: 0 0 0 2px #e2e8f0; + display: flex; + flex-direction: column; + min-height: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Dark mode columns */ +.dark .kanban-column { + background: var(--background-primary); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); +} + +.column-header { + padding: 1rem 1.25rem; + border-bottom: 2px solid #e2e8f0; + background: #ffffff; + border-radius: 12px 12px 0 0; + position: sticky; + top: 0; + z-index: 10; +} + +/* Dark mode column headers */ +.dark .column-header { + background: var(--background-secondary); + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +.column-title { + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; + margin: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.dark .column-title { + color: var(--text-primary); +} + +.column-count { + background: #e2e8f0; + color: #64748b; + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; +} + +.dark .column-count { + background: var(--background-secondary); + color: var(--text-primary); +} + +.column-cards { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + overflow-y: auto; +} + +/* Assessment Cards */ +.assessment-card { + background: #ffffff; + border-radius: 8px; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; + cursor: pointer; + position: relative; + border-left: 4px solid var(--subject-color, #d41e3a); + border: 1px solid #e2e8f0; +} + +.assessment-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-1px); + border-color: #cbd5e1; +} + +/* Dark mode cards */ +.dark .assessment-card { + background: var(--background-secondary); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.dark .assessment-card:hover { + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.15); +} + +.card-labels { + display: flex; + gap: 0.25rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.card-label { + padding: 0.25rem 0.75rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.label-subject { + background: var(--subject-color, #d41e3a); +} + +.assessment-title { + font-size: 0.875rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 0.75rem 0; + line-height: 1.4; +} + +.dark .assessment-title { + color: var(--text-primary); +} + +.assessment-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.75rem; + font-size: 0.75rem; + color: #64748b; +} + +.dark .assessment-meta { + color: var(--text-primary); + opacity: 0.7; +} + +.due-date { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: 500; +} + +.due-date.overdue { + color: #dc2626; +} + +.due-date.due-soon { + color: #d97706; +} + +.due-date.upcoming { + color: #059669; +} + +.card-footer { + display: flex; + align-items: center; + justify-content: flex-start; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; +} + +.dark .card-footer { + border-top-color: rgba(255, 255, 255, 0.1); +} + +.grade-display { + font-weight: 700; + font-size: 0.875rem; + padding: 0.375rem 0.75rem; + border-radius: 6px; +} + +.grade-good { + background: rgba(16, 185, 129, 0.1); + color: #059669; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.grade-average { + background: rgba(245, 158, 11, 0.1); + color: #d97706; + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.grade-bad { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.grade-empty { + color: #64748b; + font-style: italic; + font-weight: 500; + background: #f1f5f9; + border: 1px solid #e2e8f0; +} + +.dark .grade-empty { + color: var(--text-primary); + opacity: 0.7; + background: var(--background-secondary); + border-color: var(--background-secondary); +} + +/* Column-specific styling */ +.column-upcoming .column-header { + background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%); + border-left: 4px solid #3b82f6; +} + +.column-due-soon .column-header { + background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%); + border-left: 4px solid #f59e0b; +} + +.column-overdue .column-header { + background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%); + border-left: 4px solid #ef4444; +} + +.column-marked .column-header { + background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); + border-left: 4px solid #10b981; +} + +/* Dark mode column headers */ +.dark .column-upcoming .column-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%); +} + +.dark .column-due-soon .column-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%); +} + +.dark .column-overdue .column-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%); +} + +.dark .column-marked .column-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%); +} + +/* Subject filter view */ +.subject-section { + margin-bottom: 2rem; +} + +.subject-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 1rem; + background: #ffffff; + border-radius: 8px; + border: 2px solid #e2e8f0; + border-left: 4px solid var(--subject-color, #d41e3a); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.dark .subject-header { + background: var(--background-secondary); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.subject-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.subject-title { + font-size: 1.125rem; + font-weight: 600; + color: #1a1a1a; + margin: 0; +} + +.dark .subject-title { + color: var(--text-primary); +} + +.subject-code { + font-size: 0.875rem; + color: #64748b; +} + +.dark .subject-code { + color: var(--text-primary); + opacity: 0.7; +} + +/* Loading and error states */ +.loading-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 4rem 0; + background: #ffffff; + border-radius: 12px; + border: 2px solid #e2e8f0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.dark .loading-container { + background: var(--background-primary); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.loading-spinner { + width: 2.5rem; + height: 2.5rem; + border: 3px solid #e2e8f0; + border-top: 3px solid #d41e3a; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.dark .loading-spinner { + border-color: rgba(255, 255, 255, 0.1); + border-top-color: #d41e3a; +} + +.loading-text { + margin-top: 1rem; + color: #64748b; + font-size: 0.875rem; + font-weight: 500; +} + +.dark .loading-text { + color: var(--text-primary); + opacity: 0.7; +} + +.error-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 4rem 0; + background: #ffffff; + border-radius: 12px; + border: 2px solid #fecaca; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.dark .error-container { + background: var(--background-primary); + border-color: #991b1b; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.error-text { + color: #ef4444; + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.empty-state { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 2rem; + color: #64748b; + font-size: 0.875rem; + text-align: center; +} + +.dark .empty-state { + color: var(--text-primary); + opacity: 0.7; +} + +.empty-column { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + color: #64748b; + font-size: 0.875rem; + text-align: center; + min-height: 200px; +} + +.dark .empty-column { + color: var(--text-primary); + opacity: 0.7; +} + +.empty-icon { + font-size: 2rem; + margin-bottom: 0.5rem; + opacity: 0.5; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +.skeleton-element { + background: linear-gradient( + 90deg, + #f1f5f9 0%, + #e2e8f0 50%, + #f1f5f9 100% + ); + background-size: 1000px 100%; + animation: shimmer 2s infinite linear; + border-radius: 4px; +} + +.dark .skeleton-element { + background: linear-gradient( + 90deg, + var(--background-primary) 0%, + var(--background-secondary) 50%, + var(--background-primary) 100% + ); + background-size: 1000px 100%; +} + +.skeleton-label { + height: 20px; + width: 60px; + margin-bottom: 0.75rem; + border-radius: 6px; +} + +.skeleton-title { + height: 16px; + width: 80%; + margin-bottom: 0.5rem; +} + +.skeleton-title-line2 { + height: 16px; + width: 60%; + margin-bottom: 0.75rem; +} + +.skeleton-meta { + height: 12px; + width: 40%; + margin-top: 0.75rem; +} + +.skeleton-footer { + height: 8px; + width: 100%; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; +} + +.dark .skeleton-footer { + border-top-color: rgba(255, 255, 255, 0.1); +} + +/* Responsive design */ +@media (max-width: 768px) { + #grid-view-container { + padding: 1rem; + } + + .grid-view-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .grid-view-filters { + justify-content: center; + flex-wrap: wrap; + } + + .kanban-board { + flex-direction: column; + gap: 1rem; + } + + .kanban-column { + flex: none; + max-height: none; + } + + .filter-select { + min-width: 140px; + } +} + +@media (max-width: 480px) { + .grid-view-title { + font-size: 1.5rem !important; + } + + .filter-select { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + min-width: 120px; + } + + .assessment-card { + padding: 0.75rem; + } + + .card-footer { + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + } +} + +/* Scrollbar styling for webkit browsers */ +.column-cards::-webkit-scrollbar { + width: 6px; +} + +.column-cards::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 3px; +} + +.column-cards::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +.column-cards::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Dark mode scrollbars */ +.dark .column-cards::-webkit-scrollbar-track { + background: var(--background-secondary); +} + +.dark .column-cards::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); +} + +.dark .column-cards::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} \ No newline at end of file diff --git a/src/plugins/built-in/assessmentsOverview/ui.ts b/src/plugins/built-in/assessmentsOverview/ui.ts new file mode 100644 index 00000000..6035aac2 --- /dev/null +++ b/src/plugins/built-in/assessmentsOverview/ui.ts @@ -0,0 +1,366 @@ +import { determineStatus, formatDate, getGradeValue } from './utils'; + +interface FilterOptions { + subject: string; + sortBy: 'due' | 'grade' | 'subject' | 'title'; +} + +let currentFilters: FilterOptions = { + 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 = ` +

Assessments

+
+ + +
+ `; + + container.appendChild(header); + + 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', () => { + currentFilters.sortBy = sortFilter.value as any; + renderAssessments(); + }); + + 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 = ''; + + // Filter assessments by subject + let filteredAssessments = data.assessments.filter((a: any) => { + const subjectMatch = currentFilters.subject === 'all' || a.code === currentFilters.subject; + return subjectMatch; + }); + + // Sort assessments + filteredAssessments.sort((a: any, b: any) => { + switch (currentFilters.sortBy) { + case 'due': + return new Date(a.due).getTime() - new Date(b.due).getTime(); + 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': + return a.code.localeCompare(b.code); + case 'title': + return a.title.localeCompare(b.title); + default: + return 0; + } + }); + + if (filteredAssessments.length === 0) { + contentArea.innerHTML = ` +
+
📋
+

No assessments found matching your filters

+
+ `; + return; + } + + renderKanbanBoard(contentArea, filteredAssessments, data); + } + + function renderKanbanBoard(container: HTMLElement, assessments: any[], data: any) { + // Group assessments by status + const statusGroups = { + 'UPCOMING': [] as any[], + 'DUE_SOON': [] as any[], + 'OVERDUE': [] as any[], + 'MARKS_RELEASED': [] as any[] + }; + + 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 columns = [ + { + key: 'UPCOMING', + title: 'Upcoming', + className: 'column-upcoming', + icon: '📅' + }, + { + key: 'DUE_SOON', + title: 'Due Soon', + className: 'column-due-soon', + icon: '⏰' + }, + { + key: 'OVERDUE', + title: 'Overdue', + className: 'column-overdue', + icon: '🚨' + }, + { + key: 'MARKS_RELEASED', + title: 'Marked', + className: 'column-marked', + icon: '✅' + } + ]; + + columns.forEach(column => { + const columnParentEl = document.createElement('div'); + columnParentEl.className = 'kanban-column-parent'; + + const columnEl = document.createElement('div'); + columnEl.className = `kanban-column ${column.className}`; + + const assessmentList = statusGroups[column.key as keyof typeof statusGroups]; + + columnEl.innerHTML = /* html */` +
+
+ ${column.icon} ${column.title} + ${assessmentList.length} +
+
+
+ `; + + const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement; + + if (assessmentList.length === 0) { + cardsContainer.innerHTML = /* html */` +
+
${column.icon}
+

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

+
+ `; + } else { + assessmentList.forEach(assessment => { + cardsContainer.appendChild(createKanbanCard(assessment, data.colors[assessment.code] || '#6366f1')); + }); + } + + columnParentEl.appendChild(columnEl); + board.appendChild(columnParentEl); + }); + + container.appendChild(board); + } + + function createKanbanCard(assessment: any, color: string): HTMLElement { + const status = determineStatus(assessment); + const dueDateClass = getDueDateClass(assessment); + + 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.innerHTML = ` +
+ ${assessment.code} + ${assessment.submitted ? '' : ''} +
+

${assessment.title}

+
+
+ 📅 ${formatDate(assessment.due)} +
+
+ ${assessment.results + ? /* html */` + + ` : '' + } + `; + + card.addEventListener('click', () => { + window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`; + }); + + return card; + } + + + function getDueDateClass(assessment: any): string { + const status = determineStatus(assessment); + switch (status) { + case 'OVERDUE': + return 'overdue'; + case 'DUE_SOON': + return 'due-soon'; + case 'UPCOMING': + return 'upcoming'; + default: + return ''; + } + } + + // Initial render + renderAssessments(); +} + +export function renderSkeletonLoader(container: HTMLElement) { + container.innerHTML = ''; + container.className = ''; + container.id = 'grid-view-container'; + + // Create header with disabled filters + const header = document.createElement('div'); + header.className = 'grid-view-header'; + header.innerHTML = ` +

Assessments

+
+ + +
+ `; + + container.appendChild(header); + + const mainContent = document.createElement('div'); + mainContent.id = 'main-grid-content'; + container.appendChild(mainContent); + + const columns = [ + { + key: 'UPCOMING', + title: 'Upcoming', + className: 'column-upcoming', + icon: '📅', + skeletonCount: 3 + }, + { + key: 'DUE_SOON', + title: 'Due Soon', + className: 'column-due-soon', + icon: '⏰', + skeletonCount: 2 + }, + { + key: 'OVERDUE', + title: 'Overdue', + className: 'column-overdue', + icon: '🚨', + skeletonCount: 1 + }, + { + key: 'MARKS_RELEASED', + title: 'Marked', + className: 'column-marked', + icon: '✅', + skeletonCount: 4 + } + ]; + + const board = document.createElement('div'); + board.className = 'kanban-board'; + + columns.forEach(column => { + const columnParentEl = document.createElement('div'); + columnParentEl.className = 'kanban-column-parent'; + + const columnEl = document.createElement('div'); + columnEl.className = `kanban-column ${column.className}`; + + columnEl.innerHTML = /* html */` +
+
+ ${column.icon} ${column.title} + ... +
+
+
+ `; + + const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement; + + for (let i = 0; i < column.skeletonCount; i++) { + cardsContainer.appendChild(createSkeletonCard(column.key === 'MARKS_RELEASED')); + } + + columnParentEl.appendChild(columnEl); + board.appendChild(columnParentEl); + }); + + mainContent.appendChild(board); +} + +function createSkeletonCard(footer: boolean = false): HTMLElement { + const card = document.createElement('div'); + card.className = 'assessment-card'; + + card.innerHTML = ` +
+
+
+
+ ${footer ? /* html */` + + ` : ''} + `; + + return card; +} + +export function renderLoadingState(container: HTMLElement) { + renderSkeletonLoader(container); +} + +export function renderErrorState(container: HTMLElement, error: string) { + container.innerHTML = /* html */` +
+

Failed to load assessments

+

${error}

+
+ `; +} \ No newline at end of file diff --git a/src/plugins/built-in/assessmentsOverview/utils.ts b/src/plugins/built-in/assessmentsOverview/utils.ts new file mode 100644 index 00000000..32d0ea41 --- /dev/null +++ b/src/plugins/built-in/assessmentsOverview/utils.ts @@ -0,0 +1,89 @@ +export function formatDate(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diffTime = d.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // If it's overdue + if (diffDays < 0) { + const overdueDays = Math.abs(diffDays); + if (overdueDays === 1) return '1 day overdue'; + return `${overdueDays} days overdue`; + } + + // If it's today + if (diffDays === 0) return 'Today'; + + // If it's tomorrow + if (diffDays === 1) return 'Tomorrow'; + + // If it's within a week + if (diffDays <= 7) { + return d.toLocaleDateString(undefined, { weekday: 'long' }); + } + + // Otherwise show full date + return d.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); +} + +export function determineStatus(item: any): string { + // Check if marks are released or if there's a grade + if (item.status === 'MARKS_RELEASED' || item.grade || + (item.percentage !== undefined && item.percentage !== null) || + (item.achieved !== undefined && item.achieved !== null)) { + return 'MARKS_RELEASED'; + } + + const now = new Date(); + const due = new Date(item.due); + + // Check if overdue, but only if not submitted + if (due.getTime() < now.getTime()) { + // If it's submitted, treat it as marks pending (upcoming) instead of overdue + if (item.submitted) { + return 'UPCOMING'; + } + return 'OVERDUE'; + } + + // Check if due soon (within 7 days) + const diffTime = due.getTime() - now.getTime(); + const diffDays = diffTime / (1000 * 60 * 60 * 24); + + if (diffDays <= 7) { + return 'DUE_SOON'; + } + + return 'UPCOMING'; +} + +export function getGradeValue(assessment: any): number | null { + // Check results.percentage first (most common for graded assessments) + if (assessment.results?.percentage !== undefined && assessment.results.percentage !== null) { + return assessment.results.percentage; + } + + // Check direct percentage property + if (assessment.percentage !== undefined && assessment.percentage !== null) { + return assessment.percentage; + } + + // Check achieved/outOf combination + if (assessment.achieved !== undefined && assessment.outOf !== undefined && + assessment.achieved !== null && assessment.outOf !== null && assessment.outOf > 0) { + return (assessment.achieved / assessment.outOf) * 100; + } + + // Check results achieved/outOf combination + if (assessment.results?.achieved !== undefined && assessment.results?.outOf !== undefined && + assessment.results.achieved !== null && assessment.results.outOf !== null && assessment.results.outOf > 0) { + return (assessment.results.achieved / assessment.results.outOf) * 100; + } + + return null; +} \ No newline at end of file diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 00b692af..1c06d785 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -8,6 +8,7 @@ import animatedBackgroundPlugin from "./built-in/animatedBackground"; import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import globalSearchPlugin from "./built-in/globalSearch/src/core"; import profilePicturePlugin from "./built-in/profilePicture"; +import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; //import testPlugin from './built-in/test'; // Initialize plugin manager @@ -21,6 +22,7 @@ pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(timetablePlugin); pluginManager.registerPlugin(globalSearchPlugin); pluginManager.registerPlugin(profilePicturePlugin); +pluginManager.registerPlugin(assessmentsOverviewPlugin); //pluginManager.registerPlugin(testPlugin); export { init as Monofile } from "./monofile";