fix: fix assement overview not choosing actuve subjects and improve styling

This commit is contained in:
2026-06-05 09:18:33 +09:30
parent 314c555d87
commit b4a59330c5
11 changed files with 873 additions and 456 deletions
@@ -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">
<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>
<div class="grid-view-filters">
<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,7 +488,13 @@
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<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>
@@ -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">
<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>
<div class="grid-view-filters">
<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>
+18 -13
View File
@@ -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) => {
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) => {
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
+130 -20
View File
@@ -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();