diff --git a/package.json b/package.json index f69f0865..6695659c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@babel/runtime": "^7.26.9", "@bedframe/cli": "^0.1.2", "@crxjs/vite-plugin": "^2.4.0", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/mime-types": "^3.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -83,6 +85,8 @@ "canvas-confetti": "^1.9.3", "codemirror": "^6.0.1", "color": "^5.0.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "dompurify": "^3.2.4", "embeddia": "^1.3.0", "embla-carousel-autoplay": "^8.5.2", @@ -92,6 +96,7 @@ "flexsearch": "^0.8.147", "fuse.js": "^7.1.0", "idb": "^8.0.2", + "layerchart": "2.0.0-next.27", "localforage": "^1.10.0", "lodash": "^4.17.21", "mathjs": "^14.4.0", diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte new file mode 100644 index 00000000..020220bf --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte @@ -0,0 +1,388 @@ + + + + +
+ +
+ +
+ +

Grade trends

+ +

+ + {#if showSubjectTrends} + + Overall and per-subject averages · {getTimeRangeLabel(timeRange)} + + {:else} + + Average grades over time · {getTimeRangeLabel(timeRange)} + + {/if} + +

+ +
+ +
+ + + +
+ + {#if filteredData().length > 0} + + + + + + v.toLocaleDateString("en-US", { + + month: "short", + + day: timeRange === "7d" ? "numeric" : undefined, + + }), + + }, + + yAxis: { + + format: (v: number) => `${v.toFixed(0)}%`, + + }, + + }} + + > + + {#snippet marks({ series, getAreaProps })} + + + + + + + + + + + + + + + + {#each series as s, i (s.key)} + + {@const meta = chartSeries().find((c) => c.key === s.key)} + + {@const isOverall = meta?.isOverall ?? s.key === "average"} + + + + {/each} + + + + {/snippet} + + {#snippet tooltip()} + + + + v.toLocaleDateString("en-US", { + + month: "long", + + day: "numeric", + + year: "numeric", + + })} + + indicator="line" + + /> + + {/snippet} + + + + + + {:else} + +
+ + No grade data for this range + + Complete assessments with released marks to see trends. + +
+ + {/if} + +
+ + + + + +
+ diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte new file mode 100644 index 00000000..c895b633 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte @@ -0,0 +1,408 @@ + + + + +
+ +
+ +
+ +

Grade distribution

+ +

{subtitle()}

+ +
+ +
+ + + +
+ +
+ + + +
+ + {#if totalAssessments > 0 && chartData().length > 0} + + + + formatXTick(d), + + tickMultiline: useLetterScaleLabels(), + + tickLabelProps: useLetterScaleLabels() + + ? { class: "bsplus-bar-tick-label" } + + : undefined, + + }, + + yAxis: { + + label: "Assessments", + + format: (d: number) => (Number.isInteger(d) ? String(d) : ""), + + ticks: 5, + + }, + + }} + + > + + {#snippet tooltip()} + + + + {/snippet} + + + + + + {#if distribution().modeUsed === "letter"} + +

{distribution().scaleLabel}

+ + {/if} + + {:else} + +
+ + No graded assessments + + for {getTimeRangeLabel(timeRange).toLowerCase()} + +
+ + {/if} + +
+ + + + + +
+ diff --git a/src/plugins/built-in/gradeAnalytics/AssessmentTable.svelte b/src/plugins/built-in/gradeAnalytics/AssessmentTable.svelte new file mode 100644 index 00000000..0db5cd33 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/AssessmentTable.svelte @@ -0,0 +1,152 @@ + + +
+
+

Assessment history

+
+ +
+ + + + {#each [ + ["title", "Title"], + ["subject", "Subject"], + ["due", "Due"], + ["status", "Status"], + ["finalGrade", "Grade"], + ] as [col, label]} + + {/each} + + + + {#each pageData as row (row.id)} + + + + + + + + {:else} + + + + {/each} + +
+ +
{row.title}{row.subject} + {new Date(row.due).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + })} + {formatStatus(row.status)} + {#if row.finalGrade !== undefined} + {gradeDisplay(row)} + {:else} + {gradeDisplay(row)} + {/if} +
+ No assessments match your filters +
+
+ + +
diff --git a/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte new file mode 100644 index 00000000..43eab2d3 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte @@ -0,0 +1,436 @@ + + + { + if (!isInsideToolbarDropdown(e)) { + closeToolbarDropdowns(); + } + }} +/> + +
+
+
+

+ Analytics + {#if syncing} + + + Syncing + + {/if} +

+

Track your academic performance and progress over time

+ {#if lastUpdated && analyticsData && analyticsData.length > 0} +

Last updated: {formattedTimestamp()}

+ {/if} +
+ +
+ + {#if error} + + {/if} + + {#if loading} +
+
+
+ {:else if analyticsData && analyticsData.length > 0} +
+
+
Average grade
+
+ {statsAverage !== null ? `${statsAverage}%` : "—"} +
+
+
+
Graded shown
+
{gradedFiltered().length}
+
+
+
Subjects
+
{statsSubjectCount}
+
+
+ +
+
+ Time period +
+ + {#if showTimeRangeDropdown} +
+ {#each TIME_RANGE_OPTIONS as option (option.value)} + {@const selected = timeRange === option.value} + + {/each} +
+ {/if} +
+
+ +
+ Subjects +
+ + {#if showSubjectsDropdown} +
+ + {#each uniqueSubjects() as subject} + {@const selected = filterSubjects.includes(subject)} + + {/each} +
+ {/if} +
+
+ +
+ Grade range +
+ + + {gradeRange[0]}% – {gradeRange[1]}% +
+
+ + + + +
+ +
+ {#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)} +
+ +
+
+ +
+ {/key} +
+ +
+ +
+ +
+ + {timeScopedData().length} of {analyticsData.length} assessments shown + {#if gradedFiltered().length !== timeScopedData().length} + ({gradedFiltered().length} with grades) + {/if} + + {#if hasActiveFilters()} + + {/if} +
+ {:else} +
+

No analytics data yet

+

+ Data syncs when you visit this page. Assessments with released marks will + appear here with trends and grade breakdowns. +

+ +
+ {/if} +
diff --git a/src/plugins/built-in/gradeAnalytics/api.ts b/src/plugins/built-in/gradeAnalytics/api.ts new file mode 100644 index 00000000..fc2ddf5c --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/api.ts @@ -0,0 +1,354 @@ +import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { getMockGradeAnalyticsData } from "@/seqta/ui/dev/hideSensitiveContent"; +import { + extractLetterGradeStringFromPayload, + resolveNumericGradeFromAssessmentPayload, +} from "./letterGradeScale"; +import { loadAnalyticsCache, saveAnalyticsCache } from "./storage"; +import type { Assessment, AssessmentStatus } from "./types"; + +const PAST_FETCH_CONCURRENCY = 8; +const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface Subject { + code: string; + programme: number; + metaclass: number; +} + +async function fetchJSON(url: string, body: Record) { + const res = await fetch(`${location.origin}${url}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify(body), + }); + return res.json(); +} + +function isValidDate(dateStr: string): boolean { + const date = new Date(dateStr); + return date instanceof Date && !isNaN(date.getTime()); +} + +export function parseAssessment(data: unknown): Assessment | null { + try { + if (!data || typeof data !== "object") return null; + const raw = data as Record; + + const letterGrade = extractLetterGradeStringFromPayload( + raw as Parameters[0], + ); + let finalGrade = resolveNumericGradeFromAssessmentPayload( + raw as Parameters[0], + ); + if ( + finalGrade !== undefined && + (typeof finalGrade !== "number" || isNaN(finalGrade)) + ) { + finalGrade = undefined; + } + + const assessment: Assessment = { + id: Number(raw.id), + title: String(raw.title || ""), + subject: String(raw.subject || raw.code || ""), + status: String(raw.status || "PENDING") as AssessmentStatus, + due: String(raw.due || raw.date || raw.dueDate || ""), + code: String(raw.code || raw.subject || ""), + metaclassID: Number(raw.metaclassID ?? raw.metaclass ?? 0), + programmeID: Number(raw.programmeID ?? raw.programme ?? 0), + graded: Boolean(raw.graded), + overdue: Boolean(raw.overdue), + hasFeedback: Boolean(raw.hasFeedback), + reflectionsEnabled: Boolean(raw.reflectionsEnabled), + reflectionsCompleted: Boolean(raw.reflectionsCompleted), + expectationsEnabled: Boolean(raw.expectationsEnabled), + expectationsCompleted: Boolean(raw.expectationsCompleted), + availability: String(raw.availability || ""), + finalGrade, + letterGrade, + }; + + if ( + !assessment.id || + !assessment.title || + !assessment.subject || + !isValidDate(assessment.due) + ) { + return null; + } + + return assessment; + } catch { + return null; + } +} + +function jsonGradeToString(grade: unknown): string | undefined { + if (typeof grade === "string") return grade.trim() || undefined; + if (typeof grade === "number") return String(grade); + return undefined; +} + +function extractFinalGrade(assessment: Record): number | undefined { + if (assessment.status !== "MARKS_RELEASED") return undefined; + + const criteria = assessment.criteria as + | { results?: { percentage?: unknown } }[] + | undefined; + if (criteria?.[0]?.results?.percentage !== undefined) { + const n = Number(criteria[0].results!.percentage); + if (!isNaN(n)) return n; + } + + const results = assessment.results as { percentage?: unknown } | undefined; + if (results?.percentage !== undefined) { + const n = Number(results.percentage); + if (!isNaN(n)) return n; + } + + if (assessment.finalGrade !== undefined && assessment.finalGrade !== null) { + const n = Number(assessment.finalGrade); + if (!isNaN(n)) return n; + } + + const letter = extractLetterGradeStringFromPayload( + assessment as Parameters[0], + ); + if (letter) { + const approx = resolveNumericGradeFromAssessmentPayload({ + status: "MARKS_RELEASED", + letterGrade: letter, + }); + if (approx !== undefined) return approx; + } + + return undefined; +} + +function extractLetterGrade( + assessment: Record, +): string | undefined { + if (assessment.status !== "MARKS_RELEASED") return undefined; + + const criteria = assessment.criteria as + | { results?: { grade?: unknown } }[] + | undefined; + const c0 = criteria?.[0]?.results?.grade; + const fromCriteria = jsonGradeToString(c0); + if (fromCriteria) return fromCriteria; + + const results = assessment.results as { grade?: unknown } | undefined; + const fromResults = jsonGradeToString(results?.grade); + if (fromResults) return fromResults; + + return extractLetterGradeStringFromPayload( + assessment as Parameters[0], + ); +} + +/** All programme years / folders from SEQTA (active and inactive), matching DesQTA analytics. */ +function flattenSubjectFolders(payload: unknown): Subject[] { + if (!Array.isArray(payload)) return []; + + const subjects: Subject[] = []; + for (const folder of payload) { + if (!folder || typeof folder !== "object") continue; + const list = (folder as { subjects?: Subject[] }).subjects; + if (!Array.isArray(list)) continue; + + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const programme = Number( + (raw as Subject).programme ?? (raw as { programmeID?: number }).programmeID, + ); + const metaclass = Number( + (raw as Subject).metaclass ?? (raw as { metaclassID?: number }).metaclassID, + ); + if (!programme || !metaclass || isNaN(programme) || isNaN(metaclass)) continue; + + subjects.push({ + code: String((raw as Subject).code ?? (raw as { subject?: string }).subject ?? ""), + programme, + metaclass, + }); + } + } + return subjects; +} + +/** Subjects implied by cached assessments (covers metaclasses no longer listed). */ +function subjectsFromAssessments(assessments: Assessment[]): Subject[] { + const map = new Map(); + for (const a of assessments) { + if (!a.programmeID || !a.metaclassID) continue; + const key = `${a.programmeID}-${a.metaclassID}`; + if (!map.has(key)) { + map.set(key, { + code: a.code || a.subject, + programme: a.programmeID, + metaclass: a.metaclassID, + }); + } + } + return Array.from(map.values()); +} + +function dedupeSubjects(subjects: Subject[]): Subject[] { + const map = new Map(); + for (const s of subjects) { + map.set(`${s.programme}-${s.metaclass}`, s); + } + return Array.from(map.values()); +} + +async function loadAllSubjects(existingAssessments: Assessment[] = []): Promise { + const res = await fetchJSON("/seqta/student/load/subjects?", {}); + const fromFolders = flattenSubjectFolders(res.payload); + return dedupeSubjects([...fromFolders, ...subjectsFromAssessments(existingAssessments)]); +} + +async function loadUpcoming(studentId: number): Promise[]> { + const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", { + student: studentId, + }); + return Array.isArray(res.payload) ? res.payload : []; +} + +async function loadPastForSubject( + studentId: number, + subject: Subject, +): Promise[]> { + const res = await fetchJSON("/seqta/student/assessment/list/past?", { + programme: subject.programme, + metaclass: subject.metaclass, + student: studentId, + }); + const items: Record[] = []; + const process = (assessment: unknown) => { + if (!assessment || typeof assessment !== "object") return; + const a = assessment as Record; + if (!a.id) return; + items.push({ + ...a, + programmeID: a.programmeID ?? a.programme ?? subject.programme, + metaclassID: a.metaclassID ?? a.metaclass ?? subject.metaclass, + code: a.code ?? a.subject ?? subject.code, + }); + }; + if (Array.isArray(res.payload?.pending)) { + res.payload.pending.forEach(process); + } + if (Array.isArray(res.payload?.tasks)) { + res.payload.tasks.forEach(process); + } + return items; +} + +async function loadAllPast( + studentId: number, + subjects: Subject[], +): Promise[]> { + const results: Record[][] = []; + for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) { + const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY); + const batchResults = await Promise.all( + batch.map((s) => loadPastForSubject(studentId, s)), + ); + results.push(...batchResults); + } + return results.flat(); +} + +function mergeRawAssessments( + existing: Assessment[], + rawItems: Record[], +): Assessment[] { + const existingMap = new Map(); + for (const a of existing) { + existingMap.set(a.id, a); + } + + for (const raw of rawItems) { + const id = Number(raw.id); + if (!id) continue; + + const finalGrade = extractFinalGrade(raw); + const letterGrade = extractLetterGrade(raw); + if (finalGrade !== undefined) raw.finalGrade = finalGrade; + if (letterGrade !== undefined) raw.letterGrade = letterGrade; + + const existingItem = existingMap.get(id); + if (existingItem?.finalGrade !== undefined && finalGrade === undefined) { + continue; + } + + const parsed = parseAssessment(raw); + if (parsed) existingMap.set(id, parsed); + } + + return Array.from(existingMap.values()).sort( + (a, b) => new Date(b.due).getTime() - new Date(a.due).getTime(), + ); +} + +export async function getStudentId(): Promise { + const info = await getUserInfo(); + const id = Number(info?.id); + if (!id || isNaN(id)) throw new Error("Could not resolve student ID"); + return id; +} + +export function getCacheTtlMs(cacheTtlHours = 24): number { + return cacheTtlHours * 60 * 60 * 1000; +} + +export async function loadGradeAnalytics( + cacheTtlMs = getCacheTtlMs(), +): Promise<{ assessments: Assessment[]; updatedAt: number | null; fromCache: boolean }> { + if (settingsState.hideSensitiveContent) { + const mock = getMockGradeAnalyticsData(); + return { assessments: mock, updatedAt: Date.now(), fromCache: false }; + } + + const studentId = await getStudentId(); + const cached = await loadAnalyticsCache(location.origin, studentId); + if (cached) { + const stale = Date.now() - cached.updatedAt > cacheTtlMs; + return { + assessments: cached.assessments, + updatedAt: cached.updatedAt, + fromCache: !stale, + }; + } + + return { assessments: [], updatedAt: null, fromCache: false }; +} + +export async function syncGradeAnalytics(): Promise<{ + assessments: Assessment[]; + updatedAt: number; +}> { + if (settingsState.hideSensitiveContent) { + const mock = getMockGradeAnalyticsData(); + return { assessments: mock, updatedAt: Date.now() }; + } + + const studentId = await getStudentId(); + const cached = await loadAnalyticsCache(location.origin, studentId); + const existing = cached?.assessments ?? []; + + const subjectList = await loadAllSubjects(existing); + + const [upcoming, past] = await Promise.all([ + loadUpcoming(studentId), + loadAllPast(studentId, subjectList), + ]); + + const merged = mergeRawAssessments(existing, [...upcoming, ...past]); + await saveAnalyticsCache(location.origin, studentId, merged); + + return { assessments: merged, updatedAt: Date.now() }; +} diff --git a/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte b/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte new file mode 100644 index 00000000..25bff61c --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte @@ -0,0 +1,39 @@ + + +
+ + {@render children?.()} +
diff --git a/src/plugins/built-in/gradeAnalytics/chart/chart-style.svelte b/src/plugins/built-in/gradeAnalytics/chart/chart-style.svelte new file mode 100644 index 00000000..f02ef305 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/chart/chart-style.svelte @@ -0,0 +1,36 @@ + + +{#if themeContents} + {#key id} + + {themeContents} + + {/key} +{/if} diff --git a/src/plugins/built-in/gradeAnalytics/chart/chart-tooltip.svelte b/src/plugins/built-in/gradeAnalytics/chart/chart-tooltip.svelte new file mode 100644 index 00000000..1761dd59 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/chart/chart-tooltip.svelte @@ -0,0 +1,157 @@ + + +{#snippet TooltipLabel()} + {#if formattedLabel} +
+ {#if typeof formattedLabel === "function"} + {@render formattedLabel()} + {:else} + {formattedLabel} + {/if} +
+ {/if} +{/snippet} + + +
+ {#if !nestLabel} + {@render TooltipLabel()} + {/if} +
+ {#each tooltipCtx.payload as item, i (item.key + i)} + {@const key = `${nameKey || item.key || item.name || "value"}`} + {@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)} + {@const indicatorColor = color || item.payload?.color || item.color} +
+ {#if formatter && item.value !== undefined && item.name} + {@render formatter({ + value: item.value, + name: item.name, + item, + index: i, + payload: tooltipCtx.payload, + })} + {:else} + {#if !hideIndicator} +
+ {/if} +
+
+ {#if nestLabel} + {@render TooltipLabel()} + {/if} + + {itemConfig?.label || item.name} + +
+ {#if item.value !== undefined} + + {item.value.toLocaleString()} + + {/if} +
+ {/if} +
+ {/each} +
+
+
diff --git a/src/plugins/built-in/gradeAnalytics/chart/chart-utils.ts b/src/plugins/built-in/gradeAnalytics/chart/chart-utils.ts new file mode 100644 index 00000000..5b0348af --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/chart/chart-utils.ts @@ -0,0 +1,80 @@ +import type { Tooltip } from "layerchart"; +import { + getContext, + setContext, + type Component, + type ComponentProps, + type Snippet, +} from "svelte"; + +export const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: string; + icon?: Component; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +export type ExtractSnippetParams = T extends Snippet<[infer P]> ? P : never; + +export type TooltipPayload = ExtractSnippetParams< + ComponentProps["children"] +>["payload"][number]; + +export function getPayloadConfigFromPayload( + config: ChartConfig, + payload: TooltipPayload, + key: string, +) { + if (typeof payload !== "object" || payload === null) return undefined; + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined; + + let configLabelKey: string = key; + + if (payload.key === key) { + configLabelKey = payload.key; + } else if (payload.name === key) { + configLabelKey = payload.name; + } else if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload !== undefined && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string; + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; +} + +type ChartContextValue = { + config: ChartConfig; +}; + +const chartContextKey = Symbol("chart-context"); + +export function setChartContext(value: ChartContextValue) { + return setContext(chartContextKey, value); +} + +export function useChart() { + return getContext(chartContextKey); +} diff --git a/src/plugins/built-in/gradeAnalytics/chart/index.ts b/src/plugins/built-in/gradeAnalytics/chart/index.ts new file mode 100644 index 00000000..6e1f0bf4 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/chart/index.ts @@ -0,0 +1,10 @@ +import ChartContainer from "./chart-container.svelte"; +import ChartTooltip from "./chart-tooltip.svelte"; + +export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils"; +export { + ChartContainer, + ChartTooltip, + ChartContainer as Container, + ChartTooltip as Tooltip, +}; diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts new file mode 100644 index 00000000..9c9555cf --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/core/index.ts @@ -0,0 +1,68 @@ +import type { Plugin } from "@/plugins/core/types"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { loadAnalyticsPage } from "../loadAnalyticsPage"; +import styles from "../styles.css?inline"; + +const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item"; + +const gradeAnalyticsPlugin: Plugin<{}> = { + id: "grade-analytics", + name: "Grade Analytics", + description: + "Adds an analytics page with grade trends, distribution charts, and assessment history", + version: "1.0.0", + settings: {}, + disableToggle: false, + styles, + + run: async () => { + if (isSeqtaEngageExperience()) { + return () => {}; + } + + const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement; + + const analyticsItem = document.createElement("li"); + analyticsItem.className = "item"; + analyticsItem.classList.add(ANALYTICS_MENU_CLASS); + analyticsItem.id = "analyticsbutton"; + analyticsItem.dataset.key = "analytics"; + analyticsItem.dataset.path = "/analytics"; + analyticsItem.dataset.betterseqta = "true"; + analyticsItem.innerHTML = ``; + + const homeButton = document.getElementById("homebutton"); + if (homeButton?.parentElement === menuList) { + homeButton.insertAdjacentElement("afterend", analyticsItem); + } else { + menuList.insertBefore(analyticsItem, menuList.firstChild); + } + + const menuObserver = new MutationObserver(() => { + if (!menuList.contains(analyticsItem)) { + if (homeButton?.parentElement === menuList) { + homeButton.insertAdjacentElement("afterend", analyticsItem); + } else { + menuList.insertBefore(analyticsItem, menuList.firstChild); + } + } + }); + menuObserver.observe(menuList, { childList: true }); + + const onClick = (e: Event) => { + e.preventDefault(); + window.history.pushState({}, "", "/#?page=/analytics"); + void loadAnalyticsPage(); + }; + analyticsItem.addEventListener("click", onClick); + + return () => { + menuObserver.disconnect(); + analyticsItem.removeEventListener("click", onClick); + analyticsItem.remove(); + }; + }, +}; + +export default gradeAnalyticsPlugin; diff --git a/src/plugins/built-in/gradeAnalytics/gradeDistribution.ts b/src/plugins/built-in/gradeAnalytics/gradeDistribution.ts new file mode 100644 index 00000000..391940bf --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/gradeDistribution.ts @@ -0,0 +1,475 @@ +import { approximatePercentFromLetterGrade } from "./letterGradeScale"; +import type { Assessment } from "./types"; + +export type DistributionMode = "auto" | "letter" | "percent"; + +export const DISTRIBUTION_MODE_OPTIONS: { + value: DistributionMode; + label: string; + description: string; +}[] = [ + { + value: "auto", + label: "Auto", + description: "Letter grades when your school uses them, otherwise percentages", + }, + { + value: "letter", + label: "Letter grades", + description: "Group by letter band (school scale or standard A–F)", + }, + { + value: "percent", + label: "Percentage bands", + description: "Group by score ranges (90–100, 80–89, …)", + }, +]; + +export type DistributionBucket = { + label: string; + count: number; + minPercent?: number; + maxPercent?: number; +}; + +export type GradeDistributionResult = { + buckets: DistributionBucket[]; + modeUsed: "letter" | "percent"; + scaleSource: "inferred" | "standard" | "percent"; + scaleLabel: string; + gradedCount: number; + averagePercent: number | null; + letterGradeCoverage: number; +}; + +const PERCENT_BUCKETS: { label: string; min: number; max: number }[] = [ + { label: "90–100", min: 90, max: 100 }, + { label: "80–89", min: 80, max: 89 }, + { label: "70–79", min: 70, max: 79 }, + { label: "60–69", min: 60, max: 69 }, + { label: "50–59", min: 50, max: 59 }, + { label: "0–49", min: 0, max: 49 }, +]; + +/** Standard A–F (+ modifiers) ordering when school scale cannot be inferred. */ +const STANDARD_LETTER_ORDER = [ + "A+", + "A", + "A-", + "B+", + "B", + "B-", + "C+", + "C", + "C-", + "D+", + "D", + "D-", + "E", + "F", + "HD", + "CR", + "P", + "PS", + "N", + "PASS", + "FAIL", +] as const; + +export type InferredLetterBand = { + key: string; + label: string; + medianPercent: number; + minPercent: number; + maxPercent: number; + pairedSamples: number; + totalCount: number; +}; + +export type InferredLetterScale = { + bands: InferredLetterBand[]; + pairedCount: number; + letterAssessmentCount: number; + confidence: "high" | "medium" | "low"; +}; + +function normalizeLetterKey(raw: string): string { + const s = raw.trim().toLowerCase(); + const first = s.split(/[\s(/]/)[0] ?? s; + return first.replace(/[^a-z0-9+-]/gi, "") || s; +} + +function pickDisplayLabel(variants: string[]): string { + if (!variants.length) return ""; + const counts = new Map(); + for (const v of variants) { + const t = v.trim(); + if (!t) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + let best = variants[0].trim(); + let bestCount = 0; + for (const [label, count] of counts) { + if (count > bestCount) { + bestCount = count; + best = label; + } + } + return best; +} + +export function looksLikeLetterGrade(raw: string | undefined | null): boolean { + if (raw == null) return false; + const t = raw.trim(); + if (!t) return false; + if (/^\d+(\.\d+)?%?$/.test(t)) return false; + if (t.length > 12) return false; + const upper = t.toUpperCase(); + if (["HD", "CR", "P", "PS", "N", "PASS", "FAIL"].includes(upper)) return true; + return /[a-zA-Z]/.test(t); +} + +function isGradedAssessment(a: Assessment): boolean { + return ( + a.finalGrade !== undefined || + (a.letterGrade != null && looksLikeLetterGrade(a.letterGrade)) + ); +} + +function buildStandardLetterScale(): InferredLetterScale { + const bands: InferredLetterBand[] = []; + const seen = new Set(); + + for (const label of STANDARD_LETTER_ORDER) { + const key = normalizeLetterKey(label); + if (seen.has(key)) continue; + const approx = approximatePercentFromLetterGrade(label); + if (approx === undefined) continue; + seen.add(key); + bands.push({ + key, + label, + medianPercent: approx, + minPercent: approx, + maxPercent: approx, + pairedSamples: 0, + totalCount: 0, + }); + } + + bands.sort((a, b) => b.medianPercent - a.medianPercent); + + for (let i = 0; i < bands.length; i++) { + const above = bands[i - 1]; + const below = bands[i + 1]; + bands[i].maxPercent = + above != null + ? (above.medianPercent + bands[i].medianPercent) / 2 + : 100; + bands[i].minPercent = + below != null + ? (below.medianPercent + bands[i].medianPercent) / 2 + : 0; + } + + return { + bands, + pairedCount: 0, + letterAssessmentCount: 0, + confidence: "low", + }; +} + +/** + * Learn letter bands from assessments that report both % and the letter SEQTA assigned. + */ +export function inferLetterGradeScale( + assessments: Assessment[], +): InferredLetterScale | null { + const pairMap = new Map(); + const letterOnlyMap = new Map(); + let pairedCount = 0; + let letterAssessmentCount = 0; + + for (const a of assessments) { + if (!isGradedAssessment(a)) continue; + + const letterRaw = a.letterGrade?.trim(); + const hasLetter = letterRaw && looksLikeLetterGrade(letterRaw); + if (hasLetter) letterAssessmentCount++; + + if (hasLetter && a.finalGrade !== undefined) { + const key = normalizeLetterKey(letterRaw); + if (/^\d+(\.\d+)?$/.test(key)) continue; + pairedCount++; + if (!pairMap.has(key)) pairMap.set(key, { percents: [], labels: [] }); + const entry = pairMap.get(key)!; + entry.percents.push(a.finalGrade); + entry.labels.push(letterRaw); + } else if (hasLetter) { + const key = normalizeLetterKey(letterRaw); + if (/^\d+(\.\d+)?$/.test(key)) continue; + if (!letterOnlyMap.has(key)) letterOnlyMap.set(key, { labels: [], count: 0 }); + const entry = letterOnlyMap.get(key)!; + entry.count++; + entry.labels.push(letterRaw); + } + } + + if (letterAssessmentCount < 2 && pairedCount < 2) return null; + + const allKeys = new Set([...pairMap.keys(), ...letterOnlyMap.keys()]); + if (allKeys.size < 2 && pairedCount < 2) return null; + + const bands: InferredLetterBand[] = []; + + for (const key of allKeys) { + const paired = pairMap.get(key); + const letterOnly = letterOnlyMap.get(key); + const labels = [...(paired?.labels ?? []), ...(letterOnly?.labels ?? [])]; + const percents = paired?.percents ?? []; + const totalCount = percents.length + (letterOnly?.count ?? 0); + + let medianPercent: number; + let minPercent: number; + let maxPercent: number; + + if (percents.length > 0) { + const sorted = [...percents].sort((x, y) => x - y); + medianPercent = sorted[Math.floor(sorted.length / 2)]!; + minPercent = sorted[0]!; + maxPercent = sorted[sorted.length - 1]!; + } else { + const approx = approximatePercentFromLetterGrade(pickDisplayLabel(labels)); + if (approx === undefined) continue; + medianPercent = approx; + minPercent = approx; + maxPercent = approx; + } + + bands.push({ + key, + label: pickDisplayLabel(labels), + medianPercent, + minPercent, + maxPercent, + pairedSamples: percents.length, + totalCount, + }); + } + + if (bands.length < 2) return null; + + bands.sort((a, b) => b.medianPercent - a.medianPercent); + + for (let i = 0; i < bands.length; i++) { + const above = bands[i - 1]; + const below = bands[i + 1]; + if (bands[i].pairedSamples > 0 || above?.pairedSamples || below?.pairedSamples) { + bands[i].maxPercent = + above != null + ? (above.medianPercent + bands[i].medianPercent) / 2 + : 100; + bands[i].minPercent = + below != null + ? (below.medianPercent + bands[i].medianPercent) / 2 + : 0; + } + } + + const confidence: InferredLetterScale["confidence"] = + pairedCount >= 8 || (pairedCount >= 5 && pairedCount / letterAssessmentCount >= 0.4) + ? "high" + : pairedCount >= 3 || letterAssessmentCount >= 5 + ? "medium" + : "low"; + + return { + bands, + pairedCount, + letterAssessmentCount, + confidence, + }; +} + +function resolveEffectiveMode( + mode: DistributionMode, + inferred: InferredLetterScale | null, + graded: Assessment[], +): "letter" | "percent" { + if (mode === "percent") return "percent"; + if (mode === "letter") return "letter"; + + if (!inferred) return "percent"; + const letterCount = graded.filter( + (a) => a.letterGrade && looksLikeLetterGrade(a.letterGrade), + ).length; + if (letterCount === 0) return "percent"; + if (inferred.confidence === "high" || inferred.confidence === "medium") { + return "letter"; + } + return letterCount / graded.length >= 0.35 ? "letter" : "percent"; +} + +function assignPercentToBand( + percent: number, + scale: InferredLetterScale, +): string | null { + if (!scale.bands.length) return null; + for (const band of scale.bands) { + if (percent >= band.minPercent) return band.key; + } + return scale.bands[scale.bands.length - 1]!.key; +} + +function buildPercentDistribution(graded: Assessment[]): GradeDistributionResult { + const counts = PERCENT_BUCKETS.map((b) => ({ label: b.label, count: 0 })); + let percentSum = 0; + let percentCount = 0; + + for (const a of graded) { + let grade = a.finalGrade; + if (grade === undefined && a.letterGrade) { + grade = approximatePercentFromLetterGrade(a.letterGrade); + } + if (grade === undefined) continue; + percentSum += grade; + percentCount++; + const bucket = PERCENT_BUCKETS.find((b) => grade! >= b.min && grade! <= b.max); + if (bucket) { + const row = counts.find((c) => c.label === bucket.label); + if (row) row.count++; + } + } + + return { + buckets: counts, + modeUsed: "percent", + scaleSource: "percent", + scaleLabel: "Percentage bands", + gradedCount: graded.length, + averagePercent: + percentCount > 0 ? Math.round((percentSum / percentCount) * 10) / 10 : null, + letterGradeCoverage: 0, + }; +} + +function buildLetterDistribution( + graded: Assessment[], + inferred: InferredLetterScale | null, + forceStandard: boolean, +): GradeDistributionResult { + const scale = + !forceStandard && inferred && inferred.bands.length >= 2 + ? inferred + : buildStandardLetterScale(); + const scaleSource = + !forceStandard && inferred && inferred.bands.length >= 2 ? "inferred" : "standard"; + + const countByKey = new Map(); + for (const band of scale.bands) countByKey.set(band.key, 0); + + let percentSum = 0; + let percentCount = 0; + let withLetter = 0; + + for (const a of graded) { + if (a.finalGrade !== undefined) { + percentSum += a.finalGrade; + percentCount++; + } + + const letterRaw = a.letterGrade?.trim(); + if (letterRaw && looksLikeLetterGrade(letterRaw)) withLetter++; + + let key: string | null = null; + if (letterRaw && looksLikeLetterGrade(letterRaw)) { + key = normalizeLetterKey(letterRaw); + if (/^\d+(\.\d+)?$/.test(key)) key = null; + } + if (!key && a.finalGrade !== undefined) { + key = assignPercentToBand(a.finalGrade, scale); + } + if (!key && letterRaw && looksLikeLetterGrade(letterRaw)) { + const approx = approximatePercentFromLetterGrade(letterRaw); + if (approx !== undefined) key = assignPercentToBand(approx, scale); + } + if (!key) continue; + + if (!countByKey.has(key)) { + countByKey.set(key, 0); + const existing = scale.bands.find((b) => b.key === key); + if (!existing) { + const approx = + a.finalGrade ?? + (letterRaw ? approximatePercentFromLetterGrade(letterRaw) : undefined) ?? + 0; + scale.bands.push({ + key, + label: + letterRaw && looksLikeLetterGrade(letterRaw) + ? letterRaw + : key.toUpperCase(), + medianPercent: approx, + minPercent: 0, + maxPercent: 100, + pairedSamples: 0, + totalCount: 0, + }); + scale.bands.sort((x, y) => y.medianPercent - x.medianPercent); + } + } + countByKey.set(key, (countByKey.get(key) ?? 0) + 1); + } + + const buckets: DistributionBucket[] = scale.bands + .filter((b) => (countByKey.get(b.key) ?? 0) > 0) + .map((b) => ({ + label: b.label, + count: countByKey.get(b.key) ?? 0, + minPercent: Math.round(b.minPercent), + maxPercent: Math.round(b.maxPercent), + })); + + const scaleLabel = + scaleSource === "inferred" + ? "Learned from your school's percentage ↔ letter marks" + : "Standard A–F style scale (override)"; + + return { + buckets, + modeUsed: "letter", + scaleSource, + scaleLabel, + gradedCount: graded.length, + averagePercent: + percentCount > 0 ? Math.round((percentSum / percentCount) * 10) / 10 : null, + letterGradeCoverage: graded.length ? withLetter / graded.length : 0, + }; +} + +export function buildGradeDistribution( + assessments: Assessment[], + mode: DistributionMode = "auto", +): GradeDistributionResult { + const graded = assessments.filter(isGradedAssessment); + if (!graded.length) { + return { + buckets: [], + modeUsed: "percent", + scaleSource: "percent", + scaleLabel: "Percentage bands", + gradedCount: 0, + averagePercent: null, + letterGradeCoverage: 0, + }; + } + + const inferred = inferLetterGradeScale(graded); + const effective = resolveEffectiveMode(mode, inferred, graded); + + if (effective === "letter") { + return buildLetterDistribution(graded, inferred, mode === "letter" && !inferred); + } + return buildPercentDistribution(graded); +} diff --git a/src/plugins/built-in/gradeAnalytics/lazy.ts b/src/plugins/built-in/gradeAnalytics/lazy.ts new file mode 100644 index 00000000..9b0c84e6 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/lazy.ts @@ -0,0 +1,38 @@ +import { defineLazyPlugin } from "../../core/dynamicLoader"; +import { defineSettings, numberSetting } from "../../core/settingsHelpers"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import styles from "./styles.css?inline"; + +const settings = defineSettings({ + cacheTtlHours: numberSetting({ + default: 24, + title: "Cache duration (hours)", + description: "How long to keep synced analytics before refreshing from SEQTA", + min: 1, + max: 168, + }), +}); + +const gradeAnalyticsPluginLazy = defineLazyPlugin({ + id: "grade-analytics", + name: "Grade Analytics", + description: + "Grade trends, distribution charts, and assessment history synced from SEQTA", + version: "1.0.0", + settings, + disableToggle: false, + defaultEnabled: true, + styles, + loader: () => import("./core/index"), +}); + +const runGradeAnalytics = gradeAnalyticsPluginLazy.run!; + +gradeAnalyticsPluginLazy.run = async (api) => { + if (isSeqtaEngageExperience()) { + return () => {}; + } + return runGradeAnalytics(api); +}; + +export default gradeAnalyticsPluginLazy; diff --git a/src/plugins/built-in/gradeAnalytics/letterGradeScale.ts b/src/plugins/built-in/gradeAnalytics/letterGradeScale.ts new file mode 100644 index 00000000..50a0079f --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/letterGradeScale.ts @@ -0,0 +1,116 @@ +/** + * When SEQTA only reports letter bands (no percentage), map to approximate 0–100 + * so analytics charts can run. Conventional scale, not official school conversion. + */ +const LETTER_TO_APPROX_PERCENT: Record = { + "a+": 95, + a: 85, + "a-": 80, + "b+": 75, + b: 68, + "b-": 62, + "c+": 58, + c: 55, + "c-": 50, + "d+": 48, + d: 45, + "d-": 42, + e: 38, + f: 32, + hd: 95, + cr: 60, + p: 55, + ps: 55, + n: 35, + pass: 55, + fail: 32, +}; + +function normalizeLetterKey(raw: string): string { + const s = raw.trim().toLowerCase(); + const first = s.split(/[\s(/]/)[0] ?? s; + return first.replace(/[^a-z+-]/gi, "") || s; +} + +export function approximatePercentFromLetterGrade( + letter: string | null | undefined, +): number | undefined { + if (letter == null) return undefined; + const t = String(letter).trim(); + if (!t) return undefined; + if (/^\d+(\.\d+)?$/.test(t)) { + const n = parseFloat(t); + if (!isNaN(n) && n >= 0 && n <= 100) return n; + } + const key = normalizeLetterKey(t); + if (LETTER_TO_APPROX_PERCENT[key] !== undefined) + return LETTER_TO_APPROX_PERCENT[key]; + if (t.length === 1 && /^[a-f]$/i.test(t)) { + const single = t.toLowerCase() as keyof typeof LETTER_TO_APPROX_PERCENT; + if (LETTER_TO_APPROX_PERCENT[single] !== undefined) + return LETTER_TO_APPROX_PERCENT[single]; + } + return undefined; +} + +export function extractLetterGradeStringFromPayload(data: { + criteria?: { results?: { grade?: unknown } }[]; + results?: { grade?: unknown }; + letterGrade?: unknown; + extra?: Record; +}): string | undefined { + const merged: Record = { + ...(data?.extra && typeof data.extra === "object" ? data.extra : {}), + ...data, + }; + if (merged.letterGrade != null && String(merged.letterGrade).trim() !== "") { + return String(merged.letterGrade).trim(); + } + const criteria = merged.criteria as + | { results?: { grade?: unknown } }[] + | undefined; + const c0 = criteria?.[0]?.results?.grade; + if (c0 != null && String(c0).trim() !== "") return String(c0).trim(); + const r = (merged.results as { grade?: unknown } | undefined)?.grade; + if (r != null && String(r).trim() !== "") return String(r).trim(); + return undefined; +} + +export function resolveNumericGradeFromAssessmentPayload(data: { + status?: string; + finalGrade?: unknown; + criteria?: { results?: { percentage?: unknown; grade?: unknown } }[]; + results?: { percentage?: unknown; grade?: unknown }; + letterGrade?: unknown; + extra?: Record; +}): number | undefined { + const merged: Record = { + ...(data?.extra && typeof data.extra === "object" ? data.extra : {}), + ...data, + }; + if (merged.finalGrade != null && merged.finalGrade !== "") { + const n = Number(merged.finalGrade); + if (!isNaN(n)) return n; + } + if (merged.status && merged.status !== "MARKS_RELEASED") return undefined; + + const criteria = merged.criteria as + | { results?: { percentage?: unknown; grade?: unknown } }[] + | undefined; + if (criteria?.[0]?.results?.percentage !== undefined) { + const n = Number(criteria[0].results!.percentage); + if (!isNaN(n)) return n; + } + const results = merged.results as + | { percentage?: unknown; grade?: unknown } + | undefined; + if (results?.percentage !== undefined) { + const n = Number(results.percentage); + if (!isNaN(n)) return n; + } + + const letter = extractLetterGradeStringFromPayload( + merged as Parameters[0], + ); + return approximatePercentFromLetterGrade(letter); +} diff --git a/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts b/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts new file mode 100644 index 00000000..e92875eb --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts @@ -0,0 +1,46 @@ +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { waitForElm } from "@/seqta/utils/waitForElm"; + +let loadInFlight: Promise | null = null; + +export async function loadAnalyticsPage(): Promise { + if (!settingsState.onoff) return; + + if (loadInFlight) { + await loadInFlight; + return; + } + + loadInFlight = loadAnalyticsPageInner(); + try { + await loadInFlight; + } finally { + loadInFlight = null; + } +} + +async function loadAnalyticsPageInner(): Promise { + document.title = "Analytics ― SEQTA Learn"; + + document.querySelectorAll("#menu .item").forEach((item) => { + item.classList.remove("active"); + }); + document.querySelector('[data-key="analytics"]')?.classList.add("active"); + + const main = (await waitForElm("#main", true, 100, 60)) as HTMLElement; + + main.innerHTML = ""; + main.style.overflow = "auto"; + main.style.width = "100%"; + main.style.maxWidth = "none"; + const viewShell = document.createElement("div"); + viewShell.id = "analytics-view-container"; + main.appendChild(viewShell); + const container = viewShell; + + const titlediv = document.getElementById("title")?.firstChild; + if (titlediv) (titlediv as HTMLElement).innerText = "Analytics"; + + const { renderAnalyticsPage } = await import("./ui"); + renderAnalyticsPage(container); +} diff --git a/src/plugins/built-in/gradeAnalytics/storage.ts b/src/plugins/built-in/gradeAnalytics/storage.ts new file mode 100644 index 00000000..b69f762a --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/storage.ts @@ -0,0 +1,68 @@ +import browser from "webextension-polyfill"; +import type { DistributionMode } from "./gradeDistribution"; +import type { AnalyticsCache } from "./types"; + +const STORAGE_PREFIX = "bsplus.analytics.v2"; +const DISTRIBUTION_MODE_PREFIX = "bsplus.analytics.distMode.v1"; + +export function analyticsStorageKey(origin: string, studentId: number): string { + return `${STORAGE_PREFIX}.${origin}.${studentId}`; +} + +export async function loadAnalyticsCache( + origin: string, + studentId: number, +): Promise { + const key = analyticsStorageKey(origin, studentId); + const result = await browser.storage.local.get(key); + const cached = result[key] as AnalyticsCache | undefined; + if (!cached?.assessments) return null; + return cached; +} + +export async function saveAnalyticsCache( + origin: string, + studentId: number, + assessments: AnalyticsCache["assessments"], +): Promise { + const key = analyticsStorageKey(origin, studentId); + const payload: AnalyticsCache = { + updatedAt: Date.now(), + assessments, + }; + await browser.storage.local.set({ [key]: payload }); +} + +export function distributionModeStorageKey( + origin: string, + studentId: number, +): string { + return `${DISTRIBUTION_MODE_PREFIX}.${origin}.${studentId}`; +} + +const VALID_DISTRIBUTION_MODES: DistributionMode[] = ["auto", "letter", "percent"]; + +export async function loadDistributionMode( + origin: string, + studentId: number, +): Promise { + const key = distributionModeStorageKey(origin, studentId); + const result = await browser.storage.local.get(key); + const mode = result[key]; + if ( + typeof mode === "string" && + VALID_DISTRIBUTION_MODES.includes(mode as DistributionMode) + ) { + return mode as DistributionMode; + } + return null; +} + +export async function saveDistributionMode( + origin: string, + studentId: number, + mode: DistributionMode, +): Promise { + const key = distributionModeStorageKey(origin, studentId); + await browser.storage.local.set({ [key]: mode }); +} diff --git a/src/plugins/built-in/gradeAnalytics/styles.css b/src/plugins/built-in/gradeAnalytics/styles.css new file mode 100644 index 00000000..c7dc3581 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/styles.css @@ -0,0 +1,953 @@ +/* ─── Layout shell (mirrors BQ+ home / DesQTA analytics spacing) ─── */ +#analytics-view-container, +.bsplus-analytics-container { + width: 100%; + min-height: 100%; + box-sizing: border-box; +} + +.bsplus-analytics-host { + display: block; + width: 100%; + min-height: min(100%, calc(100vh - 6rem)); +} + +.bsplus-analytics-root { + --bsplus-analytics-radius: 16px; + --bsplus-analytics-radius-sm: 12px; + --bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1); + --bsplus-analytics-surface: var(--background-primary, #ffffff); + --bsplus-analytics-surface-2: var(--background-secondary, #f8fafc); + --bsplus-analytics-text: var(--text-primary, #1a1a1a); + --bsplus-analytics-muted: color-mix(in srgb, var(--bsplus-analytics-text) 55%, transparent); + --bsplus-analytics-border: color-mix( + in srgb, + var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%, + transparent + ); + --bsplus-analytics-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.12); + --bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16); + /* Set on host via ui.ts from --better-main / user selectedColor */ + --bsplus-analytics-accent: var(--better-main, #007bff); + + width: 100%; + max-width: none; + margin: 0; + min-height: min(100%, calc(100vh - 6rem)); + box-sizing: border-box; + padding: 1.5rem 1.25rem 2rem; + font-family: Rubik, system-ui, sans-serif; + font-size: 0.875rem; + color: var(--bsplus-analytics-text); + display: flex; + flex-direction: column; + gap: 2rem; +} + +.bsplus-analytics-root.dark { + --bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45); + --bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55); +} + +@media (max-width: 768px) { + .bsplus-analytics-root { + padding: 1.25rem 1rem 1.5rem; + gap: 1.5rem; + } +} + +/* ─── Animations (DesQTA fadeInUp) ─── */ +@keyframes bsplus-analytics-fade-in-up { + from { + opacity: 0; + transform: translateY(18px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.bsplus-analytics-animate { + animation: bsplus-analytics-fade-in-up 0.55s var(--bsplus-analytics-ease) forwards; + will-change: opacity, transform; +} + +@media (prefers-reduced-motion: reduce) { + .bsplus-analytics-animate { + animation: none; + opacity: 1; + transform: none; + } +} + +.bsplus-analytics-delay-1 { + animation-delay: 80ms; +} +.bsplus-analytics-delay-2 { + animation-delay: 160ms; +} +.bsplus-analytics-delay-3 { + animation-delay: 240ms; +} +.bsplus-analytics-delay-4 { + animation-delay: 320ms; +} + +/* ─── Header ─── */ +.bsplus-analytics-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 1.25rem; +} + +.bsplus-analytics-header-text h1 { + margin: 0 0 0.35rem; + font-size: 1.875rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--bsplus-analytics-text); +} + +.bsplus-analytics-header-text p { + margin: 0; + color: var(--bsplus-analytics-muted); + font-size: 0.9375rem; + line-height: 1.5; +} + +.bsplus-analytics-meta { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: 0.75rem; + padding: 0.2rem 0.65rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + vertical-align: middle; + background: color-mix(in srgb, var(--bsplus-analytics-accent) 18%, transparent); + color: var(--bsplus-analytics-accent); +} + +.bsplus-analytics-badge-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + border: 2px solid currentColor; + border-top-color: transparent; + animation: bsplus-analytics-spin 0.7s linear infinite; +} + +@keyframes bsplus-analytics-spin { + to { + transform: rotate(360deg); + } +} + +/* ─── Buttons ─── */ +.bsplus-analytics-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + border-radius: var(--bsplus-analytics-radius-sm); + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + border: none; + transition: + transform 0.2s var(--bsplus-analytics-ease), + box-shadow 0.2s var(--bsplus-analytics-ease), + background 0.2s var(--bsplus-analytics-ease), + opacity 0.2s ease; +} + +.bsplus-analytics-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent); +} + +.bsplus-analytics-btn-primary { + background: var(--bsplus-analytics-accent); + color: #fff; + box-shadow: 0 2px 8px color-mix(in srgb, var(--bsplus-analytics-accent) 40%, transparent); +} + +.bsplus-analytics-btn-primary:hover:not(:disabled) { + transform: scale(1.03); + box-shadow: 0 4px 14px color-mix(in srgb, var(--bsplus-analytics-accent) 45%, transparent); +} + +.bsplus-analytics-btn-primary:active:not(:disabled) { + transform: scale(0.97); +} + +.bsplus-analytics-btn-ghost { + background: transparent; + color: var(--bsplus-analytics-text); + border: 2px solid var(--bsplus-analytics-border); +} + +.bsplus-analytics-btn-ghost:hover:not(:disabled) { + transform: scale(1.02); + background: color-mix(in srgb, var(--bsplus-analytics-surface-2) 80%, transparent); +} + +.bsplus-analytics-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none !important; +} + +/* ─── Stat cards ─── */ +.bsplus-analytics-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 640px) { + .bsplus-analytics-stats { + grid-template-columns: 1fr; + } +} + +.bsplus-analytics-stat { + padding: 1.1rem 1.25rem; + border-radius: var(--bsplus-analytics-radius); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow); + transition: + transform 0.25s var(--bsplus-analytics-ease), + box-shadow 0.25s var(--bsplus-analytics-ease); +} + +.bsplus-analytics-stat:hover { + transform: translateY(-2px); + box-shadow: var(--bsplus-analytics-shadow-hover); +} + +.bsplus-analytics-stat-label { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--bsplus-analytics-muted); + margin-bottom: 0.35rem; +} + +.bsplus-analytics-stat-value { + font-size: 1.75rem; + font-weight: 700; + line-height: 1.1; + color: var(--bsplus-analytics-text); +} + +.bsplus-analytics-stat-value-accent { + color: var(--bsplus-analytics-accent); +} + +/* ─── Filter toolbar ─── */ +.bsplus-analytics-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + padding: 1rem 1.15rem; + border-radius: var(--bsplus-analytics-radius); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow); + overflow: visible; + position: relative; + z-index: 40; + isolation: isolate; +} + +.bsplus-analytics-toolbar-dropdown-field { + position: relative; + z-index: 2; +} + +.bsplus-analytics-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.bsplus-analytics-field-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-checkbox { + display: flex; + align-items: center; + gap: 0.55rem; + margin-left: auto; + padding: 0.35rem 0; + font-size: 0.8125rem; + font-weight: 500; + color: var(--bsplus-analytics-text); + cursor: pointer; + user-select: none; + transition: color 0.2s ease; +} + +.bsplus-analytics-checkbox input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; + margin: 0; + accent-color: var(--bsplus-analytics-accent); + cursor: pointer; + flex-shrink: 0; +} + +@media (max-width: 900px) { + .bsplus-analytics-checkbox { + margin-left: 0; + width: 100%; + } +} + +.bsplus-analytics-select, +.bsplus-analytics-input { + appearance: none; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + color: var(--bsplus-analytics-text); + background: var(--bsplus-analytics-surface-2); + border: 2px solid var(--bsplus-analytics-border); + border-radius: var(--bsplus-analytics-radius-sm); + padding: 0.65rem 2.25rem 0.65rem 0.9rem; + min-height: 2.75rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s var(--bsplus-analytics-ease); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.bsplus-analytics-select { + cursor: pointer; + min-width: 11rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E"); + background-position: right 0.75rem center; + background-repeat: no-repeat; + background-size: 1rem; +} + +.dark .bsplus-analytics-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E"); +} + +.bsplus-analytics-input { + padding-left: 2.25rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'/%3E%3C/svg%3E"); + background-position: left 0.75rem center; + background-repeat: no-repeat; + background-size: 1rem; + min-width: 14rem; + flex: 1; +} + +.bsplus-analytics-select:hover, +.bsplus-analytics-input:hover { + border-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, var(--bsplus-analytics-border)); +} + +.bsplus-analytics-select:focus, +.bsplus-analytics-input:focus { + outline: none; + border-color: var(--bsplus-analytics-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent); +} + +.bsplus-analytics-grade-range { + flex: 1; + min-width: 12rem; + max-width: 20rem; +} + +.bsplus-analytics-range-row { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.bsplus-analytics-range-row input[type="range"] { + flex: 1; + height: 0.35rem; + accent-color: var(--bsplus-analytics-accent); + cursor: pointer; +} + +.bsplus-analytics-range-value { + font-size: 0.75rem; + font-weight: 600; + color: var(--bsplus-analytics-muted); + white-space: nowrap; + min-width: 4.5rem; + text-align: right; +} + +.bsplus-analytics-toolbar-search { + margin-left: auto; + flex: 1 1 14rem; + max-width: 18rem; +} + +@media (max-width: 768px) { + .bsplus-analytics-toolbar-search { + margin-left: 0; + max-width: none; + width: 100%; + } +} + +/* Toolbar custom dropdowns (time period, subjects) */ +.bsplus-analytics-dropdown { + position: relative; + min-width: 11rem; +} + +.dark .bsplus-analytics-dropdown-trigger { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E"); +} + +.bsplus-analytics-dropdown-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 2.75rem; + padding: 0.65rem 2.25rem 0.65rem 0.9rem; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + text-align: left; + color: var(--bsplus-analytics-text); + background: var(--bsplus-analytics-surface-2); + border: 2px solid var(--bsplus-analytics-border); + border-radius: var(--bsplus-analytics-radius-sm); + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E"); + background-position: right 0.75rem center; + background-repeat: no-repeat; + background-size: 1rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s var(--bsplus-analytics-ease); +} + +.bsplus-analytics-dropdown-trigger:hover { + border-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, var(--bsplus-analytics-border)); + transform: scale(1.01); +} + +.bsplus-analytics-dropdown-trigger:focus-visible { + outline: none; + border-color: var(--bsplus-analytics-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent); +} + +.bsplus-analytics-dropdown-menu { + position: absolute; + left: 0; + top: calc(100% + 0.35rem); + z-index: 100; + min-width: 14rem; + max-height: 12rem; + overflow-y: auto; + padding: 0.35rem; + border-radius: var(--bsplus-analytics-radius-sm); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow-hover); +} + +.bsplus-analytics-dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.65rem; + border: none; + border-radius: 8px; + background: transparent; + font-family: inherit; + font-size: 0.8125rem; + text-align: left; + color: var(--bsplus-analytics-text); + cursor: pointer; + transition: background 0.15s ease; +} + +.bsplus-analytics-dropdown-item:hover { + background: color-mix(in srgb, var(--bsplus-analytics-surface-2) 90%, transparent); +} + +.bsplus-analytics-dropdown-item.is-selected { + background: color-mix(in srgb, var(--bsplus-analytics-accent) 12%, transparent); + color: var(--bsplus-analytics-accent); + font-weight: 600; +} + +.bsplus-analytics-dropdown-check { + width: 1rem; + flex-shrink: 0; + text-align: center; +} + +/* ─── Chart grid & cards ─── */ +.bsplus-analytics-charts { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; + width: 100%; + position: relative; + z-index: 1; +} + +/* Fade-in animation must not paint above the filter toolbar / dropdown */ +.bsplus-analytics-charts .bsplus-analytics-animate { + z-index: 1; +} + +@media (min-width: 960px) { + .bsplus-analytics-charts { + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + } +} + +.bsplus-analytics-card { + display: flex; + flex-direction: column; + border-radius: var(--bsplus-analytics-radius); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow); + overflow: hidden; + transition: + transform 0.3s var(--bsplus-analytics-ease), + box-shadow 0.3s var(--bsplus-analytics-ease); +} + +.bsplus-analytics-card:hover { + box-shadow: var(--bsplus-analytics-shadow-hover); +} + +.bsplus-analytics-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1.15rem 1.25rem; + border-bottom: 1px solid var(--bsplus-analytics-border); +} + +.bsplus-analytics-card-header-split { + flex-wrap: wrap; +} + +.bsplus-analytics-card-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; +} + +.bsplus-analytics-card-control { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 9.5rem; +} + +.bsplus-analytics-select-compact { + min-width: 9.5rem; + min-height: 2.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.8125rem; +} + +.bsplus-analytics-scale-hint { + margin: 0.65rem 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-footer-muted { + color: var(--bsplus-analytics-muted); + font-weight: 400; +} + +.bsplus-analytics-card-title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--bsplus-analytics-text); +} + +.bsplus-analytics-card-desc { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-card-body { + padding: 1rem 1.15rem; + flex: 1; + background: var(--bsplus-analytics-surface); +} + +.bsplus-analytics-card-footer { + padding: 0.85rem 1.25rem 1.1rem; + border-top: 1px solid var(--bsplus-analytics-border); + font-size: 0.8125rem; + color: var(--bsplus-analytics-muted); + line-height: 1.5; +} + +.bsplus-analytics-card-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 220px; + text-align: center; + gap: 0.35rem; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-card-empty strong { + color: var(--bsplus-analytics-text); + font-size: 1rem; +} + +/* ─── Layerchart / SVG (fix default black rects in dark UI) ─── */ +.bsplus-chart-host { + display: flex; + justify-content: center; + width: 100%; + overflow: visible; + color: var(--bsplus-analytics-muted); +} + +.bsplus-analytics-root .bsplus-chart-surface { + height: 280px; + min-height: 280px; + max-height: 280px; +} + +.bsplus-analytics-root .bsplus-chart-surface-bar { + height: 320px; + min-height: 320px; + max-height: 320px; +} + +/* Bar chart: show axis spines and tick marks (area chart hides these) */ +.bsplus-analytics-root .bsplus-chart-surface-bar .lc-rule-x-line:not(.lc-grid-x-rule), +.bsplus-analytics-root .bsplus-chart-surface-bar .lc-rule-y-line:not(.lc-grid-y-rule) { + stroke: color-mix(in srgb, var(--bsplus-analytics-muted) 45%, transparent) !important; +} + +.bsplus-analytics-root .bsplus-chart-surface-bar .lc-axis-tick { + stroke: color-mix(in srgb, var(--bsplus-analytics-muted) 55%, transparent); +} + +.bsplus-analytics-root .bsplus-chart-surface-bar .lc-axis-tick-label, +.bsplus-analytics-root .bsplus-chart-surface-bar .bsplus-bar-tick-label { + fill: var(--bsplus-analytics-text) !important; + font-size: 0.7rem; + font-weight: 500; +} + +.bsplus-analytics-root .bsplus-chart-surface-bar .lc-axis-label { + fill: var(--bsplus-analytics-muted) !important; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.bsplus-analytics-root [data-slot="chart"] svg { + background: transparent !important; + overflow: visible; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-root-container, +.bsplus-analytics-root [data-slot="chart"] .lc-layout-svg-g { + width: 100% !important; + height: 100% !important; + background: transparent !important; +} + +/* Critical: layerchart layout group defaults to black fill */ +.bsplus-analytics-root [data-slot="chart"] .lc-layout-svg-g, +.bsplus-analytics-root [data-slot="chart"] .lc-tooltip-rects-g, +.bsplus-analytics-root [data-slot="chart"] .lc-highlight-area, +.bsplus-analytics-root [data-slot="chart"] .lc-frame { + fill: transparent !important; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-grid-x-rule, +.bsplus-analytics-root [data-slot="chart"] .lc-grid-y-rule { + stroke: color-mix(in srgb, var(--bsplus-analytics-muted) 28%, transparent); + stroke-opacity: 1; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-rule-x-line:not(.lc-grid-x-rule), +.bsplus-analytics-root [data-slot="chart"] .lc-rule-y-line:not(.lc-grid-y-rule) { + stroke: transparent !important; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-axis-tick { + stroke: transparent; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-axis-tick-label { + fill: var(--bsplus-analytics-muted) !important; + font-weight: 400; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-line, +.bsplus-analytics-root [data-slot="chart"] .lc-spline-path { + stroke: var(--bsplus-analytics-accent); +} + +.bsplus-analytics-root [data-slot="chart"] .lc-area-path { + opacity: 1; +} + +/* Bar series — force accent fill (bars were invisible/black) */ +.bsplus-analytics-root [data-slot="chart"] .lc-bar-path, +.bsplus-analytics-root [data-slot="chart"] .lc-bar rect, +.bsplus-analytics-root [data-slot="chart"] g[class*="bar"] rect, +.bsplus-analytics-root [data-slot="chart"] rect.lc-bar { + fill: var(--color-count, var(--bsplus-analytics-accent)) !important; + stroke: none !important; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-highlight-line { + stroke: transparent; +} + +.bsplus-analytics-root [data-slot="chart"] .lc-legend-swatch { + border-radius: 3px; +} + +.bsplus-analytics-trend-up { + color: #16a34a; + font-weight: 600; +} + +.bsplus-analytics-trend-down { + color: #dc2626; + font-weight: 600; +} + +/* ─── Table ─── */ +.bsplus-analytics-table-wrap { + border-radius: var(--bsplus-analytics-radius); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow); + overflow: hidden; +} + +.bsplus-analytics-table-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--bsplus-analytics-border); +} + +.bsplus-analytics-table-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; +} + +.bsplus-analytics-table-scroll { + overflow-x: auto; +} + +.bsplus-analytics-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.bsplus-analytics-table thead { + background: color-mix(in srgb, var(--bsplus-analytics-surface-2) 85%, transparent); +} + +.bsplus-analytics-table th { + padding: 0.75rem 1rem; + font-weight: 600; + text-align: left; + color: var(--bsplus-analytics-muted); + white-space: nowrap; +} + +.bsplus-analytics-table th button { + font: inherit; + font-weight: 600; + color: inherit; + background: none; + border: none; + cursor: pointer; + padding: 0; + transition: color 0.15s ease; +} + +.bsplus-analytics-table th button:hover { + color: var(--bsplus-analytics-accent); +} + +.bsplus-analytics-table td { + padding: 0.7rem 1rem; + border-top: 1px solid var(--bsplus-analytics-border); + color: var(--bsplus-analytics-text); +} + +.bsplus-analytics-table tbody tr { + transition: background 0.15s ease; +} + +.bsplus-analytics-table tbody tr:hover { + background: color-mix(in srgb, var(--bsplus-analytics-accent) 6%, transparent); +} + +.bsplus-analytics-table .cell-title { + max-width: 16rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.bsplus-analytics-grade-pill { + display: inline-flex; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.75rem; + background: color-mix(in srgb, var(--bsplus-analytics-accent) 14%, transparent); + color: var(--bsplus-analytics-accent); +} + +.bsplus-analytics-table-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.25rem; + border-top: 1px solid var(--bsplus-analytics-border); + color: var(--bsplus-analytics-muted); + font-size: 0.8125rem; +} + +.bsplus-analytics-table-footer select { + margin-left: 0.35rem; + padding: 0.35rem 0.5rem; + border-radius: 8px; + border: 2px solid var(--bsplus-analytics-border); + background: var(--bsplus-analytics-surface-2); + color: var(--bsplus-analytics-text); + font-family: inherit; +} + +.bsplus-analytics-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding-bottom: 0.5rem; +} + +/* ─── States ─── */ +.bsplus-analytics-alert { + padding: 0.75rem 1rem; + border-radius: var(--bsplus-analytics-radius-sm); + border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + color: var(--bsplus-analytics-text); + font-size: 0.8125rem; +} + +.bsplus-analytics-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 16rem; +} + +.bsplus-analytics-spinner { + width: 3rem; + height: 3rem; + border-radius: 50%; + border: 4px solid var(--bsplus-analytics-border); + border-top-color: var(--bsplus-analytics-accent); + animation: bsplus-analytics-spin 0.75s linear infinite; +} + +.bsplus-analytics-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 2rem; + text-align: center; + border-radius: var(--bsplus-analytics-radius); + background: var(--bsplus-analytics-surface); + border: 1px solid var(--bsplus-analytics-border); + box-shadow: var(--bsplus-analytics-shadow); +} + +.bsplus-analytics-empty h2 { + margin: 0; + font-size: 1.25rem; +} + +.bsplus-analytics-empty p { + margin: 0; + max-width: 28rem; + color: var(--bsplus-analytics-muted); + line-height: 1.55; +} + +/* Legacy accent helpers for any remaining utility classes */ +.bsplus-analytics-root .accent-bg { + background-color: var(--bsplus-analytics-accent) !important; +} + +.bsplus-analytics-root .accent-ring { + --tw-ring-color: var(--bsplus-analytics-accent); +} diff --git a/src/plugins/built-in/gradeAnalytics/timeRange.ts b/src/plugins/built-in/gradeAnalytics/timeRange.ts new file mode 100644 index 00000000..0a2817c2 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/timeRange.ts @@ -0,0 +1,224 @@ +import type { Assessment } from "./types"; + +export type TimeRange = "all" | "365d" | "90d" | "30d" | "7d"; + +export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ + { value: "all", label: "All time" }, + { value: "365d", label: "Last 12 months" }, + { value: "90d", label: "Last 3 months" }, + { value: "30d", label: "Last 30 days" }, + { value: "7d", label: "Last 7 days" }, +]; + +export function getTimeRangeLabel(timeRange: TimeRange): string { + return TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label ?? "All time"; +} + +export function getTimeRangeCutoff(timeRange: TimeRange): Date | null { + if (timeRange === "all") return null; + const referenceDate = new Date(); + let daysToSubtract = 90; + if (timeRange === "30d") daysToSubtract = 30; + else if (timeRange === "7d") daysToSubtract = 7; + else if (timeRange === "365d") daysToSubtract = 365; + const cutoff = new Date(referenceDate); + cutoff.setDate(cutoff.getDate() - daysToSubtract); + cutoff.setHours(0, 0, 0, 0); + return cutoff; +} + +export function filterAssessmentsByTimeRange( + assessments: Assessment[], + timeRange: TimeRange, +): Assessment[] { + const cutoff = getTimeRangeCutoff(timeRange); + if (!cutoff) return assessments; + return assessments.filter((a) => new Date(a.due) >= cutoff); +} + +export type TrendPoint = { + date: Date; + average: number; + count: number; + [seriesKey: string]: number | Date; +}; + +export type TrendSeries = { + key: string; + label: string; + color: string; + isOverall?: boolean; +}; + +const SUBJECT_CHART_COLORS = [ + "#2563eb", + "#16a34a", + "#ca8a04", + "#9333ea", + "#0891b2", + "#ea580c", + "#db2777", + "#4f46e5", + "#0d9488", + "#b45309", + "#7c3aed", + "#dc2626", +]; + +export function subjectChartColor(index: number): string { + return SUBJECT_CHART_COLORS[index % SUBJECT_CHART_COLORS.length]; +} + +function periodKeyForAssessment( + assessment: Assessment, + useMonthlyGrouping: boolean, +): string { + const date = new Date(assessment.due); + if (useMonthlyGrouping) { + return date.toISOString().slice(0, 7); + } + const monday = new Date(date); + const dayOfWeek = date.getDay(); + const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); + monday.setDate(diff); + return monday.toISOString().slice(0, 10); +} + +function periodDate(periodKey: string, useMonthlyGrouping: boolean): Date { + return useMonthlyGrouping ? new Date(`${periodKey}-01`) : new Date(periodKey); +} + +function average(nums: number[]): number { + return nums.reduce((sum, g) => sum + g, 0) / nums.length; +} + +function slugSubjectKey(name: string, keyBySubject: Map): string { + if (keyBySubject.has(name)) return keyBySubject.get(name)!; + let base = + name + .trim() + .replace(/[^\w]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48) || "subject"; + const taken = new Set(keyBySubject.values()); + let candidate = base; + let n = 2; + while (taken.has(candidate)) { + candidate = `${base}_${n}`; + n++; + } + keyBySubject.set(name, candidate); + return candidate; +} + +export function buildGradeTrendChart( + data: Assessment[], + timeRange: TimeRange, + options: { showPerSubject?: boolean } = {}, +): { points: TrendPoint[]; series: TrendSeries[]; accentColor: string } { + const accentColor = + "var(--bsplus-analytics-accent, var(--better-main, #007bff))"; + + const graded = data.filter( + (a) => a.finalGrade !== undefined && a.finalGrade !== null, + ); + if (!graded.length) { + return { points: [], series: [], accentColor }; + } + + const useMonthlyGrouping = timeRange === "365d" || timeRange === "all"; + const cutoff = getTimeRangeCutoff(timeRange); + + const overallBuckets = new Map(); + const subjectBuckets = new Map>(); + const subjectLabels = new Map(); + const keyBySubject = new Map(); + + for (const assessment of graded) { + const grade = assessment.finalGrade!; + const periodKey = periodKeyForAssessment(assessment, useMonthlyGrouping); + const periodDateValue = periodDate(periodKey, useMonthlyGrouping); + if (cutoff && periodDateValue < cutoff) continue; + + if (!overallBuckets.has(periodKey)) overallBuckets.set(periodKey, []); + overallBuckets.get(periodKey)!.push(grade); + + if (options.showPerSubject) { + const subject = assessment.subject; + if (!subjectBuckets.has(subject)) { + subjectBuckets.set(subject, new Map()); + subjectLabels.set(subject, subject); + slugSubjectKey(subject, keyBySubject); + } + const buckets = subjectBuckets.get(subject)!; + if (!buckets.has(periodKey)) buckets.set(periodKey, []); + buckets.get(periodKey)!.push(grade); + } + } + + const periodKeys = new Set(overallBuckets.keys()); + if (options.showPerSubject) { + for (const buckets of subjectBuckets.values()) { + for (const key of buckets.keys()) periodKeys.add(key); + } + } + + const points: TrendPoint[] = Array.from(periodKeys) + .sort() + .map((periodKey) => { + const grades = overallBuckets.get(periodKey) ?? []; + const point: TrendPoint = { + date: periodDate(periodKey, useMonthlyGrouping), + average: grades.length ? average(grades) : NaN, + count: grades.length, + }; + + if (options.showPerSubject) { + for (const [subject, buckets] of subjectBuckets) { + const seriesKey = keyBySubject.get(subject)!; + const subjectGrades = buckets.get(periodKey); + if (subjectGrades?.length) { + point[seriesKey] = average(subjectGrades); + } + } + } + + return point; + }) + .filter((p) => { + if (!Number.isNaN(p.average)) return true; + if (!options.showPerSubject) return false; + return Object.keys(p).some( + (key) => + key !== "date" && + key !== "average" && + key !== "count" && + typeof p[key] === "number" && + !Number.isNaN(p[key] as number), + ); + }); + + const series: TrendSeries[] = [ + { + key: "average", + label: "Overall average", + color: accentColor, + isOverall: true, + }, + ]; + + if (options.showPerSubject) { + const subjects = [...subjectLabels.keys()].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + subjects.forEach((subject, index) => { + series.push({ + key: keyBySubject.get(subject)!, + label: subject, + color: subjectChartColor(index), + }); + }); + } + + return { points, series, accentColor }; +} diff --git a/src/plugins/built-in/gradeAnalytics/types.ts b/src/plugins/built-in/gradeAnalytics/types.ts new file mode 100644 index 00000000..c774d920 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/types.ts @@ -0,0 +1,29 @@ +export type AssessmentStatus = "OVERDUE" | "MARKS_RELEASED" | "PENDING"; + +export interface Assessment { + id: number; + title: string; + subject: string; + status: AssessmentStatus; + due: string; + code: string; + metaclassID: number; + programmeID: number; + graded: boolean; + overdue: boolean; + hasFeedback: boolean; + expectationsEnabled: boolean; + expectationsCompleted: boolean; + reflectionsEnabled: boolean; + reflectionsCompleted: boolean; + availability: string; + finalGrade?: number; + letterGrade?: string; +} + +export type AnalyticsData = Assessment[]; + +export interface AnalyticsCache { + updatedAt: number; + assessments: Assessment[]; +} diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts new file mode 100644 index 00000000..92b3352f --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -0,0 +1,186 @@ +import tailwindStyles from "@/interface/index.css?inline"; +import pluginStyles from "./styles.css?inline"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { mount, unmount } from "svelte"; +import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte"; + +type ThemeSettingKey = + | "selectedColor" + | "DarkMode" + | "adaptiveThemeColour" + | "adaptiveThemeGradient" + | "selectedTheme"; + +type ThemeListenerRegistration = { + key: ThemeSettingKey; + listener: () => void; +}; + +let currentApp: ReturnType | null = null; +let shadowHost: HTMLElement | null = null; +let analyticsRoot: HTMLElement | null = null; +let darkModeObserver: MutationObserver | null = null; +let themeStyleObserver: MutationObserver | null = null; +let themeListeners: ThemeListenerRegistration[] = []; + +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; + +/** Resolve a solid colour for charts (gradients → first stop). */ +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 syncThemeFromPage(target: HTMLElement) { + const computed = getComputedStyle(document.documentElement); + + for (const name of THEME_CSS_VARS) { + const value = computed.getPropertyValue(name).trim(); + if (value) { + target.style.setProperty(name, value); + } + } + + const accent = resolvePageAccentColor(); + target.style.setProperty("--bsplus-analytics-accent", accent); + target.style.setProperty("--better-main", accent); + + target.classList.toggle( + "dark", + document.documentElement.classList.contains("dark"), + ); +} + +function syncThemeToAnalyticsUi() { + if (shadowHost) syncThemeFromPage(shadowHost); + if (analyticsRoot) syncThemeFromPage(analyticsRoot); +} + +function clearThemeListeners() { + for (const { key, listener } of themeListeners) { + settingsState.unregister(key, listener); + } + themeListeners = []; +} + +function watchThemeChanges() { + clearThemeListeners(); + + const keys: ThemeSettingKey[] = [ + "selectedColor", + "DarkMode", + "adaptiveThemeColour", + "adaptiveThemeGradient", + "selectedTheme", + ]; + + const listener = () => syncThemeToAnalyticsUi(); + for (const key of keys) { + settingsState.register(key, listener); + themeListeners.push({ key, listener }); + } + + themeStyleObserver?.disconnect(); + themeStyleObserver = new MutationObserver(() => syncThemeToAnalyticsUi()); + themeStyleObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style", "class"], + }); +} + +function teardown() { + clearThemeListeners(); + themeStyleObserver?.disconnect(); + themeStyleObserver = null; + + if (currentApp) { + unmount(currentApp); + currentApp = null; + } + darkModeObserver?.disconnect(); + darkModeObserver = null; + shadowHost?.remove(); + shadowHost = null; + analyticsRoot = null; +} + +export function renderAnalyticsPage(container: HTMLElement) { + teardown(); + + container.innerHTML = ""; + container.className = "bsplus-analytics-container"; + + shadowHost = document.createElement("div"); + shadowHost.className = "bsplus-analytics-host"; + container.appendChild(shadowHost); + + const shadow = shadowHost.attachShadow({ mode: "open" }); + + const styleElement = document.createElement("style"); + styleElement.textContent = `${tailwindStyles}\n${pluginStyles}`; + shadow.appendChild(styleElement); + + analyticsRoot = document.createElement("div"); + analyticsRoot.className = "bsplus-analytics-root"; + syncThemeToAnalyticsUi(); + shadow.appendChild(analyticsRoot); + + watchThemeChanges(); + + darkModeObserver = new MutationObserver(() => syncThemeToAnalyticsUi()); + darkModeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + currentApp = mount(GradeAnalyticsPage, { target: analyticsRoot }); +} + +export function unmountAnalyticsPage() { + teardown(); +} diff --git a/src/plugins/built-in/gradeAnalytics/utils/cn.ts b/src/plugins/built-in/gradeAnalytics/utils/cn.ts new file mode 100644 index 00000000..abc8c5f4 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/utils/cn.ts @@ -0,0 +1,3 @@ +export function cn(...classes: (string | false | null | undefined)[]): string { + return classes.filter(Boolean).join(" "); +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 009e9e43..9a6ca1fa 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -15,6 +15,7 @@ import messageFoldersPlugin from "./built-in/messageFolders"; // Heavy plugins (lazy-loaded only when enabled) import globalSearchPluginLazy from "./built-in/globalSearch/lazy"; +import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy"; // Initialize plugin manager const pluginManager = PluginManager.getInstance(); @@ -34,6 +35,7 @@ pluginManager.registerPlugin(messageFoldersPlugin); // Register heavy plugins with lazy loading pluginManager.registerPlugin(globalSearchPluginLazy); +pluginManager.registerPlugin(gradeAnalyticsPluginLazy); export { init as Monofile } from "./monofile"; diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index c74d39ea..82f707e9 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -25,6 +25,7 @@ import { updateEngageHomeMenuActive, } from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; +import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; @@ -202,9 +203,7 @@ function SortMessagePageItems(messagesParentElement: any) { async function LoadPageElements(): Promise { await AddBetterSEQTAElements(); - const sublink: string | undefined = isSeqtaEngageExperience() - ? getEngageRoutePage() - : window.location.href.split("/")[4]; + const sublink: string | undefined = getEngageRoutePage(); if (isSeqtaEngageExperience() && !engageHashListenerAttached) { engageHashListenerAttached = true; @@ -335,6 +334,11 @@ async function handleSublink(sublink: string | undefined): Promise { case "news": await handleNewsPage(); break; + case "analytics": + console.info("[BetterSEQTA+] Started Init (Analytics)"); + if (settingsState.onoff) void loadAnalyticsPage(); + finishLoad(); + break; case undefined: window.location.replace( `${location.origin}/#?page=/${settingsState.defaultPage}`, diff --git a/src/seqta/ui/dev/hideSensitiveContent.ts b/src/seqta/ui/dev/hideSensitiveContent.ts index 145e1f60..d2b44e7d 100644 --- a/src/seqta/ui/dev/hideSensitiveContent.ts +++ b/src/seqta/ui/dev/hideSensitiveContent.ts @@ -705,6 +705,52 @@ export function getMockNotices() { }; } +export function getMockGradeAnalyticsData() { + const { assessments } = getMockAssessmentsData(); + return assessments + .filter((a: { percentage?: number; results?: { percentage?: number } }) => { + const pct = a.percentage ?? a.results?.percentage; + return pct != null; + }) + .map( + ( + a: { + id: number; + title: string; + code: string; + due: string; + percentage?: number; + results?: { percentage?: number }; + programmeID: number; + metaclassID: number; + }, + ) => { + const finalGrade = a.percentage ?? a.results?.percentage ?? 0; + return { + id: a.id, + title: a.title, + subject: a.code, + status: "MARKS_RELEASED" as const, + due: a.due, + code: a.code, + metaclassID: a.metaclassID, + programmeID: a.programmeID, + graded: true, + overdue: false, + hasFeedback: false, + expectationsEnabled: false, + expectationsCompleted: false, + reflectionsEnabled: false, + reflectionsCompleted: false, + availability: "", + finalGrade, + letterGrade: + finalGrade >= 85 ? "A" : finalGrade >= 70 ? "B" : "C", + }; + }, + ); +} + export function getMockAssessmentsData() { const subjects = mockData.subjects.slice(0, 5).map((title, i) => ({ code: `SUBJ${i + 1}`,