Merge origin/main into fixcloudsync

Resolve grade analytics conflicts by keeping grade forecast UI from main
and combining layout fixes from both branches.
This commit is contained in:
SethBurkart123
2026-06-15 10:38:17 +10:00
10 changed files with 930 additions and 426 deletions
@@ -1,396 +1,335 @@
<script lang="ts">
import * as Chart from "./chart/index";
import { scaleUtc, scaleLinear } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { scaleLinear } from "d3-scale";
import { Area, AreaChart, ChartClipPath, Spline } from "layerchart";
import { curveNatural } from "d3-shape";
import { cubicInOut } from "svelte/easing";
import type { Assessment } from "./types";
import {
buildGradeTrendChart,
getTimeRangeLabel,
type TimeRange,
} from "./timeRange";
import { computeGradeForecast, aggregateToMonthlyPoints } from "./utils/gradePrediction";
import PredictionMonthsSlider from "./PredictionMonthsSlider.svelte";
interface Props {
data: Assessment[];
timeRange: TimeRange;
showSubjectTrends?: boolean;
}
let { data, timeRange, showSubjectTrends = false }: Props = $props();
let showPrediction = $state(false);
let predictionMonths = $state(3);
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
const chartResult = $derived(() =>
const chartResult = $derived.by(() =>
buildGradeTrendChart(data, timeRange, {
showPerSubject: showSubjectTrends,
}),
);
const historicalData = $derived(chartResult.points);
const chartSeries = $derived(chartResult.series);
const accentColor = $derived(chartResult.accentColor);
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 forecast = $derived.by(() => {
if (!showPrediction) return null;
const points = aggregateToMonthlyPoints(
historicalData
.filter((p) => !Number.isNaN(p.average))
.map((p) => ({ date: p.date, average: p.average })),
);
return computeGradeForecast(points, predictionMonths);
});
/** Bridge point + future months — separate from historical so the main line stays intact. */
const forecastLineData = $derived.by(() => {
if (!showPrediction || !forecast) return [];
const hist = historicalData.filter((p) => !Number.isNaN(p.average));
if (!hist.length) return [];
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);
const last = hist[hist.length - 1];
return [
{ date: last.date, forecast: last.average },
...forecast.points.map((p) => ({ date: p.date, forecast: p.value })),
];
});
/** Ghost future dates (null grades) extend the x domain without touching the historical line. */
const chartData = $derived.by(() => {
if (!showPrediction || forecastLineData.length <= 1) {
return historicalData;
}
const futurePadding = forecastLineData.slice(1).map((p) => ({
date: p.date,
average: null,
count: 0,
}));
return [...historicalData, ...futurePadding];
});
const chartConfig = $derived.by(() => {
const config: Chart.ChartConfig = {};
for (const s of chartSeries) {
config[s.key] = { label: s.label, color: s.color };
}
if (showPrediction && forecastLineData.length > 1) {
config.forecast = {
label: "Forecast",
color: "var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent))",
};
}
return config;
});
const yScale = $derived.by(() => {
if (!historicalData.length) return scaleLinear().domain([0, 100]);
const values: number[] = [];
for (const p of historicalData) {
for (const s of chartSeries) {
const v = p[s.key];
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
}
if (typeof p.average === "number" && !Number.isNaN(p.average)) {
values.push(p.average);
}
}
for (const p of forecastLineData) {
if (typeof p.forecast === "number" && !Number.isNaN(p.forecast)) {
values.push(p.forecast);
}
}
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.by(() => {
if (historicalData.length < 2) {
return { percentage: "0", direction: "neutral" as const };
}
const trend = $derived(() => {
const points = filteredData();
if (points.length < 2) return { percentage: "0", direction: "neutral" as const };
const recent = points.slice(-2);
const recent = historicalData.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),
direction:
change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
};
});
const areaSeries = $derived(() =>
chartSeries().map((s) => ({
const areaSeries = $derived.by(() => {
const series = chartSeries.map((s) => ({
key: s.key,
label: s.label,
color: s.color,
}));
})),
if (showPrediction && forecastLineData.length > 1) {
series.push({
key: "forecast",
label: "Forecast",
color: "var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent))",
});
}
return series;
});
const canForecast = $derived.by(() => {
const monthly = aggregateToMonthlyPoints(
historicalData
.filter((p) => !Number.isNaN(p.average))
.map((p) => ({ date: p.date, average: p.average })),
);
return monthly.length >= 3;
});
</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 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>
<div class="bsplus-analytics-card-controls" aria-hidden="true">
<div class="bsplus-analytics-card-control bsplus-analytics-card-control-spacer"></div>
<div class="bsplus-analytics-card-controls bsplus-analytics-forecast-controls">
<label class="bsplus-analytics-checkbox bsplus-analytics-forecast-toggle">
<input
type="checkbox"
bind:checked={showPrediction}
disabled={!canForecast}
/>
<span>Grade forecast</span>
</label>
<div class="bsplus-analytics-card-control bsplus-analytics-forecast-horizon">
<span class="bsplus-analytics-field-label">Months ahead</span>
<PredictionMonthsSlider bind:value={predictionMonths} disabled={!showPrediction} />
</div>
</div>
</header>
<div class="bsplus-analytics-card-body">
{#if filteredData().length > 0}
<Chart.Container config={chartConfig()} class="bsplus-chart-surface w-full">
{#if historicalData.length > 0}
{#key `${showPrediction}-${predictionMonths}`}
<Chart.Container config={chartConfig} class="bsplus-chart-surface w-full">
<AreaChart
legend
data={filteredData()}
data={chartData}
x="date"
xScale={scaleUtc()}
yScale={yScale()}
series={areaSeries()}
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" />
<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={{
initialWidth={showPrediction ? undefined : 0}
motion={showPrediction
? undefined
: {
width: { type: "tween", duration: 900, easing: cubicInOut },
}}
>
{#each series as s, i (s.key)}
{@const meta = chartSeries().find((c) => c.key === s.key)}
{@const meta = chartSeries.find((c) => c.key === s.key)}
{@const isOverall = meta?.isOverall ?? s.key === "average"}
{@const isForecast = s.key === "forecast"}
{#if !isForecast}
<Area
{...getAreaProps(s, i)}
fill={isOverall && !showSubjectTrends
? `url(#${chartUid})`
: isOverall
? accentColor()
? accentColor
: "transparent"}
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
stroke={meta?.color ?? s.color}
style={`stroke: ${meta?.color ?? s.color}`}
/>
{/if}
{/each}
</ChartClipPath>
{/snippet}
{#snippet aboveMarks()}
{#if showPrediction && forecastLineData.length > 1}
<Spline
data={forecastLineData}
x="date"
y="forecast"
curve={curveNatural}
class="bsplus-analytics-forecast-line"
/>
{/if}
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(v: Date) =>
v.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{/key}
{: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 showPrediction && !canForecast}
<p class="bsplus-analytics-scale-hint">
At least 3 graded periods are needed to generate a forecast.
</p>
{/if}
{: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 showPrediction && forecast}
<span>
Projected average in {predictionMonths} month{predictionMonths === 1 ? "" : "s"}:
<strong>{forecast.projectedGrade}%</strong>
<span class="bsplus-analytics-footer-muted">
· {forecast.trendPerMonth >= 0 ? "+" : ""}{forecast.trendPerMonth}%/mo trend
· R² {forecast.rSquared.toFixed(2)}
</span>
</span>
<br />
{/if}
<p>
{#if trend().direction === "up"}
{#if trend.direction === "up"}
<span class="bsplus-analytics-trend-up"
>Trending up · {trend().percentage}% vs previous period</span
>Trending up · {trend.percentage}% vs previous period</span
>
{:else if trend().direction === "down"}
{:else if trend.direction === "down"}
<span class="bsplus-analytics-trend-down"
>Trending down · {trend().percentage}% vs previous period</span
>Trending down · {trend.percentage}% vs previous period</span
>
{:else}
<span>Grades remain stable across this period</span>
{/if}
</p>
<p>
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
{#if showSubjectTrends && chartSeries().length > 1}
· {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"}
<br />
<span>
{historicalData.length} data points · {getTimeRangeLabel(timeRange)}
{#if showSubjectTrends && chartSeries.length > 1}
· {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"}
{/if}
</p>
{#if showPrediction && forecast}
· {forecast.methodLabel}
{/if}
</span>
</footer>
</article>
@@ -35,6 +35,7 @@
let showSubjectTrends = $state(false);
let timestampInterval: ReturnType<typeof setInterval> | null = null;
let contentReady = $state(false);
const formattedTimestamp = $derived(() => {
if (!lastUpdated) return "";
@@ -170,6 +171,9 @@
analyticsData = [];
} finally {
loading = false;
requestAnimationFrame(() => {
contentReady = true;
});
}
const ttl = getCacheTtlMs(24);
@@ -235,7 +239,7 @@
</p>
{/if}
{#if loading}
{#if loading || !contentReady}
<div class="bsplus-analytics-loading bsplus-analytics-animate">
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
</div>
@@ -261,6 +265,7 @@
</section>
<div class="bsplus-analytics-toolbar bsplus-analytics-animate bsplus-analytics-delay-2">
<div class="bsplus-analytics-toolbar-grid">
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
@@ -364,11 +369,6 @@
</div>
</div>
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<GradeRangeSlider bind:value={gradeRange} />
</div>
<div class="bsplus-analytics-field bsplus-analytics-toolbar-search">
<span class="bsplus-analytics-field-label">Search</span>
<input
@@ -379,14 +379,34 @@
/>
</div>
<label class="bsplus-analytics-checkbox">
{#if hasActiveFilters()}
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost bsplus-analytics-toolbar-clear"
onclick={clearFilters}
>
Clear filters
</button>
{/if}
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<GradeRangeSlider bind:value={gradeRange} />
</div>
<label
class="bsplus-analytics-checkbox bsplus-analytics-toolbar-trends"
class:bsplus-analytics-toolbar-trends-top={!hasActiveFilters()}
>
<input type="checkbox" bind:checked={showSubjectTrends} />
<span>Show per-subject trends on chart</span>
</label>
</div>
</div>
<div class="bsplus-analytics-charts">
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
<div class="bsplus-analytics-chart-cell">
<div class="bsplus-analytics-animate bsplus-analytics-delay-3">
<AnalyticsAreaChart
data={gradedFiltered()}
@@ -394,13 +414,16 @@
showSubjectTrends={showSubjectTrends}
/>
</div>
</div>
<div class="bsplus-analytics-chart-cell">
<div class="bsplus-analytics-animate bsplus-analytics-delay-4">
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
</div>
</div>
{/key}
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4" style="animation-delay: 400ms;">
<div class="bsplus-analytics-animate bsplus-analytics-delay-5">
<AssessmentTable data={timeScopedData()} />
</div>
@@ -411,15 +434,6 @@
({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 }}>
@@ -0,0 +1,136 @@
<script lang="ts">
let {
value = $bindable(3),
min = 1,
max = 12,
step = 1,
disabled = false,
} = $props<{
value?: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
}>();
const percent = $derived(((value - min) / (max - min || 1)) * 100);
</script>
<div class="bsplus-prediction-months-slider" class:is-disabled={disabled}>
<div class="bsplus-prediction-months-slider-track-wrap">
<div class="bsplus-prediction-months-slider-track" aria-hidden="true">
<div class="bsplus-prediction-months-slider-rail"></div>
<div class="bsplus-prediction-months-slider-fill" style:width="{percent}%"></div>
</div>
<input
type="range"
class="bsplus-prediction-months-slider-input"
{min}
{max}
{step}
{disabled}
bind:value
aria-label="Forecast months ahead"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
/>
</div>
<span class="bsplus-analytics-range-value" aria-live="polite">
{value} month{value === 1 ? "" : "s"}
</span>
</div>
<style>
.bsplus-prediction-months-slider {
display: flex;
align-items: center;
gap: 0.65rem;
width: 100%;
min-width: 0;
}
.bsplus-prediction-months-slider.is-disabled {
opacity: 0.45;
pointer-events: none;
}
.bsplus-prediction-months-slider-track-wrap {
position: relative;
flex: 1;
height: 1.5rem;
display: flex;
align-items: center;
}
.bsplus-prediction-months-slider-track {
position: absolute;
left: 0;
right: 0;
height: 0.35rem;
pointer-events: none;
}
.bsplus-prediction-months-slider-rail {
position: absolute;
inset: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--bsplus-analytics-muted) 28%, transparent);
}
.bsplus-prediction-months-slider-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 999px;
background: var(--bsplus-analytics-accent);
}
.bsplus-prediction-months-slider-input {
position: absolute;
left: 0;
width: 100%;
margin: 0;
height: 1.5rem;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.bsplus-prediction-months-slider-input::-webkit-slider-runnable-track {
-webkit-appearance: none;
height: 0.35rem;
background: transparent;
border: none;
}
.bsplus-prediction-months-slider-input::-moz-range-track {
height: 0.35rem;
background: transparent;
border: none;
}
.bsplus-prediction-months-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1rem;
height: 1rem;
margin-top: -0.325rem;
border-radius: 50%;
border: 2px solid var(--bsplus-analytics-accent);
background: var(--bsplus-analytics-surface, #fff);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
cursor: grab;
}
.bsplus-prediction-months-slider-input::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid var(--bsplus-analytics-accent);
background: var(--bsplus-analytics-surface, #fff);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
cursor: grab;
}
</style>
@@ -25,10 +25,32 @@
return config;
},
});
function observeChartResize(node: HTMLElement) {
let frame = 0;
const notify = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
window.dispatchEvent(new Event("resize"));
});
};
const observer = new ResizeObserver(notify);
observer.observe(node);
notify();
return {
destroy() {
cancelAnimationFrame(frame);
observer.disconnect();
},
};
}
</script>
<div
bind:this={ref}
use:observeChartResize
data-chart={chartId}
data-slot="chart"
class="bsplus-chart-host {className}"
+160 -46
View File
@@ -74,8 +74,8 @@
}
.bsplus-analytics-animate {
animation: bsplus-analytics-fade-in-up 0.55s var(--bsplus-analytics-ease)
forwards;
opacity: 0;
animation: bsplus-analytics-fade-in-up 0.55s var(--bsplus-analytics-ease) both;
will-change: opacity, transform;
}
@@ -99,6 +99,9 @@
.bsplus-analytics-delay-4 {
animation-delay: 320ms;
}
.bsplus-analytics-delay-5 {
animation-delay: 400ms;
}
/* ─── Header ─── */
.bsplus-analytics-header {
@@ -166,10 +169,9 @@
font-size: 0.7rem;
font-weight: 600;
vertical-align: middle;
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 18%,
transparent
background: var(
--bsplus-analytics-accent-subtle,
color-mix(in srgb, var(--bsplus-analytics-accent) 18%, transparent)
);
color: var(--bsplus-analytics-accent);
}
@@ -325,10 +327,6 @@
/* ─── Filter toolbar ─── */
.bsplus-analytics-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
padding: 1rem 1.15rem;
border-radius: var(
--bsplus-theme-card-radius,
@@ -346,6 +344,73 @@
z-index: 3;
}
.bsplus-analytics-toolbar-grid {
display: grid;
grid-template-columns: minmax(9rem, 1fr) minmax(9rem, 1fr) minmax(12rem, 1.4fr) auto;
grid-template-rows: auto auto;
gap: 0.9rem 1rem;
align-items: end;
width: 100%;
}
.bsplus-analytics-toolbar-clear {
grid-column: 4;
grid-row: 1;
justify-self: end;
align-self: end;
padding: 0.65rem 0.9rem;
white-space: nowrap;
}
.bsplus-analytics-toolbar-trends {
grid-column: 4;
grid-row: 2;
justify-self: end;
align-self: center;
margin-left: 0;
max-width: 14rem;
text-align: right;
}
.bsplus-analytics-toolbar-trends-top {
grid-row: 1;
align-self: end;
}
@media (max-width: 900px) {
.bsplus-analytics-toolbar-grid {
grid-template-columns: 1fr 1fr;
}
.bsplus-analytics-toolbar-search {
grid-column: 1 / -1;
}
.bsplus-analytics-toolbar-clear {
grid-column: 1 / -1;
grid-row: auto;
justify-self: stretch;
}
.bsplus-analytics-grade-range {
grid-column: 1 / -1;
}
.bsplus-analytics-toolbar-trends {
grid-column: 1 / -1;
grid-row: auto;
justify-self: start;
max-width: none;
text-align: left;
}
}
@media (max-width: 520px) {
.bsplus-analytics-toolbar-grid {
grid-template-columns: 1fr;
}
}
.bsplus-analytics-toolbar-dropdown-field {
position: relative;
z-index: 4;
@@ -370,7 +435,6 @@
display: flex;
align-items: center;
gap: 0.55rem;
margin-left: auto;
padding: 0.35rem 0;
font-size: 0.8125rem;
font-weight: 500;
@@ -389,13 +453,6 @@
flex-shrink: 0;
}
@media (max-width: 900px) {
.bsplus-analytics-checkbox {
margin-left: 0;
width: 100%;
}
}
.bsplus-analytics-select,
.bsplus-analytics-input {
appearance: none;
@@ -456,9 +513,10 @@
}
.bsplus-analytics-grade-range {
flex: 1;
min-width: 12rem;
max-width: 20rem;
grid-column: 1 / 4;
grid-row: 2;
min-width: 0;
max-width: none;
}
.bsplus-analytics-range-value {
@@ -471,17 +529,7 @@
}
.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%;
}
min-width: 0;
}
/* Toolbar custom dropdowns (time period, subjects) */
@@ -604,6 +652,28 @@
align-items: stretch;
}
.bsplus-analytics-chart-cell {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
min-width: 0;
}
.bsplus-analytics-chart-cell > :global(.bsplus-analytics-animate) {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
min-width: 0;
}
.bsplus-analytics-chart-cell :global(.bsplus-analytics-card) {
flex: 1;
width: 100%;
min-width: 0;
}
/* Fade-in animation must not paint above the filter toolbar / dropdown */
.bsplus-analytics-charts > .bsplus-analytics-animate {
z-index: 1;
@@ -618,7 +688,7 @@
@media (min-width: 960px) {
.bsplus-analytics-charts {
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
@@ -660,8 +730,9 @@
.bsplus-analytics-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: flex-end;
gap: 1rem;
min-height: 4.75rem;
padding: 1.15rem 1.25rem;
border-bottom: 1px solid var(--bsplus-analytics-border);
}
@@ -674,7 +745,34 @@
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
gap: 0.75rem 1rem;
flex-shrink: 0;
}
.bsplus-analytics-forecast-controls {
min-width: min(100%, 18rem);
max-width: 22rem;
}
.bsplus-analytics-forecast-toggle {
width: 100%;
justify-content: flex-end;
}
.bsplus-analytics-forecast-horizon {
flex: 1;
min-width: 11rem;
}
.bsplus-analytics-forecast-line {
opacity: 0.9;
}
.bsplus-analytics-root [data-slot="chart"] .bsplus-analytics-forecast-line {
stroke: var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent)) !important;
stroke-dasharray: 7 5 !important;
stroke-width: 2.5px;
fill: none !important;
}
.bsplus-analytics-card-control {
@@ -728,6 +826,8 @@
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
background: var(--bsplus-analytics-surface);
}
@@ -777,18 +877,28 @@
/* ─── Layerchart / SVG (fix default black rects in dark UI) ─── */
.bsplus-chart-host {
display: flex;
justify-content: center;
display: block;
width: 100%;
min-width: 0;
overflow: visible;
color: var(--bsplus-analytics-muted);
}
.bsplus-analytics-root .bsplus-chart-surface,
.bsplus-analytics-root .bsplus-chart-surface {
width: 100%;
min-width: 0;
height: var(--bsplus-analytics-chart-height, 280px);
min-height: var(--bsplus-analytics-chart-height, 280px);
max-height: var(--bsplus-analytics-chart-height, 280px);
flex-shrink: 0;
}
.bsplus-analytics-root .bsplus-chart-surface-bar {
height: var(--bsplus-analytics-chart-height);
min-height: var(--bsplus-analytics-chart-height);
max-height: var(--bsplus-analytics-chart-height);
width: 100%;
min-width: 0;
height: 320px;
min-height: 320px;
max-height: 320px;
flex-shrink: 0;
}
@@ -826,6 +936,9 @@
}
.bsplus-analytics-root [data-slot="chart"] svg {
display: block;
width: 100% !important;
max-width: 100%;
background: transparent !important;
overflow: visible;
}
@@ -1003,10 +1116,9 @@
border-radius: 999px;
font-weight: 700;
font-size: 0.75rem;
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 14%,
transparent
background: var(
--bsplus-analytics-accent-subtle,
color-mix(in srgb, var(--bsplus-analytics-accent) 14%, transparent)
);
color: var(--bsplus-analytics-accent);
}
@@ -1037,9 +1149,11 @@
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
gap: 0.75rem;
padding-bottom: 0.5rem;
color: var(--bsplus-analytics-muted);
font-size: 0.8125rem;
}
/* ─── States ─── */
@@ -38,9 +38,10 @@ export function filterAssessmentsByTimeRange(
export type TrendPoint = {
date: Date;
average: number;
average: number | null;
count: number;
[seriesKey: string]: number | Date;
forecast?: number;
[seriesKey: string]: number | Date | null | undefined;
};
export type TrendSeries = {
+16 -2
View File
@@ -3,6 +3,7 @@ import pluginStyles from "./styles.css?inline";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { mount, unmount } from "svelte";
import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte";
import { buildContrastAccentPalette } from "./utils/accentColor";
type ThemeSettingKey =
| "selectedColor"
@@ -115,8 +116,21 @@ function syncThemeFromPage(target: HTMLElement) {
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-analytics-accent", accent);
target.style.setProperty("--better-main", accent);
const surface =
target.style.getPropertyValue("--background-primary").trim() ||
computed.getPropertyValue("--background-primary").trim() ||
(target.classList.contains("dark") ? "#1e293b" : "#ffffff");
const palette = buildContrastAccentPalette(accent, surface);
target.style.setProperty("--bsplus-analytics-accent", palette.accent);
target.style.setProperty("--bsplus-analytics-accent-subtle", palette.accentSubtle);
target.style.setProperty(
"--bsplus-analytics-forecast",
`color-mix(in srgb, ${palette.accent} 72%, ${target.classList.contains("dark") ? "#f8fafc" : "#64748b"})`,
);
target.style.setProperty("--better-main", palette.accent);
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
target.classList.toggle(
"dark",
@@ -0,0 +1,80 @@
import Color from "color";
export type ContrastAccentPalette = {
accent: string;
accentSubtle: string;
onAccent: string;
};
type ColorInstance = ReturnType<typeof Color>;
const MIN_CONTRAST_LIGHT = 4.5;
const MIN_CONTRAST_DARK = 3;
function contrastRatio(foreground: ColorInstance, background: ColorInstance): number {
const fg = foreground.luminosity();
const bg = background.luminosity();
const lighter = Math.max(fg, bg);
const darker = Math.min(fg, bg);
return (lighter + 0.05) / (darker + 0.05);
}
function adjustLightnessForContrast(
hue: number,
saturation: number,
lightness: number,
background: ColorInstance,
isDark: boolean,
): ColorInstance {
const minContrast = isDark ? MIN_CONTRAST_DARK : MIN_CONTRAST_LIGHT;
let candidate = Color.hsl(hue, saturation, lightness);
for (let i = 0; i < 16; i++) {
if (contrastRatio(candidate, background) >= minContrast) {
return candidate;
}
const { l } = candidate.hsl().object();
candidate = Color.hsl(
hue,
saturation,
isDark ? Math.min(l + 5, 82) : Math.max(l - 5, 18),
);
}
return candidate;
}
/**
* Keep the user's hue/saturation but pick lightness so accent text and fills
* stay readable against the analytics surface background.
*/
export function buildContrastAccentPalette(
accentRaw: string,
backgroundRaw: string,
): ContrastAccentPalette {
const accent = Color(accentRaw);
const background = Color(backgroundRaw);
const isDark = background.isDark();
const { h, s } = accent.hsl().object();
const saturation = Math.min(Math.max(s, 42), 88);
const baseLightness = isDark ? 64 : 40;
const foreground = adjustLightnessForContrast(
h,
saturation,
baseLightness,
background,
isDark,
);
const accentHex = foreground.hex();
const subtleLightness = isDark ? 28 : 94;
const subtle = Color.hsl(h, saturation * 0.75, subtleLightness);
return {
accent: accentHex,
accentSubtle: subtle.alpha(isDark ? 0.22 : 0.14).rgb().string(),
onAccent: foreground.isLight() ? "#141414" : "#ffffff",
};
}
@@ -0,0 +1,179 @@
export type HistoricalGradePoint = {
date: Date;
average: number;
};
export type ForecastPoint = {
date: Date;
value: number;
};
export type GradeForecastResult = {
points: ForecastPoint[];
projectedGrade: number;
trendPerMonth: number;
rSquared: number;
methodLabel: string;
};
const MIN_POINTS = 3;
const MAX_MONTHS = 12;
function clampGrade(value: number): number {
return Math.min(100, Math.max(0, value));
}
function addMonths(date: Date, months: number): Date {
const next = new Date(date);
next.setMonth(next.getMonth() + months);
next.setDate(1);
next.setHours(0, 0, 0, 0);
return next;
}
/** Holt's linear trend method (double exponential smoothing). */
function holtLinearForecast(
values: number[],
horizon: number,
alpha = 0.38,
beta = 0.14,
): number[] {
if (values.length < 2) return [];
let level = values[0];
let trend = values[1] - values[0];
for (let i = 1; i < values.length; i++) {
const prevLevel = level;
level = alpha * values[i] + (1 - alpha) * (level + trend);
trend = beta * (level - prevLevel) + (1 - beta) * trend;
}
return Array.from({ length: horizon }, (_, i) => level + (i + 1) * trend);
}
/** Weighted least squares with recency bias (exponential weights). */
function weightedLinearRegression(values: number[]): {
forecasts: number[];
slope: number;
rSquared: number;
} {
const n = values.length;
if (n < 2) {
return { forecasts: [], slope: 0, rSquared: 0 };
}
const decay = 0.72;
const weights = values.map((_, i) => decay ** (n - 1 - i));
const xs = values.map((_, i) => i);
let sumW = 0;
let sumWX = 0;
let sumWY = 0;
let sumWXX = 0;
let sumWXY = 0;
for (let i = 0; i < n; i++) {
const w = weights[i];
sumW += w;
sumWX += w * xs[i];
sumWY += w * values[i];
sumWXX += w * xs[i] * xs[i];
sumWXY += w * xs[i] * values[i];
}
const denom = sumW * sumWXX - sumWX * sumWX;
const slope = denom === 0 ? 0 : (sumW * sumWXY - sumWX * sumWY) / denom;
const intercept = (sumWY - slope * sumWX) / sumW;
let ssRes = 0;
let ssTot = 0;
const meanY = sumWY / sumW;
for (let i = 0; i < n; i++) {
const predicted = intercept + slope * xs[i];
ssRes += weights[i] * (values[i] - predicted) ** 2;
ssTot += weights[i] * (values[i] - meanY) ** 2;
}
const rSquared = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
const forecasts = Array.from({ length: MAX_MONTHS }, (_, i) =>
intercept + slope * (n - 1 + (i + 1)),
);
return { forecasts, slope, rSquared };
}
function monthKey(date: Date): string {
return date.toISOString().slice(0, 7);
}
/** Collapse trend points to calendar months for stable forward projections. */
export function aggregateToMonthlyPoints(
historical: HistoricalGradePoint[],
): HistoricalGradePoint[] {
const buckets = new Map<string, number[]>();
for (const point of historical) {
const key = monthKey(point.date);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(point.average);
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, values]) => ({
date: new Date(`${key}-01`),
average: values.reduce((sum, value) => sum + value, 0) / values.length,
}));
}
/**
* Blend Holt-Winters-style smoothing with weighted regression, then damp
* toward the recent mean so extreme projections stay realistic for grades.
*/
export function computeGradeForecast(
historical: HistoricalGradePoint[],
monthsForward: number,
): GradeForecastResult | null {
const horizon = Math.min(MAX_MONTHS, Math.max(1, Math.round(monthsForward)));
const sorted = [...historical]
.filter((p) => Number.isFinite(p.average))
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (sorted.length < MIN_POINTS) return null;
const values = sorted.map((p) => p.average);
const holt = holtLinearForecast(values, horizon);
const regression = weightedLinearRegression(values);
const recentMean = values.slice(-3).reduce((sum, v) => sum + v, 0) / Math.min(3, values.length);
const lastDate = sorted[sorted.length - 1].date;
const points: ForecastPoint[] = [];
for (let i = 0; i < horizon; i++) {
const holtValue = holt[i] ?? regression.forecasts[i] ?? recentMean;
const regValue = regression.forecasts[i] ?? holtValue;
const blended = holtValue * 0.58 + regValue * 0.42;
const damped = blended * 0.86 + recentMean * 0.14;
points.push({
date: addMonths(lastDate, i + 1),
value: Math.round(clampGrade(damped) * 10) / 10,
});
}
const projectedGrade = points[points.length - 1]?.value ?? recentMean;
const trendPerMonth =
points.length > 1
? (points[points.length - 1].value - values[values.length - 1]) / points.length
: regression.slope;
return {
points,
projectedGrade,
trendPerMonth: Math.round(trendPerMonth * 10) / 10,
rSquared: Math.round(regression.rSquared * 100) / 100,
methodLabel: "Holt linear + weighted regression",
};
}
+7 -2
View File
@@ -42,7 +42,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.7.0 Grade Analytics, Enhanced Navigation, Global Search & SEQTA Engage Improvements</h1>
<h1>3.7.0 Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements</h1>
<li>Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.</li>
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
<li>Added Grade distribution auto-detects your schools letter scale from released marks for analytics page.</li>
@@ -50,11 +50,16 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Added shortcuts to SEQTA Engage home page.</li>
<li>Added assessments overview and assessment weighting overrides for SEQTA Engage.</li>
<li>Added BetterSEQTA sidebar icons to SEQTA Engage.</li>
<li>Added runtime handlers for upcoming interactive theme.</li>
<li>Added more capabilities for an upcoming interactive theme.</li>
<li>Added font picker in settings.</li>
<li>Added rubric copy on assessment detail pages.</li>
<li>Added manual weight entry when an assessment weight is N/A.</li>
<li>Added an automatic reindex of assessments if any of a series of tracked values change (title, release state, etc). Helps keep weightings up to date.</li>
<li>Fixed BetterSEQTA sidebar injection issues on some pages.</li>
<li>Tweak Theme of the Month popup making it more clear about dismissals and respecting “Dont show again”.</li>
<li>Fixed duplicate-result fixes.</li>
<li>Improved assessments overview with a better look.</li>
<li>Fixed assement overview showing old subjects.</li>
<h1>3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
<li>Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.</li>