feat: analytics page

This commit is contained in:
2026-06-01 19:43:47 +09:30
parent 65cd0a1c4f
commit 2b7c5e17b6
25 changed files with 4326 additions and 3 deletions
+5
View File
@@ -40,6 +40,8 @@
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.1.2", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0", "@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/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -83,6 +85,8 @@
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^5.0.0", "color": "^5.0.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"embeddia": "^1.3.0", "embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
@@ -92,6 +96,7 @@
"flexsearch": "^0.8.147", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"idb": "^8.0.2", "idb": "^8.0.2",
"layerchart": "2.0.0-next.27",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
@@ -0,0 +1,388 @@
<script lang="ts">
import * as Chart from "./chart/index";
import { scaleUtc, scaleLinear } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { curveNatural } from "d3-shape";
import { cubicInOut } from "svelte/easing";
import type { Assessment } from "./types";
import {
buildGradeTrendChart,
getTimeRangeLabel,
type TimeRange,
} from "./timeRange";
interface Props {
data: Assessment[];
timeRange: TimeRange;
showSubjectTrends?: boolean;
}
let { data, timeRange, showSubjectTrends = false }: Props = $props();
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
const chartResult = $derived(() =>
buildGradeTrendChart(data, timeRange, {
showPerSubject: showSubjectTrends,
}),
);
const filteredData = $derived(() => chartResult().points);
const chartSeries = $derived(() => chartResult().series);
const accentColor = $derived(() => chartResult().accentColor);
const chartConfig = $derived(() => {
const config: Chart.ChartConfig = {};
for (const s of chartSeries()) {
config[s.key] = { label: s.label, color: s.color };
}
return config;
});
const yScale = $derived.by(() => {
const points = filteredData();
const series = chartSeries();
if (!points.length) return scaleLinear().domain([0, 100]);
const values: number[] = [];
for (const p of points) {
for (const s of series) {
const v = p[s.key];
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
}
}
if (!values.length) return scaleLinear().domain([0, 100]);
const min = Math.max(0, Math.min(...values) - 8);
const max = Math.min(100, Math.max(...values) + 8);
return scaleLinear().domain([min, max]).nice();
});
const trend = $derived(() => {
const points = filteredData();
if (points.length < 2) return { percentage: "0", direction: "neutral" as const };
const recent = points.slice(-2);
const change = recent[1].average - recent[0].average;
return {
percentage: Math.abs(change).toFixed(1),
direction: change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
};
});
const areaSeries = $derived(() =>
chartSeries().map((s) => ({
key: s.key,
label: s.label,
color: s.color,
})),
);
</script>
<article class="bsplus-analytics-card">
<header class="bsplus-analytics-card-header">
<div>
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
<p class="bsplus-analytics-card-desc">
{#if showSubjectTrends}
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
{:else}
Average grades over time · {getTimeRangeLabel(timeRange)}
{/if}
</p>
</div>
</header>
<div class="bsplus-analytics-card-body">
{#if filteredData().length > 0}
<Chart.Container config={chartConfig()} class="bsplus-chart-surface w-full">
<AreaChart
legend
data={filteredData()}
x="date"
xScale={scaleUtc()}
yScale={yScale()}
series={areaSeries()}
props={{
area: {
curve: curveNatural,
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
line: { class: "stroke-2" },
motion: "tween",
},
xAxis: {
ticks: timeRange === "7d" ? 7 : undefined,
format: (v: Date) =>
v.toLocaleDateString("en-US", {
month: "short",
day: timeRange === "7d" ? "numeric" : undefined,
}),
},
yAxis: {
format: (v: number) => `${v.toFixed(0)}%`,
},
}}
>
{#snippet marks({ series, getAreaProps })}
<defs>
<linearGradient id={chartUid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={accentColor()} stop-opacity="0.55" />
<stop offset="100%" stop-color={accentColor()} stop-opacity="0.04" />
</linearGradient>
</defs>
<ChartClipPath
initialWidth={0}
motion={{
width: { type: "tween", duration: 900, easing: cubicInOut },
}}
>
{#each series as s, i (s.key)}
{@const meta = chartSeries().find((c) => c.key === s.key)}
{@const isOverall = meta?.isOverall ?? s.key === "average"}
<Area
{...getAreaProps(s, i)}
fill={isOverall && !showSubjectTrends
? `url(#${chartUid})`
: isOverall
? accentColor()
: "transparent"}
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
stroke={meta?.color ?? s.color}
style={`stroke: ${meta?.color ?? s.color}`}
/>
{/each}
</ChartClipPath>
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(v: Date) =>
v.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="bsplus-analytics-card-empty">
<strong>No grade data for this range</strong>
<span>Complete assessments with released marks to see trends.</span>
</div>
{/if}
</div>
<footer class="bsplus-analytics-card-footer">
{#if trend().direction === "up"}
<span class="bsplus-analytics-trend-up"
>Trending up · {trend().percentage}% vs previous period</span
>
{:else if trend().direction === "down"}
<span class="bsplus-analytics-trend-down"
>Trending down · {trend().percentage}% vs previous period</span
>
{:else}
<span>Grades remain stable across this period</span>
{/if}
<br />
<span>
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
{#if showSubjectTrends && chartSeries().length > 1}
· {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"}
{/if}
</span>
</footer>
</article>
@@ -0,0 +1,408 @@
<script lang="ts">
import { onMount } from "svelte";
import { scaleBand, scaleLinear } from "d3-scale";
import { BarChart } from "layerchart";
import * as Chart from "./chart/index";
import { cubicInOut } from "svelte/easing";
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import type { Assessment } from "./types";
import { getTimeRangeLabel, type TimeRange } from "./timeRange";
import {
buildGradeDistribution,
DISTRIBUTION_MODE_OPTIONS,
type DistributionMode,
} from "./gradeDistribution";
import { loadDistributionMode, saveDistributionMode } from "./storage";
interface Props {
data: Assessment[];
timeRange: TimeRange;
}
let { data, timeRange }: Props = $props();
let distributionMode: DistributionMode = $state("auto");
let modeReady = $state(false);
let studentId: number | null = $state(null);
const accentColor =
"var(--bsplus-analytics-accent, var(--better-main, #007bff))";
const distribution = $derived(() =>
buildGradeDistribution(data, distributionMode),
);
const chartData = $derived(() =>
distribution().buckets.map((b) => ({
grade: b.label,
count: b.count,
minPercent: b.minPercent,
maxPercent: b.maxPercent,
})),
);
const useLetterScaleLabels = $derived(() => distribution().modeUsed === "letter");
function formatXTick(label: string): string {
if (!useLetterScaleLabels()) return label;
const row = chartData().find((d) => d.grade === label);
if (
row?.minPercent !== undefined &&
row?.maxPercent !== undefined &&
!(row.minPercent === 0 && row.maxPercent === 100)
) {
return `${label}\n${Math.round(row.minPercent)}${Math.round(row.maxPercent)}%`;
}
return label;
}
const chartConfig = $derived(() => {
const config: Chart.ChartConfig = {
count: { label: "Assessments", color: accentColor },
};
return config;
});
const yMax = $derived(Math.max(1, ...chartData().map((d) => d.count)));
const yScale = $derived(scaleLinear().domain([0, yMax]).nice());
const totalAssessments = $derived(distribution().gradedCount);
const modeOptionLabel = $derived(
DISTRIBUTION_MODE_OPTIONS.find((o) => o.value === distributionMode)?.label ??
"Auto",
);
const subtitle = $derived(() => {
const d = distribution();
if (d.modeUsed === "letter") {
return `Assessments per letter grade · ${getTimeRangeLabel(timeRange)}`;
}
return `Assessments per grade band · ${getTimeRangeLabel(timeRange)}`;
});
onMount(async () => {
try {
const info = await getUserInfo();
if (info?.id) {
studentId = info.id;
const saved = await loadDistributionMode(location.origin, info.id);
if (saved) distributionMode = saved;
}
} catch {
/* use default */
} finally {
modeReady = true;
}
});
async function onModeChange(next: DistributionMode) {
distributionMode = next;
if (studentId != null) {
await saveDistributionMode(location.origin, studentId, next);
}
}
</script>
<article class="bsplus-analytics-card">
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
<div>
<h3 class="bsplus-analytics-card-title">Grade distribution</h3>
<p class="bsplus-analytics-card-desc">{subtitle()}</p>
</div>
<div class="bsplus-analytics-card-controls">
<label class="bsplus-analytics-card-control">
<span class="bsplus-analytics-field-label">Grouping</span>
<select
class="bsplus-analytics-select bsplus-analytics-select-compact"
value={distributionMode}
disabled={!modeReady}
aria-label="Grade distribution grouping"
onchange={(e) => onModeChange(e.currentTarget.value as DistributionMode)}
>
{#each DISTRIBUTION_MODE_OPTIONS as option}
<option value={option.value} title={option.description}>{option.label}</option>
{/each}
</select>
</label>
</div>
</header>
<div class="bsplus-analytics-card-body">
{#if totalAssessments > 0 && chartData().length > 0}
<Chart.Container config={chartConfig()} class="bsplus-chart-surface bsplus-chart-surface-bar w-full">
<BarChart
data={chartData()}
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
yScale={yScale()}
x="grade"
y="count"
axis={true}
grid={true}
series={[
{
key: "count",
label: "Assessments",
color: accentColor,
},
]}
props={{
bars: {
stroke: "none",
fill: accentColor,
rounded: "all",
radius: 10,
insets: { top: 4, bottom: 0, left: 4, right: 4 },
motion: {
y: { type: "tween", duration: 600, easing: cubicInOut },
height: { type: "tween", duration: 600, easing: cubicInOut },
},
},
highlight: { area: { fill: "none" } },
xAxis: {
format: (d: string) => 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()}
<Chart.Tooltip hideLabel />
{/snippet}
</BarChart>
</Chart.Container>
{#if distribution().modeUsed === "letter"}
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
{/if}
{:else}
<div class="bsplus-analytics-card-empty">
<strong>No graded assessments</strong>
<span>for {getTimeRangeLabel(timeRange).toLowerCase()}</span>
</div>
{/if}
</div>
<footer class="bsplus-analytics-card-footer">
{#if distribution().averagePercent !== null}
Average <strong>{distribution().averagePercent}%</strong>
{:else}
Average <strong></strong>
{/if}
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
{:else if distributionMode !== "auto"}
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
{/if}
</footer>
</article>
@@ -0,0 +1,152 @@
<script lang="ts">
import type { Assessment } from "./types";
interface Props {
data: Assessment[];
}
let { data }: Props = $props();
let currentPage = $state(0);
let itemsPerPage = $state(10);
let sortColumn = $state<keyof Assessment | null>("due");
let sortDirection = $state<"asc" | "desc">("desc");
const sortedData = $derived.by(() => {
const list = [...data];
if (!sortColumn) return list;
list.sort((a, b) => {
const av = a[sortColumn!];
const bv = b[sortColumn!];
if (av === bv) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : 1;
return sortDirection === "asc" ? cmp : -cmp;
});
return list;
});
const pageCount = $derived(Math.max(1, Math.ceil(sortedData.length / itemsPerPage)));
const pageData = $derived(
sortedData.slice(
currentPage * itemsPerPage,
(currentPage + 1) * itemsPerPage,
),
);
function toggleSort(column: keyof Assessment) {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = "asc";
}
currentPage = 0;
}
function formatStatus(status: string) {
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
}
function gradeDisplay(a: Assessment) {
if (a.finalGrade !== undefined) {
return a.letterGrade
? `${a.finalGrade}% (${a.letterGrade})`
: `${a.finalGrade}%`;
}
return a.letterGrade ?? "—";
}
</script>
<section class="bsplus-analytics-table-wrap">
<header class="bsplus-analytics-table-header">
<h2>Assessment history</h2>
</header>
<div class="bsplus-analytics-table-scroll">
<table class="bsplus-analytics-table">
<thead>
<tr>
{#each [
["title", "Title"],
["subject", "Subject"],
["due", "Due"],
["status", "Status"],
["finalGrade", "Grade"],
] as [col, label]}
<th>
<button type="button" onclick={() => toggleSort(col as keyof Assessment)}>
{label}
{#if sortColumn === col}
{sortDirection === "asc" ? " ↑" : " ↓"}
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each pageData as row (row.id)}
<tr>
<td class="cell-title" title={row.title}>{row.title}</td>
<td>{row.subject}</td>
<td style="white-space: nowrap">
{new Date(row.due).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td>{formatStatus(row.status)}</td>
<td>
{#if row.finalGrade !== undefined}
<span class="bsplus-analytics-grade-pill">{gradeDisplay(row)}</span>
{:else}
{gradeDisplay(row)}
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: var(--bsplus-analytics-muted)">
No assessments match your filters
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<footer class="bsplus-analytics-table-footer">
<label>
Rows per page
<select bind:value={itemsPerPage} onchange={() => (currentPage = 0)}>
{#each [5, 10, 20, 50] as n}
<option value={n}>{n}</option>
{/each}
</select>
</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
disabled={currentPage === 0}
onclick={() => currentPage--}
>
Previous
</button>
<span>Page {currentPage + 1} of {pageCount}</span>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
disabled={currentPage >= pageCount - 1}
onclick={() => currentPage++}
>
Next
</button>
</div>
</footer>
</section>
@@ -0,0 +1,436 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { fade } from "svelte/transition";
import type { Assessment } from "./types";
import {
loadGradeAnalytics,
syncGradeAnalytics,
getCacheTtlMs,
} from "./api";
import AnalyticsAreaChart from "./AnalyticsAreaChart.svelte";
import AnalyticsBarChart from "./AnalyticsBarChart.svelte";
import AssessmentTable from "./AssessmentTable.svelte";
import {
filterAssessmentsByTimeRange,
getTimeRangeLabel,
TIME_RANGE_OPTIONS,
type TimeRange,
} from "./timeRange";
let analyticsData: Assessment[] | null = $state(null);
let loading = $state(true);
let syncing = $state(false);
let lastUpdated: Date | null = $state(null);
let timestampRefresh = $state(0);
let error: string | null = $state(null);
let filterSubjects: string[] = $state([]);
let filterSearch = $state("");
let gradeRange = $state([0, 100]);
let showSubjectsDropdown = $state(false);
let showTimeRangeDropdown = $state(false);
let timeRange: TimeRange = $state("all");
let showSubjectTrends = $state(false);
let timestampInterval: ReturnType<typeof setInterval> | null = null;
const formattedTimestamp = $derived(() => {
if (!lastUpdated) return "";
timestampRefresh;
return formatLastUpdated(lastUpdated);
});
const uniqueSubjects = $derived(() => {
if (!analyticsData) return [];
return [...new Set(analyticsData.map((a) => a.subject))].sort();
});
const filteredData = $derived(() => {
if (!analyticsData) return [];
const [minG, maxG] = gradeRange;
return analyticsData.filter((a) => {
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
const grade = a.finalGrade ?? -1;
if (grade < minG || grade > maxG) return false;
if (
filterSearch &&
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
!a.subject.toLowerCase().includes(filterSearch.toLowerCase())
) {
return false;
}
return true;
});
});
const timeScopedData = $derived(() =>
filterAssessmentsByTimeRange(filteredData(), timeRange),
);
const gradedFiltered = $derived(() =>
timeScopedData().filter((a) => a.finalGrade !== undefined),
);
const statsAverage = $derived.by(() => {
const graded = gradedFiltered();
if (!graded.length) return null;
const sum = graded.reduce((acc, a) => acc + (a.finalGrade ?? 0), 0);
return Math.round((sum / graded.length) * 10) / 10;
});
const statsSubjectCount = $derived(
new Set(timeScopedData().map((a) => a.subject)).size,
);
function formatLastUpdated(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
return date.toLocaleString();
}
async function runSync() {
syncing = true;
error = null;
try {
const result = await syncGradeAnalytics();
analyticsData = result.assessments;
lastUpdated = new Date(result.updatedAt);
} catch (e) {
console.error("[BetterSEQTA+] Analytics sync failed:", e);
error =
"Failed to sync analytics data. Showing cached data if available.";
} finally {
syncing = false;
}
}
function clearFilters() {
filterSubjects = [];
filterSearch = "";
gradeRange = [0, 100];
}
function hasActiveFilters() {
return !!(
filterSubjects.length ||
filterSearch ||
gradeRange[0] !== 0 ||
gradeRange[1] !== 100
);
}
function toggleSubject(subject: string) {
if (filterSubjects.includes(subject)) {
filterSubjects = filterSubjects.filter((s) => s !== subject);
} else {
filterSubjects = [...filterSubjects, subject];
}
}
const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange));
function closeToolbarDropdowns() {
showSubjectsDropdown = false;
showTimeRangeDropdown = false;
}
/** Shadow DOM retargets `event.target`; use the full composed path for outside-click. */
function isInsideToolbarDropdown(event: Event): boolean {
return event.composedPath().some((node) => {
if (!(node instanceof Element)) return false;
return node.closest("[data-analytics-dropdown]") !== null;
});
}
function selectTimeRange(value: TimeRange) {
timeRange = value;
showTimeRangeDropdown = false;
}
onMount(async () => {
timestampInterval = setInterval(() => {
timestampRefresh = Date.now();
}, 60000);
try {
const result = await loadGradeAnalytics();
analyticsData = result.assessments;
lastUpdated = result.updatedAt ? new Date(result.updatedAt) : null;
} catch (e) {
console.error("[BetterSEQTA+] Failed to load analytics:", e);
analyticsData = [];
} finally {
loading = false;
}
const ttl = getCacheTtlMs(24);
const needsSync =
!lastUpdated || Date.now() - lastUpdated.getTime() > ttl;
if (needsSync) {
void runSync();
}
});
onDestroy(() => {
if (timestampInterval) clearInterval(timestampInterval);
});
</script>
<svelte:window
onclick={(e) => {
if (!isInsideToolbarDropdown(e)) {
closeToolbarDropdowns();
}
}}
/>
<div class="bsplus-analytics-root">
<header class="bsplus-analytics-header bsplus-analytics-animate">
<div class="bsplus-analytics-header-text">
<h1>
Analytics
{#if syncing}
<span class="bsplus-analytics-badge">
<span class="bsplus-analytics-badge-dot" aria-hidden="true"></span>
Syncing
</span>
{/if}
</h1>
<p>Track your academic performance and progress over time</p>
{#if lastUpdated && analyticsData && analyticsData.length > 0}
<p class="bsplus-analytics-meta">Last updated: {formattedTimestamp()}</p>
{/if}
</div>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-primary"
disabled={syncing}
onclick={() => runSync()}
>
{syncing ? "Syncing…" : "Refresh data"}
</button>
</header>
{#if error}
<p class="bsplus-analytics-alert bsplus-analytics-animate" role="alert" transition:fade={{ duration: 200 }}>
{error}
</p>
{/if}
{#if loading}
<div class="bsplus-analytics-loading bsplus-analytics-animate">
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
</div>
{:else if analyticsData && analyticsData.length > 0}
<section
class="bsplus-analytics-stats bsplus-analytics-animate bsplus-analytics-delay-1"
aria-label="Summary statistics"
>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Average grade</div>
<div class="bsplus-analytics-stat-value bsplus-analytics-stat-value-accent">
{statsAverage !== null ? `${statsAverage}%` : "—"}
</div>
</div>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Graded shown</div>
<div class="bsplus-analytics-stat-value">{gradedFiltered().length}</div>
</div>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Subjects</div>
<div class="bsplus-analytics-stat-value">{statsSubjectCount}</div>
</div>
</section>
<div class="bsplus-analytics-toolbar bsplus-analytics-animate bsplus-analytics-delay-2">
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
>
<span class="bsplus-analytics-field-label">Time period</span>
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
<button
type="button"
class="bsplus-analytics-dropdown-trigger"
onclick={(e) => {
e.stopPropagation();
showSubjectsDropdown = false;
showTimeRangeDropdown = !showTimeRangeDropdown;
}}
aria-expanded={showTimeRangeDropdown}
aria-haspopup="listbox"
aria-label="Time period for analytics"
>
{timeRangeLabel()}
</button>
{#if showTimeRangeDropdown}
<div class="bsplus-analytics-dropdown-menu" role="listbox">
{#each TIME_RANGE_OPTIONS as option (option.value)}
{@const selected = timeRange === option.value}
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={selected}
role="option"
aria-selected={selected}
onclick={() => selectTimeRange(option.value)}
>
<span class="bsplus-analytics-dropdown-check"
>{selected ? "✓" : ""}</span
>
<span>{option.label}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
>
<span class="bsplus-analytics-field-label">Subjects</span>
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
<button
type="button"
class="bsplus-analytics-dropdown-trigger"
onclick={(e) => {
e.stopPropagation();
showTimeRangeDropdown = false;
showSubjectsDropdown = !showSubjectsDropdown;
}}
aria-expanded={showSubjectsDropdown}
aria-haspopup="listbox"
>
{#if filterSubjects.length === 0}
All subjects
{:else if filterSubjects.length === 1}
{filterSubjects[0]}
{:else}
{filterSubjects.length} selected
{/if}
</button>
{#if showSubjectsDropdown}
<div class="bsplus-analytics-dropdown-menu" role="listbox">
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={filterSubjects.length === 0}
onclick={() => {
filterSubjects = [];
showSubjectsDropdown = false;
}}
>
<span class="bsplus-analytics-dropdown-check"
>{filterSubjects.length === 0 ? "✓" : ""}</span
>
All subjects
</button>
{#each uniqueSubjects() as subject}
{@const selected = filterSubjects.includes(subject)}
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={selected}
onclick={() => toggleSubject(subject)}
>
<span class="bsplus-analytics-dropdown-check"
>{selected ? "✓" : ""}</span
>
<span style="overflow:hidden;text-overflow:ellipsis">{subject}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<div class="bsplus-analytics-range-row">
<input type="range" min="0" max="100" bind:value={gradeRange[0]} />
<input type="range" min="0" max="100" bind:value={gradeRange[1]} />
<span class="bsplus-analytics-range-value"
>{gradeRange[0]}% {gradeRange[1]}%</span
>
</div>
</div>
<div class="bsplus-analytics-field bsplus-analytics-toolbar-search">
<span class="bsplus-analytics-field-label">Search</span>
<input
type="search"
class="bsplus-analytics-input"
bind:value={filterSearch}
placeholder="Search assessments…"
/>
</div>
<label class="bsplus-analytics-checkbox">
<input type="checkbox" bind:checked={showSubjectTrends} />
<span>Show per-subject trends on chart</span>
</label>
</div>
<div class="bsplus-analytics-charts">
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
<div class="bsplus-analytics-animate bsplus-analytics-delay-3">
<AnalyticsAreaChart
data={gradedFiltered()}
{timeRange}
showSubjectTrends={showSubjectTrends}
/>
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4">
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
</div>
{/key}
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4" style="animation-delay: 400ms;">
<AssessmentTable data={timeScopedData()} />
</div>
<footer class="bsplus-analytics-footer">
<span>
{timeScopedData().length} of {analyticsData.length} assessments shown
{#if gradedFiltered().length !== timeScopedData().length}
({gradedFiltered().length} with grades)
{/if}
</span>
{#if hasActiveFilters()}
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
onclick={clearFilters}
>
Clear filters
</button>
{/if}
</footer>
{:else}
<div class="bsplus-analytics-empty bsplus-analytics-animate" transition:fade={{ duration: 300 }}>
<h2>No analytics data yet</h2>
<p>
Data syncs when you visit this page. Assessments with released marks will
appear here with trends and grade breakdowns.
</p>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-primary"
disabled={syncing}
onclick={() => runSync()}
>
Sync now
</button>
</div>
{/if}
</div>
+354
View File
@@ -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<string, unknown>) {
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<string, unknown>;
const letterGrade = extractLetterGradeStringFromPayload(
raw as Parameters<typeof extractLetterGradeStringFromPayload>[0],
);
let finalGrade = resolveNumericGradeFromAssessmentPayload(
raw as Parameters<typeof resolveNumericGradeFromAssessmentPayload>[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<string, unknown>): 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<typeof extractLetterGradeStringFromPayload>[0],
);
if (letter) {
const approx = resolveNumericGradeFromAssessmentPayload({
status: "MARKS_RELEASED",
letterGrade: letter,
});
if (approx !== undefined) return approx;
}
return undefined;
}
function extractLetterGrade(
assessment: Record<string, unknown>,
): 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<typeof extractLetterGradeStringFromPayload>[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<string, Subject>();
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<string, Subject>();
for (const s of subjects) {
map.set(`${s.programme}-${s.metaclass}`, s);
}
return Array.from(map.values());
}
async function loadAllSubjects(existingAssessments: Assessment[] = []): Promise<Subject[]> {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
const fromFolders = flattenSubjectFolders(res.payload);
return dedupeSubjects([...fromFolders, ...subjectsFromAssessments(existingAssessments)]);
}
async function loadUpcoming(studentId: number): Promise<Record<string, unknown>[]> {
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<Record<string, unknown>[]> {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student: studentId,
});
const items: Record<string, unknown>[] = [];
const process = (assessment: unknown) => {
if (!assessment || typeof assessment !== "object") return;
const a = assessment as Record<string, unknown>;
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<Record<string, unknown>[]> {
const results: Record<string, unknown>[][] = [];
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<string, unknown>[],
): Assessment[] {
const existingMap = new Map<number, Assessment>();
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<number> {
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() };
}
@@ -0,0 +1,39 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import ChartStyle from "./chart-style.svelte";
import { setChartContext, type ChartConfig } from "./chart-utils";
const uid = $props.id();
let {
ref = $bindable(null),
id = uid,
class: className = "",
children,
config,
...restProps
}: HTMLAttributes<HTMLElement> & {
ref?: HTMLElement | null;
config: ChartConfig;
class?: string;
} = $props();
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
setChartContext({
get config() {
return config;
},
});
</script>
<div
bind:this={ref}
data-chart={chartId}
data-slot="chart"
class="bsplus-chart-host {className}"
{...restProps}
>
<ChartStyle id={chartId} {config} />
{@render children?.()}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config
? Object.entries(config).filter(([, c]) => c.theme || c.color)
: null,
);
const themeContents = $derived.by(() => {
if (!colorConfig?.length) return;
const themeContents: string[] = [];
for (const [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const c = itemConfig.theme?.[theme] || itemConfig.color;
return c ? `\t--color-${key}: ${c};` : null;
});
content += color.filter(Boolean).join("\n") + "\n}";
themeContents.push(content);
}
return themeContents.join("\n");
});
</script>
{#if themeContents}
{#key id}
<svelte:element this={"style"}>
{themeContents}
</svelte:element>
{/key}
{/if}
@@ -0,0 +1,157 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
import { cn } from "../utils/cn";
import {
getPayloadConfigFromPayload,
useChart,
type TooltipPayload,
} from "./chart-utils";
function defaultFormatter(value: unknown) {
return `${value}`;
}
let {
class: className,
hideLabel = false,
indicator = "dot",
hideIndicator = false,
labelKey,
label,
labelFormatter = defaultFormatter,
labelClassName,
formatter,
nameKey,
color,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
hideLabel?: boolean;
label?: string;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideIndicator?: boolean;
labelClassName?: string;
labelFormatter?: (
value: unknown,
payload: TooltipPayload[],
) => string | number | Snippet;
formatter?: Snippet<
[
{
value: unknown;
name: string;
item: TooltipPayload;
index: number;
payload: TooltipPayload[];
},
]
>;
} = $props();
const chart = useChart();
const tooltipCtx = getTooltipContext();
const formattedLabel = $derived.by(() => {
if (hideLabel || !tooltipCtx.payload?.length) return null;
const [item] = tooltipCtx.payload;
const key = labelKey ?? item?.label ?? item?.name ?? "value";
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
const value =
!labelKey && typeof label === "string"
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
: (itemConfig?.label ?? item.label);
if (value === undefined) return null;
if (!labelFormatter) return value;
return labelFormatter(value, tooltipCtx.payload);
});
const nestLabel = $derived(
tooltipCtx.payload.length === 1 && indicator !== "dot",
);
</script>
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn("font-medium text-zinc-900 dark:text-white", labelClassName)}>
{#if typeof formattedLabel === "function"}
{@render formattedLabel()}
{:else}
{formattedLabel}
{/if}
</div>
{/if}
{/snippet}
<TooltipPrimitive.Root variant="none">
<div
class={cn(
"grid min-w-[9rem] items-start gap-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-2.5 py-1.5 text-xs shadow-xl text-zinc-900 dark:text-white",
className,
)}
{...restProps}
>
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
{#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}
<div
class={cn(
"flex w-full flex-wrap items-stretch gap-2",
indicator === "dot" && "items-center",
)}
>
{#if formatter && item.value !== undefined && item.name}
{@render formatter({
value: item.value,
name: item.name,
item,
index: i,
payload: tooltipCtx.payload,
})}
{:else}
{#if !hideIndicator}
<div
style="background: {indicatorColor}; border-color: {indicatorColor};"
class={cn("shrink-0 rounded-[2px] border", {
"size-2.5": indicator === "dot",
"h-full w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
})}
></div>
{/if}
<div
class={cn(
"flex flex-1 shrink-0 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div class="grid gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-zinc-500 dark:text-zinc-400">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</TooltipPrimitive.Root>
@@ -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<keyof typeof THEMES, string> }
);
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["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<ChartContextValue>(chartContextKey);
}
@@ -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,
};
@@ -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 = `<label><svg style="width:24px;height:24px" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 8h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg><span>Analytics</span></label>`;
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;
@@ -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 AF)",
},
{
value: "percent",
label: "Percentage bands",
description: "Group by score ranges (90100, 8089, …)",
},
];
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: "90100", min: 90, max: 100 },
{ label: "8089", min: 80, max: 89 },
{ label: "7079", min: 70, max: 79 },
{ label: "6069", min: 60, max: 69 },
{ label: "5059", min: 50, max: 59 },
{ label: "049", min: 0, max: 49 },
];
/** Standard AF (+ 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<string, number>();
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<string>();
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<string, { percents: number[]; labels: string[] }>();
const letterOnlyMap = new Map<string, { labels: string[]; count: number }>();
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<string, number>();
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 AF 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);
}
@@ -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;
@@ -0,0 +1,116 @@
/**
* When SEQTA only reports letter bands (no percentage), map to approximate 0100
* so analytics charts can run. Conventional scale, not official school conversion.
*/
const LETTER_TO_APPROX_PERCENT: Record<string, number> = {
"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, unknown>;
}): string | undefined {
const merged: Record<string, unknown> = {
...(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<string, unknown>;
}): number | undefined {
const merged: Record<string, unknown> = {
...(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<typeof extractLetterGradeStringFromPayload>[0],
);
return approximatePercentFromLetterGrade(letter);
}
@@ -0,0 +1,46 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { waitForElm } from "@/seqta/utils/waitForElm";
let loadInFlight: Promise<void> | null = null;
export async function loadAnalyticsPage(): Promise<void> {
if (!settingsState.onoff) return;
if (loadInFlight) {
await loadInFlight;
return;
}
loadInFlight = loadAnalyticsPageInner();
try {
await loadInFlight;
} finally {
loadInFlight = null;
}
}
async function loadAnalyticsPageInner(): Promise<void> {
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);
}
@@ -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<AnalyticsCache | null> {
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<void> {
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<DistributionMode | null> {
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<void> {
const key = distributionModeStorageKey(origin, studentId);
await browser.storage.local.set({ [key]: mode });
}
@@ -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);
}
@@ -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, string>): 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<string, number[]>();
const subjectBuckets = new Map<string, Map<string, number[]>>();
const subjectLabels = new Map<string, string>();
const keyBySubject = new Map<string, string>();
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<string>(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 };
}
@@ -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[];
}
+186
View File
@@ -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<typeof mount> | 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();
}
@@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
+2
View File
@@ -15,6 +15,7 @@ import messageFoldersPlugin from "./built-in/messageFolders";
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
import globalSearchPluginLazy from "./built-in/globalSearch/lazy"; import globalSearchPluginLazy from "./built-in/globalSearch/lazy";
import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy";
// Initialize plugin manager // Initialize plugin manager
const pluginManager = PluginManager.getInstance(); const pluginManager = PluginManager.getInstance();
@@ -34,6 +35,7 @@ pluginManager.registerPlugin(messageFoldersPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
pluginManager.registerPlugin(globalSearchPluginLazy); pluginManager.registerPlugin(globalSearchPluginLazy);
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
export { init as Monofile } from "./monofile"; export { init as Monofile } from "./monofile";
+7 -3
View File
@@ -25,6 +25,7 @@ import {
updateEngageHomeMenuActive, updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage"; } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -202,9 +203,7 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise<void> { async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements(); await AddBetterSEQTAElements();
const sublink: string | undefined = isSeqtaEngageExperience() const sublink: string | undefined = getEngageRoutePage();
? getEngageRoutePage()
: window.location.href.split("/")[4];
if (isSeqtaEngageExperience() && !engageHashListenerAttached) { if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
engageHashListenerAttached = true; engageHashListenerAttached = true;
@@ -335,6 +334,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
case "news": case "news":
await handleNewsPage(); await handleNewsPage();
break; break;
case "analytics":
console.info("[BetterSEQTA+] Started Init (Analytics)");
if (settingsState.onoff) void loadAnalyticsPage();
finishLoad();
break;
case undefined: case undefined:
window.location.replace( window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`, `${location.origin}/#?page=/${settingsState.defaultPage}`,
+46
View File
@@ -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() { export function getMockAssessmentsData() {
const subjects = mockData.subjects.slice(0, 5).map((title, i) => ({ const subjects = mockData.subjects.slice(0, 5).map((title, i) => ({
code: `SUBJ${i + 1}`, code: `SUBJ${i + 1}`,