mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: analytics page
This commit is contained in:
@@ -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)
|
||||
import globalSearchPluginLazy from "./built-in/globalSearch/lazy";
|
||||
import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy";
|
||||
|
||||
// Initialize plugin manager
|
||||
const pluginManager = PluginManager.getInstance();
|
||||
@@ -34,6 +35,7 @@ pluginManager.registerPlugin(messageFoldersPlugin);
|
||||
|
||||
// Register heavy plugins with lazy loading
|
||||
pluginManager.registerPlugin(globalSearchPluginLazy);
|
||||
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
||||
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateEngageHomeMenuActive,
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
@@ -202,9 +203,7 @@ function SortMessagePageItems(messagesParentElement: any) {
|
||||
|
||||
async function LoadPageElements(): Promise<void> {
|
||||
await AddBetterSEQTAElements();
|
||||
const sublink: string | undefined = isSeqtaEngageExperience()
|
||||
? getEngageRoutePage()
|
||||
: window.location.href.split("/")[4];
|
||||
const sublink: string | undefined = getEngageRoutePage();
|
||||
|
||||
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
||||
engageHashListenerAttached = true;
|
||||
@@ -335,6 +334,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||
case "news":
|
||||
await handleNewsPage();
|
||||
break;
|
||||
case "analytics":
|
||||
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
||||
if (settingsState.onoff) void loadAnalyticsPage();
|
||||
finishLoad();
|
||||
break;
|
||||
case undefined:
|
||||
window.location.replace(
|
||||
`${location.origin}/#?page=/${settingsState.defaultPage}`,
|
||||
|
||||
Reference in New Issue
Block a user