mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
fix: fix assement overview not choosing actuve subjects and improve styling
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
|
||||
import OverviewIcon from "./OverviewIcon.svelte";
|
||||
import {
|
||||
GROUP_SORT_ICONS,
|
||||
STATUS_COLUMN_ICONS,
|
||||
type OverviewIconName,
|
||||
} from "./icons";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
export let data: any;
|
||||
@@ -50,7 +56,12 @@
|
||||
|
||||
let filteredAssessments: any[] = [];
|
||||
let statusGroups: Record<string, any[]> = {};
|
||||
let columns: { key: string; title: string; className: string; icon: string }[] = [];
|
||||
let columns: {
|
||||
key: string;
|
||||
title: string;
|
||||
className: string;
|
||||
icon: OverviewIconName;
|
||||
}[] = [];
|
||||
|
||||
function getAssessmentYear(a: any): number {
|
||||
const dateStr = a.due || a.date || a.dueDate || a.created;
|
||||
@@ -89,14 +100,23 @@
|
||||
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: "✅" },
|
||||
const STATUS_COLUMNS: {
|
||||
key: string;
|
||||
title: string;
|
||||
className: string;
|
||||
icon: OverviewIconName;
|
||||
}[] = [
|
||||
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days" },
|
||||
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock" },
|
||||
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle" },
|
||||
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "document-check" },
|
||||
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle" },
|
||||
];
|
||||
|
||||
function groupSortIcon(): OverviewIconName {
|
||||
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
|
||||
}
|
||||
|
||||
function buildGroupsAndColumns() {
|
||||
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
|
||||
const subjectFilters = settingsState.subjectfilters || {};
|
||||
@@ -131,18 +151,19 @@
|
||||
groups[key].sort(sortCompare);
|
||||
});
|
||||
|
||||
let cols: { key: string; title: string; className: string; icon: string }[];
|
||||
let cols: { key: string; title: string; className: string; icon: OverviewIconName }[];
|
||||
if (currentFilters.sortBy === "due") {
|
||||
cols = STATUS_COLUMNS;
|
||||
} else {
|
||||
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
|
||||
const sortIcon = groupSortIcon();
|
||||
if (currentFilters.sortBy === "year") {
|
||||
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
|
||||
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
|
||||
} 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: "📚" }));
|
||||
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: sortIcon }));
|
||||
} else {
|
||||
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
|
||||
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,10 +400,13 @@
|
||||
|
||||
<svelte:window on:click={closeAllMenus} />
|
||||
|
||||
<div id="grid-view-container">
|
||||
<div class="grid-view-header">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<div class="bsplus-overview-page">
|
||||
<header class="grid-view-header bsplus-overview-animate">
|
||||
<div class="grid-view-header-text">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<p class="grid-view-subtitle">Track upcoming tasks, submissions, and released marks</p>
|
||||
</div>
|
||||
<div class="grid-view-filters bsplus-overview-toolbar">
|
||||
{#if showStudentFilter}
|
||||
<select class="filter-select" bind:value={currentFilters.student}>
|
||||
<option value="all">All Students</option>
|
||||
@@ -411,14 +435,15 @@
|
||||
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
|
||||
title="Manage hidden subjects and assessments"
|
||||
>
|
||||
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
|
||||
<OverviewIcon name="eye" size={18} />
|
||||
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showVisibilityPanel && hasHiddenItems}
|
||||
<div class="visibility-panel">
|
||||
<div class="visibility-panel bsplus-overview-animate">
|
||||
<h4 class="visibility-panel-title">Hidden items</h4>
|
||||
{#if hiddenSubjects.length > 0}
|
||||
<div class="visibility-section">
|
||||
@@ -449,10 +474,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div id="main-grid-content">
|
||||
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
|
||||
{#if filteredAssessments.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<OverviewIcon name="clipboard-document-list" size={40} class="empty-icon" />
|
||||
<p>No assessments found matching your filters</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -463,9 +488,15 @@
|
||||
<div class="kanban-column {column.className}">
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
{column.icon} {column.title}
|
||||
<span class="column-count">{statusGroups[column.key].length}</span>
|
||||
</div>
|
||||
<span class="column-title-main">
|
||||
<OverviewIcon
|
||||
name={column.icon ?? STATUS_COLUMN_ICONS[column.key] ?? "queue-list"}
|
||||
size={18}
|
||||
/>
|
||||
{column.title}
|
||||
</span>
|
||||
<span class="column-count">{statusGroups[column.key].length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||
{#each statusGroups[column.key] as assessment}
|
||||
@@ -504,11 +535,7 @@
|
||||
on:click={(e) => toggleMenu(assessment.id, e)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<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>
|
||||
<OverviewIcon name="ellipsis-vertical" size={16} />
|
||||
</button>
|
||||
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
|
||||
{#if status !== "MARKS_RELEASED"}
|
||||
@@ -535,7 +562,8 @@
|
||||
{#if !assessment.results && !isCompleted}
|
||||
<div class="assessment-meta">
|
||||
<div class="due-date {dueDateClass}">
|
||||
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
||||
<OverviewIcon name="calendar-days" size={14} />
|
||||
{formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import OverviewIcon from "./OverviewIcon.svelte";
|
||||
|
||||
export let error: string;
|
||||
</script>
|
||||
|
||||
<div class="error-container">
|
||||
<div class="error-container bsplus-overview-animate">
|
||||
<OverviewIcon name="exclamation-circle" size={40} class="error-icon" />
|
||||
<p class="error-text">Failed to load assessments</p>
|
||||
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
|
||||
<p class="error-detail">{error}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { OVERVIEW_ICON_PATHS, type OverviewIconName } from "./icons";
|
||||
|
||||
interface Props {
|
||||
name: OverviewIconName;
|
||||
class?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, class: className = "", size = 20 }: Props = $props();
|
||||
|
||||
const paths = $derived.by(() => {
|
||||
const raw = OVERVIEW_ICON_PATHS[name];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
class="bsplus-overview-icon {className}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each paths as path, index (index)}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={path} />
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -1,7 +1,28 @@
|
||||
<div id="grid-view-container">
|
||||
<div class="grid-view-header">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<script lang="ts">
|
||||
import OverviewIcon from "./OverviewIcon.svelte";
|
||||
import type { OverviewIconName } from "./icons";
|
||||
|
||||
const columns: {
|
||||
key: string;
|
||||
title: string;
|
||||
className: string;
|
||||
icon: OverviewIconName;
|
||||
skeletonCount: number;
|
||||
}[] = [
|
||||
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days", skeletonCount: 3 },
|
||||
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock", skeletonCount: 2 },
|
||||
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle", skeletonCount: 1 },
|
||||
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle", skeletonCount: 4 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="bsplus-overview-page">
|
||||
<header class="grid-view-header bsplus-overview-animate">
|
||||
<div class="grid-view-header-text">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<p class="grid-view-subtitle">Loading your assessment overview…</p>
|
||||
</div>
|
||||
<div class="grid-view-filters bsplus-overview-toolbar">
|
||||
<select class="filter-select" disabled>
|
||||
<option value="all">Loading subjects...</option>
|
||||
</select>
|
||||
@@ -9,17 +30,20 @@
|
||||
<option value="due">Sort by Due Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="main-grid-content">
|
||||
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
|
||||
<div class="kanban-board">
|
||||
{#each columns as column}
|
||||
<div class="kanban-column-parent">
|
||||
<div class="kanban-column {column.className}">
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
{column.icon} {column.title}
|
||||
<span class="column-count">...</span>
|
||||
<span class="column-title-main">
|
||||
<OverviewIcon name={column.icon} size={18} />
|
||||
{column.title}
|
||||
</span>
|
||||
<span class="column-count">…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||
@@ -43,36 +67,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script lang="ts">
|
||||
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,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -1,9 +1,10 @@
|
||||
interface Subject {
|
||||
code: string;
|
||||
programme: number;
|
||||
metaclass: number;
|
||||
title: string;
|
||||
}
|
||||
import {
|
||||
activeSubjectsFromLearnPayload,
|
||||
assessmentBelongsToActiveSubjects,
|
||||
filterAssessmentsForActiveSubjects,
|
||||
type OverviewSubject,
|
||||
} from "./utils";
|
||||
|
||||
interface PrefItem {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -31,11 +32,9 @@ async function fetchJSON(url: string, body: any) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
async function loadSubjects(): Promise<OverviewSubject[]> {
|
||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||
return res.payload
|
||||
.filter((s: any) => s.active === 1)
|
||||
.flatMap((s: any) => s.subjects);
|
||||
return activeSubjectsFromLearnPayload(res.payload);
|
||||
}
|
||||
|
||||
async function loadPrefs(student: number) {
|
||||
@@ -61,7 +60,7 @@ async function loadUpcoming(student: number) {
|
||||
return res.payload;
|
||||
}
|
||||
|
||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
|
||||
const normalized = { ...t };
|
||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||
@@ -72,7 +71,7 @@ function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function loadPast(student: number, subjects: Subject[]) {
|
||||
async function loadPast(student: number, subjects: OverviewSubject[]) {
|
||||
const map: Record<number, any> = {};
|
||||
await Promise.all(
|
||||
subjects.map(async (s) => {
|
||||
@@ -141,14 +140,20 @@ async function getLearnAssessmentsData(studentId: number) {
|
||||
const pastMap = await loadPast(studentId, subjects);
|
||||
const map: Record<number, any> = {};
|
||||
upcoming.forEach((a: any) => {
|
||||
map[a.id] = { ...a };
|
||||
if (assessmentBelongsToActiveSubjects(a, subjects)) {
|
||||
map[a.id] = { ...a };
|
||||
}
|
||||
});
|
||||
Object.values(pastMap).forEach((t: any) => {
|
||||
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
|
||||
if (map[t.id]) Object.assign(map[t.id], t);
|
||||
else map[t.id] = t;
|
||||
});
|
||||
|
||||
const allAssessments = Object.values(map);
|
||||
const allAssessments = filterAssessmentsForActiveSubjects(
|
||||
Object.values(map),
|
||||
subjects,
|
||||
);
|
||||
const submissions = await loadSubmissions(studentId, allAssessments);
|
||||
|
||||
allAssessments.forEach((assessment: any) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||
|
||||
interface Subject {
|
||||
code: string;
|
||||
programme: number;
|
||||
metaclass: number;
|
||||
title: string;
|
||||
}
|
||||
import {
|
||||
activeSubjectsFromEngageChild,
|
||||
assessmentBelongsToActiveSubjects,
|
||||
filterAssessmentsForActiveSubjects,
|
||||
type OverviewSubject,
|
||||
} from "./utils";
|
||||
|
||||
interface PrefItem {
|
||||
name: string;
|
||||
@@ -58,17 +57,8 @@ export async function resolveEngageStudentId(): Promise<number> {
|
||||
throw new Error("Could not resolve Engage student ID");
|
||||
}
|
||||
|
||||
function subjectsFromChild(child: EngageChildPayload): Subject[] {
|
||||
return (child.terms ?? [])
|
||||
.filter((term) => term.active === 1)
|
||||
.flatMap((term) =>
|
||||
(term.subjects ?? []).map((subject) => ({
|
||||
code: subject.code ?? "",
|
||||
programme: subject.programme ?? 0,
|
||||
metaclass: subject.metaclass ?? 0,
|
||||
title: subject.title ?? subject.description ?? subject.code ?? "",
|
||||
})),
|
||||
);
|
||||
function subjectsFromChild(child: EngageChildPayload): OverviewSubject[] {
|
||||
return activeSubjectsFromEngageChild(child);
|
||||
}
|
||||
|
||||
async function loadEngagePrefs(): Promise<Record<string, string>> {
|
||||
@@ -94,7 +84,7 @@ async function loadEngageUpcoming(studentId: number) {
|
||||
return res.payload ?? [];
|
||||
}
|
||||
|
||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
|
||||
const normalized = { ...t };
|
||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||
@@ -105,7 +95,7 @@ function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function loadEngagePast(studentId: number, subjects: Subject[]) {
|
||||
async function loadEngagePast(studentId: number, subjects: OverviewSubject[]) {
|
||||
const map: Record<number, any> = {};
|
||||
|
||||
await Promise.all(
|
||||
@@ -179,14 +169,20 @@ async function loadEngageAssessmentsForStudent(
|
||||
|
||||
const map: Record<number, any> = {};
|
||||
upcoming.forEach((assessment: any) => {
|
||||
map[assessment.id] = { ...assessment };
|
||||
if (assessmentBelongsToActiveSubjects(assessment, subjects)) {
|
||||
map[assessment.id] = { ...assessment };
|
||||
}
|
||||
});
|
||||
Object.values(pastMap).forEach((task: any) => {
|
||||
if (!assessmentBelongsToActiveSubjects(task, subjects)) return;
|
||||
if (map[task.id]) Object.assign(map[task.id], task);
|
||||
else map[task.id] = task;
|
||||
});
|
||||
|
||||
const assessments = Object.values(map).map((assessment) => ({
|
||||
const assessments = filterAssessmentsForActiveSubjects(
|
||||
Object.values(map),
|
||||
subjects,
|
||||
).map((assessment) => ({
|
||||
...assessment,
|
||||
studentId,
|
||||
studentName,
|
||||
@@ -218,7 +214,7 @@ export async function getEngageAssessmentsData() {
|
||||
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
|
||||
]);
|
||||
|
||||
const subjectsMap = new Map<string, Subject>();
|
||||
const subjectsMap = new Map<string, OverviewSubject>();
|
||||
childrenPayload.forEach((child) => {
|
||||
subjectsFromChild(child).forEach((subject) => {
|
||||
if (!subjectsMap.has(subject.code)) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Heroicons v2 outline paths (https://heroicons.com) */
|
||||
export type OverviewIconName =
|
||||
| "calendar-days"
|
||||
| "clock"
|
||||
| "exclamation-triangle"
|
||||
| "document-check"
|
||||
| "check-circle"
|
||||
| "book-open"
|
||||
| "calendar"
|
||||
| "chart-bar"
|
||||
| "queue-list"
|
||||
| "eye"
|
||||
| "clipboard-document-list"
|
||||
| "ellipsis-vertical"
|
||||
| "exclamation-circle";
|
||||
|
||||
export const OVERVIEW_ICON_PATHS: Record<
|
||||
OverviewIconName,
|
||||
string | string[]
|
||||
> = {
|
||||
"calendar-days":
|
||||
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
|
||||
clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
"exclamation-triangle":
|
||||
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z",
|
||||
"document-check":
|
||||
"M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 8.625a2.625 2.625 0 100-5.25 2.625 2.625 0 000 5.25zm0 0l-3 3m3-3l3 3",
|
||||
"check-circle":
|
||||
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
"book-open":
|
||||
"M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25",
|
||||
calendar:
|
||||
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
|
||||
"chart-bar":
|
||||
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z",
|
||||
"queue-list":
|
||||
"M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z",
|
||||
eye: [
|
||||
"M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z",
|
||||
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
],
|
||||
"clipboard-document-list": [
|
||||
"M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 17.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z",
|
||||
"M8.25 6.75V4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V6.75H8.25z",
|
||||
],
|
||||
"ellipsis-vertical":
|
||||
"M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z",
|
||||
"exclamation-circle":
|
||||
"M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
|
||||
};
|
||||
|
||||
export const STATUS_COLUMN_ICONS: Record<string, OverviewIconName> = {
|
||||
UPCOMING: "calendar-days",
|
||||
DUE_SOON: "clock",
|
||||
OVERDUE: "exclamation-triangle",
|
||||
SUBMITTED: "document-check",
|
||||
MARKS_RELEASED: "check-circle",
|
||||
};
|
||||
|
||||
export const GROUP_SORT_ICONS: Record<string, OverviewIconName> = {
|
||||
year: "calendar",
|
||||
subject: "book-open",
|
||||
grade: "chart-bar",
|
||||
title: "queue-list",
|
||||
};
|
||||
@@ -110,7 +110,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
.querySelector('[data-key="assessments"]')
|
||||
?.classList.add("active");
|
||||
|
||||
main.innerHTML = '<div id="grid-view-container"></div>';
|
||||
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||
const container = document.getElementById(
|
||||
"grid-view-container",
|
||||
) as HTMLElement;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,155 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
||||
import ErrorState from "./ErrorState.svelte";
|
||||
import { unmount } from "svelte";
|
||||
|
||||
let currentApp: any = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
type ThemeSettingKey =
|
||||
| "selectedColor"
|
||||
| "DarkMode"
|
||||
| "adaptiveThemeColour"
|
||||
| "adaptiveThemeGradient"
|
||||
| "selectedTheme";
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
|
||||
|
||||
const THEME_CSS_VARS = [
|
||||
"--better-main",
|
||||
"--better-pale",
|
||||
"--better-light",
|
||||
"--text-color",
|
||||
"--background-primary",
|
||||
"--background-secondary",
|
||||
"--text-primary",
|
||||
"--theme-offset-bg",
|
||||
"--better-sub",
|
||||
] as const;
|
||||
|
||||
const ACCENT_CSS_VARS = [
|
||||
"--better-main",
|
||||
"--accent-color-value",
|
||||
"--accentColor",
|
||||
"--colour-betterseqta-blue",
|
||||
] as const;
|
||||
|
||||
function extractSolidColor(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "initial") return null;
|
||||
if (
|
||||
trimmed.startsWith("#") ||
|
||||
trimmed.startsWith("rgb") ||
|
||||
trimmed.startsWith("hsl")
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.includes("gradient")) {
|
||||
const match = trimmed.match(
|
||||
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
|
||||
);
|
||||
return match?.[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePageAccentColor(): string {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
for (const name of ACCENT_CSS_VARS) {
|
||||
const solid = extractSolidColor(computed.getPropertyValue(name));
|
||||
if (solid) return solid;
|
||||
}
|
||||
const fromSettings = settingsState.selectedColor?.trim();
|
||||
if (fromSettings) {
|
||||
const solid = extractSolidColor(fromSettings);
|
||||
if (solid) return solid;
|
||||
}
|
||||
return "#007bff";
|
||||
}
|
||||
|
||||
function syncOverviewTheme(target: HTMLElement) {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
for (const name of THEME_CSS_VARS) {
|
||||
const value = document.documentElement.style.getPropertyValue(name).trim()
|
||||
|| computed.getPropertyValue(name).trim();
|
||||
if (value) target.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
const accent = resolvePageAccentColor();
|
||||
target.style.setProperty("--bsplus-overview-accent", accent);
|
||||
target.style.setProperty("--better-main", accent);
|
||||
target.classList.toggle(
|
||||
"dark",
|
||||
document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
}
|
||||
|
||||
function watchOverviewTheme(root: HTMLElement) {
|
||||
for (const { key, listener } of themeListeners) {
|
||||
settingsState.unregister(key, listener);
|
||||
}
|
||||
themeListeners = [];
|
||||
|
||||
const listener = () => syncOverviewTheme(root);
|
||||
for (const key of [
|
||||
"selectedColor",
|
||||
"DarkMode",
|
||||
"adaptiveThemeColour",
|
||||
"adaptiveThemeGradient",
|
||||
"selectedTheme",
|
||||
] satisfies ThemeSettingKey[]) {
|
||||
settingsState.register(key, listener);
|
||||
themeListeners.push({ key, listener });
|
||||
}
|
||||
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = new MutationObserver(() => syncOverviewTheme(root));
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
}
|
||||
|
||||
function prepareContainer(container: HTMLElement) {
|
||||
container.innerHTML = "";
|
||||
container.className = "bsplus-overview-host";
|
||||
container.classList.add("bsplus-overview-root");
|
||||
syncOverviewTheme(container);
|
||||
watchOverviewTheme(container);
|
||||
}
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
||||
}
|
||||
|
||||
export function renderSkeletonLoader(container: HTMLElement) {
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(SkeletonLoader, container);
|
||||
}
|
||||
|
||||
|
||||
export function renderLoadingState(container: HTMLElement) {
|
||||
renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export function renderErrorState(container: HTMLElement, error: string) {
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(ErrorState, container, { error });
|
||||
}
|
||||
|
||||
export function teardownOverviewUi() {
|
||||
for (const { key, listener } of themeListeners) {
|
||||
settingsState.unregister(key, listener);
|
||||
}
|
||||
themeListeners = [];
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
currentApp = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,115 @@
|
||||
export interface OverviewSubject {
|
||||
code: string;
|
||||
programme: number;
|
||||
metaclass: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function isActiveTermFlag(active: unknown): boolean {
|
||||
return active === 1 || active === true;
|
||||
}
|
||||
|
||||
export function normalizeOverviewSubject(raw: unknown): OverviewSubject | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const subject = raw as Record<string, unknown>;
|
||||
const programme = Number(subject.programme ?? subject.programmeID);
|
||||
const metaclass = Number(subject.metaclass ?? subject.metaclassID);
|
||||
if (!programme || !metaclass || Number.isNaN(programme) || Number.isNaN(metaclass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = String(subject.code ?? subject.subject ?? "").trim();
|
||||
if (!code) return null;
|
||||
|
||||
return {
|
||||
code,
|
||||
programme,
|
||||
metaclass,
|
||||
title: String(subject.title ?? subject.description ?? code),
|
||||
};
|
||||
}
|
||||
|
||||
/** Subjects from the active programme-year folder(s) in `/seqta/student/load/subjects`. */
|
||||
export function activeSubjectsFromLearnPayload(payload: unknown): OverviewSubject[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
|
||||
const subjects: OverviewSubject[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const folder of payload) {
|
||||
if (!folder || typeof folder !== "object") continue;
|
||||
const term = folder as { active?: unknown; subjects?: unknown[] };
|
||||
if (!isActiveTermFlag(term.active) || !Array.isArray(term.subjects)) continue;
|
||||
|
||||
for (const raw of term.subjects) {
|
||||
const subject = normalizeOverviewSubject(raw);
|
||||
if (!subject) continue;
|
||||
const key = `${subject.programme}-${subject.metaclass}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
subjects.push(subject);
|
||||
}
|
||||
}
|
||||
|
||||
return subjects;
|
||||
}
|
||||
|
||||
export function activeSubjectsFromEngageChild(child: {
|
||||
terms?: { active?: number; subjects?: unknown[] }[];
|
||||
}): OverviewSubject[] {
|
||||
const subjects: OverviewSubject[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const term of child.terms ?? []) {
|
||||
if (term.active !== 1) continue;
|
||||
for (const raw of term.subjects ?? []) {
|
||||
const subject = normalizeOverviewSubject(raw);
|
||||
if (!subject) continue;
|
||||
const key = `${subject.programme}-${subject.metaclass}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
subjects.push(subject);
|
||||
}
|
||||
}
|
||||
|
||||
return subjects;
|
||||
}
|
||||
|
||||
export function assessmentBelongsToActiveSubjects(
|
||||
assessment: Record<string, unknown>,
|
||||
activeSubjects: OverviewSubject[],
|
||||
): boolean {
|
||||
if (!activeSubjects.length) return false;
|
||||
|
||||
const programme = Number(
|
||||
assessment.programmeID ?? assessment.programme,
|
||||
);
|
||||
const metaclass = Number(
|
||||
assessment.metaclassID ?? assessment.metaclass,
|
||||
);
|
||||
|
||||
if (programme && metaclass && !Number.isNaN(programme) && !Number.isNaN(metaclass)) {
|
||||
return activeSubjects.some(
|
||||
(subject) =>
|
||||
subject.programme === programme && subject.metaclass === metaclass,
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(assessment.code ?? assessment.subject ?? "").trim();
|
||||
if (!code) return false;
|
||||
return activeSubjects.some((subject) => subject.code === code);
|
||||
}
|
||||
|
||||
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
|
||||
assessments: T[],
|
||||
activeSubjects: OverviewSubject[],
|
||||
): T[] {
|
||||
return assessments.filter((assessment) =>
|
||||
assessmentBelongsToActiveSubjects(assessment, activeSubjects),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, submitted?: boolean): string {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
Reference in New Issue
Block a user