feat: assessments overview mark as complete

This commit is contained in:
SethBurkart123
2025-06-07 23:49:28 +10:00
parent 841426d7ec
commit ccb4354b26
5 changed files with 669 additions and 285 deletions
+48 -26
View File
@@ -1,5 +1,13 @@
interface Subject { code: string; programme: number; metaclass: number; title: string; } interface Subject {
interface PrefItem { name: string; value: string; } code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
let cache: { time: number; data: any } | null = null; let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000; const CACHE_MS = 10 * 60 * 1000;
@@ -7,29 +15,31 @@ const student = 69;
async function fetchJSON(url: string, body: any) { async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, { const res = await fetch(`${location.origin}${url}`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return res.json(); return res.json();
} }
async function loadSubjects() { async function loadSubjects() {
const res = await fetchJSON('/seqta/student/load/subjects?', {}); const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload.filter((s: any) => s.active === 1).flatMap((s: any) => s.subjects); return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
} }
async function loadPrefs(student: number) { async function loadPrefs(student: number) {
const res = await fetchJSON('/seqta/student/load/prefs?', { const res = await fetchJSON("/seqta/student/load/prefs?", {
request: 'userPrefs', request: "userPrefs",
asArray: true, asArray: true,
user: student, user: student,
}); });
const colors: Record<string, string> = {}; const colors: Record<string, string> = {};
res.payload.forEach((p: PrefItem) => { res.payload.forEach((p: PrefItem) => {
if (p.name.startsWith('timetable.subject.colour.')) { if (p.name.startsWith("timetable.subject.colour.")) {
const code = p.name.replace('timetable.subject.colour.', ''); const code = p.name.replace("timetable.subject.colour.", "");
colors[code] = p.value; colors[code] = p.value;
} }
}); });
@@ -37,44 +47,56 @@ async function loadPrefs(student: number) {
} }
async function loadUpcoming(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; return res.payload;
} }
async function loadPast(student: number, subjects: Subject[]) { async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {}; const map: Record<number, any> = {};
await Promise.all(subjects.map(async (s) => { await Promise.all(
const res = await fetchJSON('/seqta/student/assessment/list/past?', { subjects.map(async (s) => {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: s.programme, programme: s.programme,
metaclass: s.metaclass, metaclass: s.metaclass,
student, student,
}); });
if (res.payload.tasks) { if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => { map[t.id] = t; }); res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
} }
})); }),
);
return map; return map;
} }
async function loadSubmissions(student: number, assessments: any[]) { async function loadSubmissions(student: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {}; const submissionMap: Record<number, boolean> = {};
// Fetch submission status for each assessment await Promise.all(
await Promise.all(assessments.map(async (assessment) => { assessments.map(async (assessment) => {
try { try {
const res = await fetchJSON('/seqta/student/assessment/submissions/get', { const res = await fetchJSON(
"/seqta/student/assessment/submissions/get",
{
assessment: assessment.id, assessment: assessment.id,
metaclass: assessment.metaclassID, metaclass: assessment.metaclassID,
student, student,
}); },
);
// If there are any submissions, mark as submitted
submissionMap[assessment.id] = res.payload && res.payload.length > 0; submissionMap[assessment.id] = res.payload && res.payload.length > 0;
} catch (error) { } catch (error) {
console.warn(`Failed to fetch submission for assessment ${assessment.id}:`, error); console.warn(
`Failed to fetch submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false; submissionMap[assessment.id] = false;
} }
})); }),
);
return submissionMap; return submissionMap;
} }
@@ -88,17 +110,17 @@ export async function getAssessmentsData() {
]); ]);
const pastMap = await loadPast(student, subjects); const pastMap = await loadPast(student, subjects);
const map: Record<number, any> = {}; const map: Record<number, any> = {};
upcoming.forEach((a: any) => { map[a.id] = { ...a }; }); upcoming.forEach((a: any) => {
map[a.id] = { ...a };
});
Object.values(pastMap).forEach((t: any) => { Object.values(pastMap).forEach((t: any) => {
if (map[t.id]) Object.assign(map[t.id], t); if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t; else map[t.id] = t;
}); });
// Load submission data for all assessments
const allAssessments = Object.values(map); const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments); const submissions = await loadSubmissions(student, allAssessments);
// Add submission status to each assessment
allAssessments.forEach((assessment: any) => { allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false; assessment.submitted = submissions[assessment.id] || false;
}); });
@@ -1,29 +1,35 @@
import type { Plugin } from '../../core/types'; import type { Plugin } from "../../core/types";
import { waitForElm } from '@/seqta/utils/waitForElm'; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from './api'; import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from './ui'; import { renderSkeletonLoader, renderErrorState } from "./ui";
import styles from './styles.css?inline'; import styles from "./styles.css?inline";
import { delay } from '@/seqta/utils/delay'; import { delay } from "@/seqta/utils/delay";
const assessmentsOverviewPlugin: Plugin<{}> = { const assessmentsOverviewPlugin: Plugin<{}> = {
id: 'assessments-overview', id: "assessments-overview",
name: 'Assessments Overview', name: "Assessments Overview",
description: 'Adds an overview option to the assessments page that organizes assessments by status', description:
version: '1.0.0', "Adds an overview option to the assessments page that organizes assessments by status",
version: "1.0.0",
settings: {}, settings: {},
disableToggle: false, disableToggle: false,
styles, styles,
run: async () => { run: async () => {
const menu = (await waitForElm('[data-key="assessments"] > .sub > ul', true, 100, 60)) as HTMLElement; const menu = (await waitForElm(
const gridItem = document.createElement('li'); '[data-key="assessments"] > .sub > ul',
gridItem.className = 'item'; true,
const label = document.createElement('label'); 100,
label.textContent = 'Overview'; 60,
)) as HTMLElement;
const gridItem = document.createElement("li");
gridItem.className = "item";
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label); gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null); menu.insertBefore(gridItem, menu.children[1] || null);
if (window.location.hash.includes('/assessments/overview')) { if (window.location.hash.includes("/assessments/overview")) {
loadGridView(); loadGridView();
} }
@@ -31,39 +37,47 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
e.preventDefault(); e.preventDefault();
loadGridView(); loadGridView();
}; };
gridItem.addEventListener('click', clickHandler); gridItem.addEventListener("click", clickHandler);
async function loadGridView() { async function loadGridView() {
await delay(1); await delay(1);
window.history.pushState({}, '', '/#?page=/assessments/overview'); window.history.pushState({}, "", "/#?page=/assessments/overview");
const main = document.getElementById('main'); document.title = "Overview ― SEQTA Learn";
const main = document.getElementById("main");
if (!main) return; if (!main) return;
// Update navigation state document
document.querySelectorAll('[data-key="assessments"] .item').forEach(item => { .querySelectorAll('[data-key="assessments"] .item')
item.classList.remove('active'); .forEach((item) => {
item.classList.remove("active");
}); });
gridItem.classList.add('active'); gridItem.classList.add("active");
document.querySelector('[data-key="assessments"]')?.classList.add('active'); document
.querySelector('[data-key="assessments"]')
?.classList.add("active");
// Clear main content and add container
main.innerHTML = '<div id="grid-view-container"></div>'; main.innerHTML = '<div id="grid-view-container"></div>';
const container = document.getElementById('grid-view-container') as HTMLElement; const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
renderSkeletonLoader(container); renderSkeletonLoader(container);
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
const { renderGrid } = await import('./ui'); const { renderGrid } = await import("./ui");
renderGrid(container, data); renderGrid(container, data);
} catch (err) { } catch (err) {
console.error('Failed to load assessments:', err); console.error("Failed to load assessments:", err);
renderErrorState(container, err instanceof Error ? err.message : 'Unknown error'); renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
} }
} }
return () => { return () => {
gridItem.removeEventListener('click', clickHandler); gridItem.removeEventListener("click", clickHandler);
gridItem.remove(); gridItem.remove();
}; };
}, },
@@ -224,12 +224,125 @@
background: var(--subject-color, #d41e3a); 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 { .assessment-title {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
line-height: 1.4; line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
} }
.dark .assessment-title { .dark .assessment-title {
+363 -136
View File
@@ -1,40 +1,53 @@
import { determineStatus, formatDate, getGradeValue } from './utils'; import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from '@/seqta/utils/listeners/SettingsState'; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
function percentageToLetter(percentage: number): string { function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = { const letterMap: Record<number, string> = {
100: 'A+', 95: 'A', 90: 'A-', 85: 'B+', 80: 'B', 75: 'B-', 100: "A+",
70: 'C+', 65: 'C', 60: 'C-', 55: 'D+', 50: 'D', 45: 'D-', 95: "A",
40: 'E+', 35: 'E', 30: 'E-', 0: 'F' 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; const rounded = Math.ceil(percentage / 5) * 5;
return letterMap[rounded] || 'F'; return letterMap[rounded] || "F";
} }
interface FilterOptions { interface FilterOptions {
subject: string; subject: string;
sortBy: 'due' | 'grade' | 'subject' | 'title'; sortBy: "due" | "grade" | "subject" | "title";
} }
let currentFilters: FilterOptions = { let currentFilters: FilterOptions = {
subject: 'all', subject: "all",
sortBy: 'due' sortBy: "due",
}; };
export function renderGrid(container: HTMLElement, data: any) { export function renderGrid(container: HTMLElement, data: any) {
container.innerHTML = ''; container.innerHTML = "";
container.className = ''; container.className = "";
container.id = 'grid-view-container'; container.id = "grid-view-container";
const header = document.createElement('div'); const header = document.createElement("div");
header.className = 'grid-view-header'; header.className = "grid-view-header";
header.innerHTML = /* html */` header.innerHTML = `
<h1 class="grid-view-title">Assessments</h1> <h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters"> <div class="grid-view-filters">
<select class="filter-select" id="subject-filter"> <select class="filter-select" id="subject-filter">
<option value="all">All Subjects</option> <option value="all">All Subjects</option>
${data.subjects.map((s: any) => `<option value="${s.code}">${s.code} - ${s.title}</option>`).join('')} ${data.subjects.map((s: any) => `<option value="${s.code}">${s.code} - ${s.title}</option>`).join("")}
</select> </select>
<select class="filter-select" id="sort-filter"> <select class="filter-select" id="sort-filter">
<option value="due">Sort by Due Date</option> <option value="due">Sort by Due Date</option>
@@ -47,48 +60,51 @@ export function renderGrid(container: HTMLElement, data: any) {
container.appendChild(header); container.appendChild(header);
const subjectFilter = header.querySelector('#subject-filter') as HTMLSelectElement; const subjectFilter = header.querySelector(
const sortFilter = header.querySelector('#sort-filter') as HTMLSelectElement; "#subject-filter",
) as HTMLSelectElement;
const sortFilter = header.querySelector("#sort-filter") as HTMLSelectElement;
subjectFilter.addEventListener('change', () => { subjectFilter.addEventListener("change", () => {
currentFilters.subject = subjectFilter.value; currentFilters.subject = subjectFilter.value;
renderAssessments(); renderAssessments();
}); });
sortFilter.addEventListener('change', () => { sortFilter.addEventListener("change", () => {
currentFilters.sortBy = sortFilter.value as any; currentFilters.sortBy = sortFilter.value as any;
renderAssessments(); renderAssessments();
}); });
const mainContent = document.createElement('div'); const mainContent = document.createElement("div");
mainContent.id = 'main-grid-content'; mainContent.id = "main-grid-content";
container.appendChild(mainContent); container.appendChild(mainContent);
function renderAssessments() { function renderAssessments() {
const contentArea = container.querySelector('#main-grid-content') as HTMLElement; const contentArea = container.querySelector(
contentArea.innerHTML = ''; "#main-grid-content",
) as HTMLElement;
contentArea.innerHTML = "";
// Filter assessments by subject
let filteredAssessments = data.assessments.filter((a: any) => { 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; return subjectMatch;
}); });
// Sort assessments
filteredAssessments.sort((a: any, b: any) => { filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) { switch (currentFilters.sortBy) {
case 'due': case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime(); return new Date(a.due).getTime() - new Date(b.due).getTime();
case 'grade': case "grade":
const gradeA = getGradeValue(a); const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b); const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0; if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1; if (gradeA === null) return 1;
if (gradeB === null) return -1; if (gradeB === null) return -1;
return gradeB - gradeA; return gradeB - gradeA;
case 'subject': case "subject":
return a.code.localeCompare(b.code); return a.code.localeCompare(b.code);
case 'title': case "title":
return a.title.localeCompare(b.title); return a.title.localeCompare(b.title);
default: default:
return 0; return 0;
@@ -108,74 +124,77 @@ export function renderGrid(container: HTMLElement, data: any) {
renderKanbanBoard(contentArea, filteredAssessments, data); renderKanbanBoard(contentArea, filteredAssessments, data);
} }
function renderKanbanBoard(container: HTMLElement, assessments: any[], data: any) { function renderKanbanBoard(
// Group assessments by status container: HTMLElement,
assessments: any[],
data: any,
) {
const statusGroups = { const statusGroups = {
'UPCOMING': [] as any[], UPCOMING: [] as any[],
'DUE_SOON': [] as any[], DUE_SOON: [] as any[],
'OVERDUE': [] as any[], OVERDUE: [] as any[],
'SUBMITTED': [] as any[], SUBMITTED: [] as any[],
'MARKS_RELEASED': [] as any[] MARKS_RELEASED: [] as any[],
}; };
assessments.forEach(assessment => { assessments.forEach((assessment) => {
const status = determineStatus(assessment); const status = determineStatus(assessment);
if (statusGroups[status as keyof typeof statusGroups]) { if (statusGroups[status as keyof typeof statusGroups]) {
statusGroups[status as keyof typeof statusGroups].push(assessment); statusGroups[status as keyof typeof statusGroups].push(assessment);
} }
}); });
const board = document.createElement('div'); const board = document.createElement("div");
board.className = 'kanban-board'; board.className = "kanban-board";
const columns = [ const columns = [
{ {
key: 'UPCOMING', key: "UPCOMING",
title: 'Upcoming', title: "Upcoming",
className: 'column-upcoming', className: "column-upcoming",
icon: '📅' icon: "📅",
}, },
{ {
key: 'DUE_SOON', key: "DUE_SOON",
title: 'Due Soon', title: "Due Soon",
className: 'column-due-soon', className: "column-due-soon",
icon: '⏰' icon: "⏰",
}, },
{ {
key: 'OVERDUE', key: "OVERDUE",
title: 'Overdue', title: "Overdue",
className: 'column-overdue', className: "column-overdue",
icon: '🚨' icon: "🚨",
}, },
{ {
key: 'SUBMITTED', key: "SUBMITTED",
title: 'Submitted', title: "Submitted",
className: 'column-submitted', className: "column-submitted",
icon: '📝' icon: "📝",
}, },
{ {
key: 'MARKS_RELEASED', key: "MARKS_RELEASED",
title: 'Marked', title: "Marked",
className: 'column-marked', className: "column-marked",
icon: '✅' icon: "✅",
} },
]; ];
columns.forEach(column => { columns.forEach((column) => {
const assessmentList = statusGroups[column.key as keyof typeof statusGroups]; const assessmentList =
statusGroups[column.key as keyof typeof statusGroups];
// Skip SUBMITTED column if it's empty if (column.key === "SUBMITTED" && assessmentList.length === 0) {
if (column.key === 'SUBMITTED' && assessmentList.length === 0) {
return; return;
} }
const columnParentEl = document.createElement('div'); const columnParentEl = document.createElement("div");
columnParentEl.className = 'kanban-column-parent'; columnParentEl.className = "kanban-column-parent";
const columnEl = document.createElement('div'); const columnEl = document.createElement("div");
columnEl.className = `kanban-column ${column.className}`; columnEl.className = `kanban-column ${column.className}`;
columnEl.innerHTML = /* html */` columnEl.innerHTML = `
<div class="column-header"> <div class="column-header">
<div class="column-title"> <div class="column-title">
${column.icon} ${column.title} ${column.icon} ${column.title}
@@ -185,18 +204,25 @@ export function renderGrid(container: HTMLElement, data: any) {
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div> <div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
`; `;
const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement; const cardsContainer = columnEl.querySelector(
`#${column.key.toLowerCase()}-cards`,
) as HTMLElement;
if (assessmentList.length === 0) { if (assessmentList.length === 0) {
cardsContainer.innerHTML = /* html */` cardsContainer.innerHTML = `
<div class="empty-column"> <div class="empty-column">
<div class="empty-icon">${column.icon}</div> <div class="empty-icon">${column.icon}</div>
<p>No ${column.title.toLowerCase()} assessments</p> <p>No ${column.title.toLowerCase()} assessments</p>
</div> </div>
`; `;
} else { } else {
assessmentList.forEach(assessment => { assessmentList.forEach((assessment) => {
cardsContainer.appendChild(createKanbanCard(assessment, data.colors[assessment.code] || '#6366f1')); cardsContainer.appendChild(
createKanbanCard(
assessment,
data.colors[assessment.code] || "#6366f1",
),
);
}); });
} }
@@ -211,34 +237,79 @@ export function renderGrid(container: HTMLElement, data: any) {
const status = determineStatus(assessment); const status = determineStatus(assessment);
const dueDateClass = getDueDateClass(assessment); const dueDateClass = getDueDateClass(assessment);
const card = document.createElement('div'); const completedKey = "betterseqta-completed-assessments";
card.className = 'assessment-card'; 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.subject = assessment.code;
card.dataset.status = status; card.dataset.status = status;
card.style.setProperty('--subject-color', color); card.style.setProperty("--subject-color", color);
card.innerHTML = ` card.innerHTML = `
<div class="card-labels"> <div class="card-labels">
<span class="card-label label-subject">${assessment.code}</span> <span class="card-label label-subject">${assessment.code}</span>
${assessment.submitted ? '<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>' : ''} ${assessment.submitted ? '<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>' : ""}
${isManuallyCompleted && status === "MARKS_RELEASED" && !assessment.results ? '<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>' : ""}
</div> </div>
${
status !== "MARKS_RELEASED"
? `
<div class="card-menu">
<button class="menu-button" data-assessment-id="${assessment.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: none;">
<button class="menu-item mark-completed">Mark as Completed</button>
</div>
</div>
`
: isManuallyCompleted
? `
<div class="card-menu">
<button class="menu-button" data-assessment-id="${assessment.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: none;">
<button class="menu-item mark-not-completed">Mark as Not Complete</button>
</div>
</div>
`
: ""
}
<h3 class="assessment-title">${assessment.title}</h3> <h3 class="assessment-title">${assessment.title}</h3>
${!assessment.results ? ` ${
!assessment.results && !isManuallyCompleted
? `
<div class="assessment-meta"> <div class="assessment-meta">
<div class="due-date ${dueDateClass}"> <div class="due-date ${dueDateClass}">
📅 ${formatDate(assessment.due, assessment.submitted)} 📅 ${formatDate(assessment.due, assessment.submitted)}
</div> </div>
</div> </div>
` : ''} `
${assessment.results : ""
? /* html */` }
${
assessment.results
? `
<div class="card-footer"> <div class="card-footer">
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: ${color}"> <div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: ${color}">
<div style="width: ${assessment.results.percentage}%" class="Thermoscore__fill___ojxDI"> <div style="width: ${assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
<div title="${assessment.results.percentage}%" class="Thermoscore__text___XSR_M"> <div title="${assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
${(() => { ${(() => {
const allSettings = settingsState.getAll() as unknown as any; const allSettings = settingsState.getAll() as unknown as any;
const letterGradeSetting = allSettings['plugin.assessments-average.settings']?.lettergrade; const letterGradeSetting =
allSettings["plugin.assessments-average.settings"]
?.lettergrade;
return letterGradeSetting return letterGradeSetting
? percentageToLetter(assessment.results.percentage) ? percentageToLetter(assessment.results.percentage)
: `${assessment.results.percentage}%`; : `${assessment.results.percentage}%`;
@@ -247,44 +318,192 @@ export function renderGrid(container: HTMLElement, data: any) {
</div> </div>
</div> </div>
</div> </div>
` : '' `
: ""
} }
`; `;
card.addEventListener('click', () => { card.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".card-menu")) {
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`; window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}); });
if (status !== "MARKS_RELEASED" || isManuallyCompleted) {
const menuButton = card.querySelector(
".menu-button",
) as HTMLButtonElement;
const menuDropdown = card.querySelector(".menu-dropdown") as HTMLElement;
const markCompletedBtn = card.querySelector(
".mark-completed",
) as HTMLButtonElement;
const markNotCompletedBtn = card.querySelector(
".mark-not-completed",
) as HTMLButtonElement;
menuButton?.addEventListener("click", (e) => {
e.stopPropagation();
document.querySelectorAll(".menu-dropdown").forEach((dropdown) => {
if (dropdown !== menuDropdown) {
(dropdown as HTMLElement).style.display = "none";
}
});
menuDropdown.style.display =
menuDropdown.style.display === "none" ? "block" : "none";
});
markCompletedBtn?.addEventListener("click", (e) => {
e.stopPropagation();
markAssessmentCompleted(assessment);
menuDropdown.style.display = "none";
});
markNotCompletedBtn?.addEventListener("click", (e) => {
e.stopPropagation();
unmarkAssessmentCompleted(assessment);
menuDropdown.style.display = "none";
});
document.addEventListener("click", (e) => {
if (!card.contains(e.target as Node)) {
menuDropdown.style.display = "none";
}
});
}
return card; return card;
} }
function markAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (!completed.includes(assessment.id)) {
completed.push(assessment.id);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessmentCard(assessment);
}
}
function unmarkAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
const index = completed.indexOf(assessment.id);
if (index > -1) {
completed.splice(index, 1);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessmentCard(assessment);
}
}
function updateAssessmentCard(assessment: any) {
const existingCard = document
.querySelector(`[data-assessment-id="${assessment.id}"]`)
?.closest(".assessment-card") as HTMLElement;
if (!existingCard) return;
const newStatus = determineStatus(assessment);
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
const isManuallyCompleted = completed.includes(assessment.id);
const currentColumn = existingCard.closest(".column-cards") as HTMLElement;
const currentColumnId = currentColumn?.id;
const targetColumnId = `${newStatus.toLowerCase()}-cards`;
if (currentColumnId !== targetColumnId) {
const targetColumn = document.getElementById(targetColumnId);
if (targetColumn) {
existingCard.remove();
const newCard = createKanbanCard(
assessment,
data.colors[assessment.code] || "#6366f1",
);
targetColumn.appendChild(newCard);
updateColumnCounts();
const emptyState = targetColumn.querySelector(".empty-column");
if (emptyState) {
emptyState.remove();
}
if (currentColumn && currentColumn.children.length === 0) {
const columnKey = currentColumnId
?.replace("-cards", "")
.toUpperCase();
const columnInfo = getColumnInfo(columnKey);
if (columnInfo) {
currentColumn.innerHTML = `
<div class="empty-column">
<div class="empty-icon">${columnInfo.icon}</div>
<p>No ${columnInfo.title.toLowerCase()} assessments</p>
</div>
`;
}
}
}
} else {
const newCard = createKanbanCard(
assessment,
data.colors[assessment.code] || "#6366f1",
);
existingCard.replaceWith(newCard);
}
}
function updateColumnCounts() {
document.querySelectorAll(".column-count").forEach((countEl) => {
const column = countEl.closest(".kanban-column");
const cardsContainer = column?.querySelector(".column-cards");
const cardCount =
cardsContainer?.querySelectorAll(".assessment-card").length || 0;
countEl.textContent = cardCount.toString();
});
}
function getColumnInfo(columnKey: string | undefined) {
const columns = {
UPCOMING: { title: "Upcoming", icon: "📅" },
DUE_SOON: { title: "Due Soon", icon: "⏰" },
OVERDUE: { title: "Overdue", icon: "🚨" },
SUBMITTED: { title: "Submitted", icon: "📝" },
MARKS_RELEASED: { title: "Marked", icon: "✅" },
};
return columnKey ? columns[columnKey as keyof typeof columns] : null;
}
function getDueDateClass(assessment: any): string { function getDueDateClass(assessment: any): string {
const status = determineStatus(assessment); const status = determineStatus(assessment);
switch (status) { switch (status) {
case 'OVERDUE': case "OVERDUE":
return 'overdue'; return "overdue";
case 'DUE_SOON': case "DUE_SOON":
return 'due-soon'; return "due-soon";
case 'UPCOMING': case "UPCOMING":
return 'upcoming'; return "upcoming";
default: default:
return ''; return "";
} }
} }
// Initial render
renderAssessments(); renderAssessments();
} }
export function renderSkeletonLoader(container: HTMLElement) { export function renderSkeletonLoader(container: HTMLElement) {
container.innerHTML = ''; container.innerHTML = "";
container.className = ''; container.className = "";
container.id = 'grid-view-container'; container.id = "grid-view-container";
// Create header with disabled filters const header = document.createElement("div");
const header = document.createElement('div'); header.className = "grid-view-header";
header.className = 'grid-view-header';
header.innerHTML = ` header.innerHTML = `
<h1 class="grid-view-title">Assessments</h1> <h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters"> <div class="grid-view-filters">
@@ -299,52 +518,52 @@ export function renderSkeletonLoader(container: HTMLElement) {
container.appendChild(header); container.appendChild(header);
const mainContent = document.createElement('div'); const mainContent = document.createElement("div");
mainContent.id = 'main-grid-content'; mainContent.id = "main-grid-content";
container.appendChild(mainContent); container.appendChild(mainContent);
const columns = [ const columns = [
{ {
key: 'UPCOMING', key: "UPCOMING",
title: 'Upcoming', title: "Upcoming",
className: 'column-upcoming', className: "column-upcoming",
icon: '📅', icon: "📅",
skeletonCount: 3 skeletonCount: 3,
}, },
{ {
key: 'DUE_SOON', key: "DUE_SOON",
title: 'Due Soon', title: "Due Soon",
className: 'column-due-soon', className: "column-due-soon",
icon: '⏰', icon: "⏰",
skeletonCount: 2 skeletonCount: 2,
}, },
{ {
key: 'OVERDUE', key: "OVERDUE",
title: 'Overdue', title: "Overdue",
className: 'column-overdue', className: "column-overdue",
icon: '🚨', icon: "🚨",
skeletonCount: 1 skeletonCount: 1,
}, },
{ {
key: 'MARKS_RELEASED', key: "MARKS_RELEASED",
title: 'Marked', title: "Marked",
className: 'column-marked', className: "column-marked",
icon: '✅', icon: "✅",
skeletonCount: 4 skeletonCount: 4,
} },
]; ];
const board = document.createElement('div'); const board = document.createElement("div");
board.className = 'kanban-board'; board.className = "kanban-board";
columns.forEach(column => { columns.forEach((column) => {
const columnParentEl = document.createElement('div'); const columnParentEl = document.createElement("div");
columnParentEl.className = 'kanban-column-parent'; columnParentEl.className = "kanban-column-parent";
const columnEl = document.createElement('div'); const columnEl = document.createElement("div");
columnEl.className = `kanban-column ${column.className}`; columnEl.className = `kanban-column ${column.className}`;
columnEl.innerHTML = /* html */` columnEl.innerHTML = `
<div class="column-header"> <div class="column-header">
<div class="column-title"> <div class="column-title">
${column.icon} ${column.title} ${column.icon} ${column.title}
@@ -354,10 +573,14 @@ export function renderSkeletonLoader(container: HTMLElement) {
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div> <div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
`; `;
const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement; const cardsContainer = columnEl.querySelector(
`#${column.key.toLowerCase()}-cards`,
) as HTMLElement;
for (let i = 0; i < column.skeletonCount; i++) { for (let i = 0; i < column.skeletonCount; i++) {
cardsContainer.appendChild(createSkeletonCard(column.key === 'MARKS_RELEASED')); cardsContainer.appendChild(
createSkeletonCard(column.key === "MARKS_RELEASED"),
);
} }
columnParentEl.appendChild(columnEl); columnParentEl.appendChild(columnEl);
@@ -368,19 +591,23 @@ export function renderSkeletonLoader(container: HTMLElement) {
} }
function createSkeletonCard(footer: boolean = false): HTMLElement { function createSkeletonCard(footer: boolean = false): HTMLElement {
const card = document.createElement('div'); const card = document.createElement("div");
card.className = 'assessment-card'; card.className = "assessment-card";
card.innerHTML = ` card.innerHTML = `
<div class="skeleton-element skeleton-label"></div> <div class="skeleton-element skeleton-label"></div>
<div class="skeleton-element skeleton-title"></div> <div class="skeleton-element skeleton-title"></div>
<div class="skeleton-element skeleton-title-line2"></div> <div class="skeleton-element skeleton-title-line2"></div>
<div class="skeleton-element skeleton-meta"></div> <div class="skeleton-element skeleton-meta"></div>
${footer ? /* html */` ${
footer
? `
<div class="skeleton-footer"> <div class="skeleton-footer">
<div class="skeleton-element" style="height: 16px; width: 100%;"></div> <div class="skeleton-element" style="height: 16px; width: 100%;"></div>
</div> </div>
` : ''} `
: ""
}
`; `;
return card; return card;
@@ -391,7 +618,7 @@ export function renderLoadingState(container: HTMLElement) {
} }
export function renderErrorState(container: HTMLElement, error: string) { export function renderErrorState(container: HTMLElement, error: string) {
container.innerHTML = /* html */` container.innerHTML = `
<div class="error-container"> <div class="error-container">
<p class="error-text">Failed to load assessments</p> <p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">${error}</p> <p style="color: #94a3b8; font-size: 0.875rem;">${error}</p>
@@ -4,88 +4,96 @@ export function formatDate(dateStr: string, submitted?: boolean): string {
const diffTime = d.getTime() - now.getTime(); const diffTime = d.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// If it's overdue but don't show overdue text for submitted assessments
if (diffDays < 0 && !submitted) { if (diffDays < 0 && !submitted) {
const overdueDays = Math.abs(diffDays); const overdueDays = Math.abs(diffDays);
if (overdueDays === 1) return '1 day overdue'; if (overdueDays === 1) return "1 day overdue";
return `${overdueDays} days overdue`; return `${overdueDays} days overdue`;
} }
// If it's today if (diffDays === 0) return "Today";
if (diffDays === 0) return 'Today';
// If it's tomorrow if (diffDays === 1) return "Tomorrow";
if (diffDays === 1) return 'Tomorrow';
// If it's within a week
if (diffDays <= 7) { if (diffDays <= 7) {
const weekdayName = d.toLocaleDateString(undefined, { weekday: 'long' }); const weekdayName = d.toLocaleDateString(undefined, { weekday: "long" });
// If it's in the past, add "Last" prefix
return diffDays < 0 ? `Last ${weekdayName}` : weekdayName; return diffDays < 0 ? `Last ${weekdayName}` : weekdayName;
} }
// Otherwise show full date
return d.toLocaleDateString(undefined, { return d.toLocaleDateString(undefined, {
weekday: 'short', weekday: "short",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
}); });
} }
export function determineStatus(item: any): string { export function determineStatus(item: any): string {
// Check if marks are released or if there's a grade if (
if (item.status === 'MARKS_RELEASED' || item.grade || item.status === "MARKS_RELEASED" ||
item.grade ||
(item.percentage !== undefined && item.percentage !== null) || (item.percentage !== undefined && item.percentage !== null) ||
(item.achieved !== undefined && item.achieved !== null)) { (item.achieved !== undefined && item.achieved !== null)
return 'MARKS_RELEASED'; ) {
return "MARKS_RELEASED";
}
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
} }
// Check if submitted (awaiting marking)
if (item.submitted) { if (item.submitted) {
return 'SUBMITTED'; return "SUBMITTED";
} }
const now = new Date(); const now = new Date();
const due = new Date(item.due); const due = new Date(item.due);
// Calculate the difference in days (consistent with formatDate)
const diffTime = due.getTime() - now.getTime(); const diffTime = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Check if overdue (past due date, but not including today)
if (diffDays < 0) { if (diffDays < 0) {
return 'OVERDUE'; return "OVERDUE";
} }
// Check if due soon (today through 7 days from now)
if (diffDays <= 7) { if (diffDays <= 7) {
return 'DUE_SOON'; return "DUE_SOON";
} }
return 'UPCOMING'; return "UPCOMING";
} }
export function getGradeValue(assessment: any): number | null { export function getGradeValue(assessment: any): number | null {
// Check results.percentage first (most common for graded assessments) if (
if (assessment.results?.percentage !== undefined && assessment.results.percentage !== null) { assessment.results?.percentage !== undefined &&
assessment.results.percentage !== null
) {
return assessment.results.percentage; return assessment.results.percentage;
} }
// Check direct percentage property
if (assessment.percentage !== undefined && assessment.percentage !== null) { if (assessment.percentage !== undefined && assessment.percentage !== null) {
return assessment.percentage; return assessment.percentage;
} }
// Check achieved/outOf combination if (
if (assessment.achieved !== undefined && assessment.outOf !== undefined && assessment.achieved !== undefined &&
assessment.achieved !== null && assessment.outOf !== null && assessment.outOf > 0) { assessment.outOf !== undefined &&
assessment.achieved !== null &&
assessment.outOf !== null &&
assessment.outOf > 0
) {
return (assessment.achieved / assessment.outOf) * 100; return (assessment.achieved / assessment.outOf) * 100;
} }
// Check results achieved/outOf combination if (
if (assessment.results?.achieved !== undefined && assessment.results?.outOf !== undefined && assessment.results?.achieved !== undefined &&
assessment.results.achieved !== null && assessment.results.outOf !== null && assessment.results.outOf > 0) { 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 (assessment.results.achieved / assessment.results.outOf) * 100;
} }