From 7d89733f96b61081ae25cedd2ad2cba6ee658041 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 15 Mar 2026 10:28:55 +1030 Subject: [PATCH] feat: add more sorting options to kaban view --- .../AssessmentsOverview.svelte | 312 +++++++++++++----- .../built-in/assessmentsOverview/api.ts | 32 +- .../built-in/assessmentsOverview/styles.css | 143 ++++++++ src/seqta/ui/dev/hideSensitiveContent.ts | 6 +- 4 files changed, 404 insertions(+), 89 deletions(-) diff --git a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte index 56c44bcf..821f4b2e 100644 --- a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte +++ b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte @@ -7,9 +7,11 @@ interface FilterOptions { subject: string; - sortBy: "due" | "grade" | "subject" | "title"; + sortBy: "due" | "grade" | "subject" | "title" | "year"; } + const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments"; + function percentageToLetter(percentage: number): string { const letterMap: Record = { 100: "A+", @@ -41,48 +43,108 @@ let filteredAssessments: any[] = []; let statusGroups: Record = {}; + let columns: { key: string; title: string; className: string; icon: string }[] = []; + + function getAssessmentYear(a: any): number { + const dateStr = a.due || a.date || a.dueDate || a.created; + return dateStr ? new Date(dateStr).getFullYear() : 0; + } + + function getAssessmentType(a: any): string { + return (a.type || a.assessmentType || a.taskType || "Other").toString(); + } + + function getAssessmentGrade(a: any): string { + const val = getGradeValue(a); + if (val === null) return "No grade"; + return percentageToLetter(val); + } + + function getGroupKey(assessment: any): string { + switch (currentFilters.sortBy) { + case "due": + return determineStatus(assessment); + case "year": + return String(getAssessmentYear(assessment) || "Unknown"); + case "subject": + return assessment.code || "Unknown"; + case "grade": + return getAssessmentGrade(assessment); + case "title": + const first = (assessment.title || "?")[0].toUpperCase(); + return /[A-Z0-9]/.test(first) ? first : "#"; + default: + return determineStatus(assessment); + } + } + + function sortCompare(a: any, b: any): number { + return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime(); + } + + const STATUS_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: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" }, + { key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" }, + ]; + + function buildGroupsAndColumns() { + if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] }; + const subjectFilters = settingsState.subjectfilters || {}; + const hiddenAssessmentIds = new Set( + (JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String) + ); + + const filtered = data.assessments.filter((a: any) => { + if (hiddenAssessmentIds.has(String(a.id))) return false; + if (subjectFilters[a.code] === false) return false; + return currentFilters.subject === "all" || a.code === currentFilters.subject; + }); + + const groups: Record = {}; + filtered.forEach((assessment) => { + const key = getGroupKey(assessment); + if (!groups[key]) groups[key] = []; + groups[key].push(assessment); + }); + + Object.keys(groups).forEach((key) => { + groups[key].sort(sortCompare); + }); + + let cols: { key: string; title: string; className: string; icon: string }[]; + if (currentFilters.sortBy === "due") { + cols = STATUS_COLUMNS; + } else { + const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0); + if (currentFilters.sortBy === "year") { + cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" })); + } else if (currentFilters.sortBy === "subject") { + const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []); + cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" })); + } else { + cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" })); + } + } + + return { filteredAssessments: filtered, statusGroups: groups, columns: cols }; + } + + $: if (data) { + const _ = currentFilters.sortBy && currentFilters.subject; + const result = buildGroupsAndColumns(); + filteredAssessments = result.filteredAssessments; + statusGroups = result.statusGroups; + columns = result.columns; + } function updateAssessments() { - filteredAssessments = data.assessments.filter((a: any) => { - const subjectMatch = - currentFilters.subject === "all" || a.code === currentFilters.subject; - return subjectMatch; - }); - - 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; - } - }); - - statusGroups = { - UPCOMING: [], - DUE_SOON: [], - OVERDUE: [], - SUBMITTED: [], - MARKS_RELEASED: [], - }; - - filteredAssessments.forEach((assessment) => { - const status = determineStatus(assessment); - if (statusGroups[status]) { - statusGroups[status].push(assessment); - } - }); + const result = buildGroupsAndColumns(); + filteredAssessments = result.filteredAssessments; + statusGroups = result.statusGroups; + columns = result.columns; } function getDueDateClass(assessment: any): string { @@ -123,6 +185,56 @@ } } + function hideAssessment(assessment: any) { + const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]"); + const id = String(assessment.id); + if (!hidden.includes(id)) { + hidden.push(id); + localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden)); + visibilityRefresh++; + closeAllMenus(); + updateAssessments(); + } + } + + function hideSubject(subjectCode: string) { + const filters = { ...(settingsState.subjectfilters || {}) }; + filters[subjectCode] = false; + settingsState.subjectfilters = filters; + closeAllMenus(); + updateAssessments(); + } + + function unhideSubject(subjectCode: string) { + const filters = { ...(settingsState.subjectfilters || {}) }; + filters[subjectCode] = true; + settingsState.subjectfilters = filters; + updateAssessments(); + } + + function unhideAssessment(assessmentId: string) { + const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]"); + const idStr = String(assessmentId); + const filtered = hidden.filter((id: string) => id !== idStr); + localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered)); + visibilityRefresh++; + updateAssessments(); + } + + function initSubjectFilters() { + const filters = settingsState.subjectfilters || {}; + let updated = false; + data.subjects.forEach((s: any) => { + if (!Object.prototype.hasOwnProperty.call(filters, s.code)) { + filters[s.code] = true; + updated = true; + } + }); + if (updated) { + settingsState.subjectfilters = filters; + } + } + function checkForCelebration() { const overdueCount = statusGroups.OVERDUE?.length || 0; const dueSoonCount = statusGroups.DUE_SOON?.length || 0; @@ -201,6 +313,20 @@ } let openMenuId: string | null = null; + let showVisibilityPanel = false; + let visibilityRefresh = 0; + + $: hiddenSubjects = data?.subjects?.filter( + (s: any) => (settingsState.subjectfilters || {})[s.code] === false + ) || []; + $: hiddenAssessmentIds = (() => { + visibilityRefresh; // Dependency for reactivity + return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String)); + })(); + $: hiddenAssessmentsWithInfo = data?.assessments?.filter( + (a: any) => hiddenAssessmentIds.has(String(a.id)) + ) || []; + $: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0; function toggleMenu(assessmentId: string, event: Event) { event.stopPropagation(); @@ -211,44 +337,13 @@ openMenuId = null; } - $: { - if (data) { - updateAssessments(); - } + $: if (data) { + initSubjectFilters(); + updateAssessments(); + void currentFilters.sortBy; + void currentFilters.subject; } - 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: "SUBMITTED", - title: "Submitted", - className: "column-submitted", - icon: "📝", - }, - { - key: "MARKS_RELEASED", - title: "Marked", - className: "column-marked", - icon: "✅", - }, - ]; @@ -263,15 +358,58 @@ {/each} - + + + + + + {#if hasHiddenItems} + + {/if} + {#if showVisibilityPanel && hasHiddenItems} +
+

