mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: analytics page
This commit is contained in:
@@ -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>
|
||||||
@@ -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 A–F)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "percent",
|
||||||
|
label: "Percentage bands",
|
||||||
|
description: "Group by score ranges (90–100, 80–89, …)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DistributionBucket = {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
minPercent?: number;
|
||||||
|
maxPercent?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GradeDistributionResult = {
|
||||||
|
buckets: DistributionBucket[];
|
||||||
|
modeUsed: "letter" | "percent";
|
||||||
|
scaleSource: "inferred" | "standard" | "percent";
|
||||||
|
scaleLabel: string;
|
||||||
|
gradedCount: number;
|
||||||
|
averagePercent: number | null;
|
||||||
|
letterGradeCoverage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERCENT_BUCKETS: { label: string; min: number; max: number }[] = [
|
||||||
|
{ label: "90–100", min: 90, max: 100 },
|
||||||
|
{ label: "80–89", min: 80, max: 89 },
|
||||||
|
{ label: "70–79", min: 70, max: 79 },
|
||||||
|
{ label: "60–69", min: 60, max: 69 },
|
||||||
|
{ label: "50–59", min: 50, max: 59 },
|
||||||
|
{ label: "0–49", min: 0, max: 49 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Standard A–F (+ modifiers) ordering when school scale cannot be inferred. */
|
||||||
|
const STANDARD_LETTER_ORDER = [
|
||||||
|
"A+",
|
||||||
|
"A",
|
||||||
|
"A-",
|
||||||
|
"B+",
|
||||||
|
"B",
|
||||||
|
"B-",
|
||||||
|
"C+",
|
||||||
|
"C",
|
||||||
|
"C-",
|
||||||
|
"D+",
|
||||||
|
"D",
|
||||||
|
"D-",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"HD",
|
||||||
|
"CR",
|
||||||
|
"P",
|
||||||
|
"PS",
|
||||||
|
"N",
|
||||||
|
"PASS",
|
||||||
|
"FAIL",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type InferredLetterBand = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
medianPercent: number;
|
||||||
|
minPercent: number;
|
||||||
|
maxPercent: number;
|
||||||
|
pairedSamples: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InferredLetterScale = {
|
||||||
|
bands: InferredLetterBand[];
|
||||||
|
pairedCount: number;
|
||||||
|
letterAssessmentCount: number;
|
||||||
|
confidence: "high" | "medium" | "low";
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLetterKey(raw: string): string {
|
||||||
|
const s = raw.trim().toLowerCase();
|
||||||
|
const first = s.split(/[\s(/]/)[0] ?? s;
|
||||||
|
return first.replace(/[^a-z0-9+-]/gi, "") || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDisplayLabel(variants: string[]): string {
|
||||||
|
if (!variants.length) return "";
|
||||||
|
const counts = new Map<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 A–F style scale (override)";
|
||||||
|
|
||||||
|
return {
|
||||||
|
buckets,
|
||||||
|
modeUsed: "letter",
|
||||||
|
scaleSource,
|
||||||
|
scaleLabel,
|
||||||
|
gradedCount: graded.length,
|
||||||
|
averagePercent:
|
||||||
|
percentCount > 0 ? Math.round((percentSum / percentCount) * 10) / 10 : null,
|
||||||
|
letterGradeCoverage: graded.length ? withLetter / graded.length : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGradeDistribution(
|
||||||
|
assessments: Assessment[],
|
||||||
|
mode: DistributionMode = "auto",
|
||||||
|
): GradeDistributionResult {
|
||||||
|
const graded = assessments.filter(isGradedAssessment);
|
||||||
|
if (!graded.length) {
|
||||||
|
return {
|
||||||
|
buckets: [],
|
||||||
|
modeUsed: "percent",
|
||||||
|
scaleSource: "percent",
|
||||||
|
scaleLabel: "Percentage bands",
|
||||||
|
gradedCount: 0,
|
||||||
|
averagePercent: null,
|
||||||
|
letterGradeCoverage: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferred = inferLetterGradeScale(graded);
|
||||||
|
const effective = resolveEffectiveMode(mode, inferred, graded);
|
||||||
|
|
||||||
|
if (effective === "letter") {
|
||||||
|
return buildLetterDistribution(graded, inferred, mode === "letter" && !inferred);
|
||||||
|
}
|
||||||
|
return buildPercentDistribution(graded);
|
||||||
|
}
|
||||||
@@ -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 0–100
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user