Hidden items

+ {#if hiddenSubjects.length > 0} +
+ Subjects: +
+ {#each hiddenSubjects as subject} + + {subject.code} + + + {/each} +
+
+ {/if} + {#if hiddenAssessmentsWithInfo.length > 0} +
+ Assessments: +
+ {#each hiddenAssessmentsWithInfo as assessment} + + {assessment.title} + + + {/each} +
+
+ {/if} +
+ {/if} +
{#if filteredAssessments.length === 0}
@@ -340,6 +478,12 @@ Mark as Not Complete {/if} + +
{/if} @@ -349,7 +493,7 @@ {#if !assessment.results && !isCompleted}
- 📅 {formatDate(assessment.due, assessment.submitted)} + 📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
{/if} @@ -381,4 +525,4 @@ {/if} - \ No newline at end of file + diff --git a/src/plugins/built-in/assessmentsOverview/api.ts b/src/plugins/built-in/assessmentsOverview/api.ts index 264a0805..38df693c 100644 --- a/src/plugins/built-in/assessmentsOverview/api.ts +++ b/src/plugins/built-in/assessmentsOverview/api.ts @@ -56,6 +56,18 @@ async function loadUpcoming(student: number) { return res.payload; } +function normalizeAssessmentDates(t: any, subject: Subject): any { + const normalized = { ...t }; + // Past API may use different date fields - ensure we have 'due' for year filter & display + if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) { + normalized.due = t.date || t.dueDate || t.created || t.submittedDate; + } + if (!normalized.programmeID) normalized.programmeID = subject.programme; + if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass; + if (!normalized.code && t.subject) normalized.code = t.subject; + return normalized; +} + async function loadPast(student: number, subjects: Subject[]) { const map: Record = {}; await Promise.all( @@ -65,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) { metaclass: s.metaclass, student, }); - if (res.payload.tasks) { - res.payload.tasks.forEach((t: any) => { - map[t.id] = t; - }); + const processAssessment = (t: any) => { + if (t && t.id) { + const merged = { + ...t, + programmeID: t.programmeID || t.programme || s.programme, + metaclassID: t.metaclassID || t.metaclass || s.metaclass, + code: t.code || t.subject || s.code, + }; + map[t.id] = normalizeAssessmentDates(merged, s); + } + }; + if (res.payload?.pending && Array.isArray(res.payload.pending)) { + res.payload.pending.forEach(processAssessment); + } + if (res.payload?.tasks && Array.isArray(res.payload.tasks)) { + res.payload.tasks.forEach(processAssessment); } }), ); diff --git a/src/plugins/built-in/assessmentsOverview/styles.css b/src/plugins/built-in/assessmentsOverview/styles.css index a1374145..3ae35863 100644 --- a/src/plugins/built-in/assessmentsOverview/styles.css +++ b/src/plugins/built-in/assessmentsOverview/styles.css @@ -335,6 +335,141 @@ color: #ef4444; } +.menu-item.menu-item-hide { + color: #64748b; +} + +.dark .menu-item.menu-item-hide { + color: var(--text-primary); + opacity: 0.8; +} + +.visibility-toggle { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 8px; + border: 2px solid #e2e8f0; + background: #ffffff; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; +} + +.visibility-toggle:hover { + border-color: #cbd5e1; + color: #1a1a1a; +} + +.visibility-toggle.active { + border-color: #d41e3a; + background: rgba(212, 30, 58, 0.08); + color: #d41e3a; +} + +.dark .visibility-toggle { + background: var(--background-primary); + border-color: var(--background-secondary); + color: var(--text-primary); +} + +.dark .visibility-toggle:hover { + border-color: rgba(255, 255, 255, 0.2); +} + +.dark .visibility-toggle.active { + border-color: #d41e3a; + background: rgba(212, 30, 58, 0.15); + color: #d41e3a; +} + +.visibility-panel { + padding: 1rem 1.25rem; + margin: 0 1rem 1rem; + background: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.dark .visibility-panel { + background: var(--background-secondary); + border-color: rgba(255, 255, 255, 0.1); +} + +.visibility-panel-title { + font-size: 0.875rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 0.75rem; +} + +.dark .visibility-panel-title { + color: var(--text-primary); +} + +.visibility-section { + margin-bottom: 0.5rem; +} + +.visibility-section:last-child { + margin-bottom: 0; +} + +.visibility-label { + font-size: 0.75rem; + font-weight: 500; + color: #64748b; + display: block; + margin-bottom: 0.25rem; +} + +.dark .visibility-label { + color: var(--text-primary); + opacity: 0.7; +} + +.visibility-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.visibility-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + background: #e2e8f0; + border-radius: 6px; + font-size: 0.8125rem; + color: #1a1a1a; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +.dark .visibility-chip { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +.visibility-unhide { + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 4px; + border: none; + background: #d41e3a; + color: white; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.visibility-unhide:hover { + background: #b91c33; +} + .assessment-title { font-size: 0.875rem; font-weight: 600; @@ -455,6 +590,10 @@ background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); } +.column-custom .column-header { + background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%); +} + /* Dark mode column headers */ .dark .column-upcoming .column-header { background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%); @@ -476,6 +615,10 @@ background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%); } +.dark .column-custom .column-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%); +} + /* Subject filter view */ .subject-section { margin-bottom: 2rem; diff --git a/src/seqta/ui/dev/hideSensitiveContent.ts b/src/seqta/ui/dev/hideSensitiveContent.ts index 4d7b6581..a99958a9 100644 --- a/src/seqta/ui/dev/hideSensitiveContent.ts +++ b/src/seqta/ui/dev/hideSensitiveContent.ts @@ -617,12 +617,15 @@ export function getMockAssessmentsData() { { submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue ]; - const assessments = Array.from({ length: 12 }, (_, i) => { + const currentYear = new Date().getFullYear(); + const assessments = Array.from({ length: 14 }, (_, i) => { const subj = subjects[i % subjects.length]; const template = statusTemplates[i % statusTemplates.length]; const due = new Date(); due.setDate(due.getDate() + template.dayOffset()); + if (i >= 10) due.setFullYear(currentYear - 1); + const types = ["Assignment", "Test", "Exam", "Project", "Presentation", "Report"]; const assessment: any = { id: i + 1, title: mockData.assessmentTitles[i % mockData.assessmentTitles.length], @@ -631,6 +634,7 @@ export function getMockAssessmentsData() { metaclassID: subj.metaclass, due: due.toISOString(), submitted: template.submitted, + type: types[i % types.length], }; if (template.score && typeof template.score === 'function') {